Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
# The MechatronicsAdmin team owns everything: PRs require their review.
* @dpengineering/mechatronicsadmin
12 changes: 2 additions & 10 deletions .github/workflows/build-image.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
# Build + publish the DPEA Pi image.
# Rebuilds when an image-affecting file changes on main, and on demand.
# Customizes the stock Raspberry Pi OS image (see build-image.sh) on a native
# arm64 runner, so the chroot's arm64 binaries run natively. Requires the 5358
# avahi .deb published by the avahi_0.8 repo's CI.

name: build-dpea-pi-image

on:
Expand Down Expand Up @@ -43,9 +37,7 @@ jobs:
. .build/image-meta.env
TAG="dpea-pi-$(date +%Y%m%d-%H%M)"
IMG_URL="/${GITHUB_REPOSITORY}/releases/download/${TAG}/${IMG_FILE}"
# os-list.json = a Raspberry Pi Imager repo with one entry. init_format
# enables the Customisation panel; the constant asset name means
# .../releases/latest/download/os-list.json is a stable URL for Imager.
# init_format enables Imager's Customisation panel.
jq -n \
--arg url "$IMG_URL" \
--arg date "$(date +%Y-%m-%d)" \
Expand All @@ -67,7 +59,7 @@ jobs:
echo "IMG_FILE=$IMG_FILE" >> "$GITHUB_ENV"

- name: Publish latest Release
if: github.event_name != 'pull_request' # publish on push + manual dispatch
if: github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
run: |
Expand Down
22 changes: 3 additions & 19 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
# PR validation: make sure a change would still produce a working image before it
# merges to main. Much cheaper than a full build: it runs the customization in a
# container, not a full loop-mounted image.

name: validate

on:
Expand All @@ -18,7 +14,7 @@ jobs:
shellcheck -S error build-image.sh customize.sh hardware-smoke-test.sh

customize-in-container:
runs-on: ubuntu-24.04-arm # native arm64 container
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- name: Run customize.sh in an arm64 Trixie container and assert
Expand All @@ -32,37 +28,25 @@ jobs:
install -d -m 700 /etc/NetworkManager/system-connections
install -m 600 /src/files/dpea-eth0.nmconnection \
/etc/NetworkManager/system-connections/dpea-eth0.nmconnection
# Simulate the real RPi OS Desktop base, which already ships stock avahi.
# A bare Trixie container does NOT, so without this the avahi-dpea
# file-overwrite conflict (a missing Replaces:) stays hidden here and only
# surfaces on the real image build. Installing stock avahi first makes CI
# reproduce that conflict, which is the whole point of this gate.
# Reproduce replacement conflicts with the stock avahi package.
apt-get install -y avahi-daemon libnss-mdns
# The real base image also ships an RPi-MODIFIED avahi-daemon.conf, so
# replacing it triggers an interactive dpkg conffile prompt that aborts a
# non-interactive install. Dirty the conffile here so CI reproduces that
# too, not just the file-overwrite conflict.
# Reproduce the conffile prompt caused by Raspberry Pi OS's modified config.
echo '# dpea-ci: simulate a base-image modified conffile' >> /etc/avahi/avahi-daemon.conf
bash /src/customize.sh
echo "===== assertions ====="
fail=0
command -v uv >/dev/null && echo "OK uv $(uv --version)" || { echo "FAIL uv"; fail=1; }
dpkg -s avahi-dpea >/dev/null 2>&1 && echo "OK avahi-dpea installed" || { echo "FAIL avahi-dpea"; fail=1; }
# It must actually take OVER the stock daemon binary, not just install
# alongside it. Replaces: transfers file ownership to avahi-dpea.
dpkg -S /usr/sbin/avahi-daemon 2>/dev/null | grep -q '^avahi-dpea:' \
&& echo "OK /usr/sbin/avahi-daemon owned by avahi-dpea" \
|| { echo "FAIL avahi-daemon binary not replaced by avahi-dpea"; fail=1; }
for line in "dtparam=i2c_arm=on" "enable_uart=1" "dtoverlay=disable-bt"; do
grep -qx "$line" /boot/firmware/config.txt && echo "OK config: $line" || { echo "FAIL config: $line"; fail=1; }
done
# SPI must NOT be enabled: it would claim GPIO7 (SPI0 CE1), a DPi computer input.
grep -qx "dtparam=spi=on" /boot/firmware/config.txt && { echo "FAIL config: spi should be off"; fail=1; } || echo "OK config: spi not enabled"
# serial login console masked so the DPi RS485 bus owns /dev/serial0.
test -L /etc/systemd/system/serial-getty@ttyAMA0.service && echo "OK serial getty masked" || { echo "FAIL serial getty not masked"; fail=1; }
test -f /etc/NetworkManager/system-connections/dpea-eth0.nmconnection \
&& echo "OK eth0 profile present" || { echo "FAIL eth0 profile"; fail=1; }
# best-effort: mDNS binds 5358 (a container may not let the daemon start; verify on hardware)
(service dbus start >/dev/null 2>&1 || true; avahi-daemon -D >/dev/null 2>&1 || true; sleep 2)
if ss -lun 2>/dev/null | grep -q ":5358"; then echo "OK avahi listening on 5358"
else echo "WARN could not confirm 5358 in container (verify on hardware)"; fi
Expand Down
26 changes: 4 additions & 22 deletions build-image.sh
Original file line number Diff line number Diff line change
@@ -1,25 +1,12 @@
#!/usr/bin/env bash
# Build the DPEA Raspberry Pi OS image by customizing the stock Raspberry Pi OS
# arm64 image: download it, grow it, loop-mount it, run customize.sh in a chroot,
# then recompress.
#
# Invoked by the build-image GitHub Actions workflow (.github/workflows/build-image.yml),
# which runs on a native arm64 runner, so the chroot's arm64 binaries run natively.
# Must run as root (loop-mount + chroot).
#
# Tools: xz-utils parted e2fsprogs curl
#
# PREREQUISITE: the 5358 avahi .deb must be published (avahi_0.8 CI). The chroot
# step hard-fails without it.

set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
WORK="${WORK:-$HERE/.build}"

# Stock Raspberry Pi OS (arm64, desktop). PIN a dated release for reproducibility;
# check https://downloads.raspberrypi.com/raspios_arm64/images/ for the current one.
# Keep this dated URL pinned so builds are reproducible.
BASE_URL="${BASE_URL:-https://downloads.raspberrypi.com/raspios_arm64/images/raspios_arm64-2026-06-19/2026-06-18-raspios-trixie-arm64.img.xz}"
GROW_MB="${GROW_MB:-2048}" # headroom for our added packages
GROW_MB="${GROW_MB:-2048}"
OUT_IMG="$WORK/dpea-pi-$(date +%Y%m%d).img"

[ "$(id -u)" -eq 0 ] || { echo "run as root (loop-mount + chroot)" >&2; exit 1; }
Expand All @@ -30,9 +17,8 @@ base_xz="$(basename "$BASE_URL")"; base_img="${base_xz%.xz}"
[ -f "$base_img" ] || xz -dk "$base_xz"
cp -f "$base_img" "$OUT_IMG"

# --- grow the image file + root partition (p2) ---
truncate -s "+${GROW_MB}M" "$OUT_IMG"
LOOP="$(losetup -fP --show "$OUT_IMG")" # exposes ${LOOP}p1 (boot) + ${LOOP}p2 (root)
LOOP="$(losetup -fP --show "$OUT_IMG")"
cleanup() {
set +e
for d in run sys proc dev/pts dev; do umount "$WORK/mnt/$d" 2>/dev/null; done
Expand All @@ -45,14 +31,12 @@ parted -s "$LOOP" resizepart 2 100%
e2fsck -fy "${LOOP}p2" || true
resize2fs "${LOOP}p2"

# --- mount root + boot + pseudo-filesystems ---
MNT="$WORK/mnt"; mkdir -p "$MNT"
mount "${LOOP}p2" "$MNT"
mount "${LOOP}p1" "$MNT/boot/firmware"
for d in dev dev/pts proc sys run; do mount --bind "/$d" "$MNT/$d"; done
cp /etc/resolv.conf "$MNT/etc/resolv.conf"

# --- drop our files in and run the customization ---
install -m 644 "$HERE/packages.txt" "$MNT/tmp/packages.txt"
install -d -m 700 "$MNT/etc/NetworkManager/system-connections"
install -m 600 "$HERE/files/dpea-eth0.nmconnection" \
Expand All @@ -61,11 +45,9 @@ install -m 755 "$HERE/customize.sh" "$MNT/tmp/customize.sh"
chroot "$MNT" /bin/bash /tmp/customize.sh
rm -f "$MNT/tmp/customize.sh"

# --- unmount (via trap) + compress ---
cleanup; trap - EXIT

# Record metadata for the Raspberry Pi Imager OS list (os-list.json, built by CI):
# the sha256 + size are of the UNCOMPRESSED image, which Imager verifies.
# Raspberry Pi Imager verifies the uncompressed image size and checksum.
EXTRACT_SIZE="$(stat -c%s "$OUT_IMG")"
EXTRACT_SHA256="$(sha256sum "$OUT_IMG" | awk '{print $1}')"
xz -T0 -f "$OUT_IMG"
Expand Down
36 changes: 4 additions & 32 deletions customize.sh
Original file line number Diff line number Diff line change
@@ -1,76 +1,48 @@
#!/bin/bash
# DPEA image customizations. Runs INSIDE the mounted image chroot (see build-image.sh),
# and inside the CI validation container (see .github/workflows/validate.yml).
# Expects /tmp/packages.txt already copied in, and the NetworkManager profile
# already placed at /etc/NetworkManager/system-connections/dpea-eth0.nmconnection.

# Both callers invoke this as `bash customize.sh`, which ignores the shebang's
# flags, so set the strict flags in the body or a failed step is silently skipped.
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive

# --- shared non-Python system packages ---
apt-get update
# shellcheck disable=SC2046
apt-get install -y --no-install-recommends \
$(sed -e 's/#.*//' -e '/^[[:space:]]*$/d' /tmp/packages.txt)

# --- hardware interfaces (edit config.txt directly) ---
# SPI is deliberately NOT enabled: the DPi computer board uses GPIO7 (SPI0 CE1)
# as a digital input (_IN_3_PIN), so an enabled SPI overlay claims that pin and
# the board's GPIO.setup fails with "GPIO busy".
# as a digital input, and the SPI overlay would claim it.
CONFIG=/boot/firmware/config.txt
[ -f "$CONFIG" ] || CONFIG=/boot/config.txt
grep -q '^dtparam=i2c_arm=on' "$CONFIG" || echo 'dtparam=i2c_arm=on' >> "$CONFIG"
grep -q '^enable_uart=1' "$CONFIG" || echo 'enable_uart=1' >> "$CONFIG"
grep -q '^dtoverlay=disable-bt' "$CONFIG" || echo 'dtoverlay=disable-bt' >> "$CONFIG"
grep -q '^i2c-dev' /etc/modules || echo 'i2c-dev' >> /etc/modules

# --- serial login console OFF (the UART hardware stays on via enable_uart above) ---
# Raspberry Pi OS runs a getty on the serial console by default; it holds
# /dev/serial0 open and fights the DPi RS485 bus (serial.Serial on /dev/serial0),
# giving "device reports readiness to read but returned no data (multiple access
# on port?)". Disable it two ways: drop the console= token from cmdline.txt, and
# mask the serial getty. enable_uart=1 above keeps the UART itself available.
# The default serial getty competes with the DPi RS485 bus for /dev/serial0.
CMDLINE=/boot/firmware/cmdline.txt
[ -f "$CMDLINE" ] || CMDLINE=/boot/cmdline.txt
[ -f "$CMDLINE" ] && sed -i -E 's/ ?console=(serial0|ttyAMA0|ttyS0),[0-9]+//g' "$CMDLINE"
install -d /etc/systemd/system
ln -sf /dev/null /etc/systemd/system/serial-getty@ttyAMA0.service

# --- patched avahi (mDNS on 5358) from the prebuilt arm64 .deb (avahi_0.8 CI) ---
AVAHI_DEB_URL="${AVAHI_DEB_URL:-https://github.com/dpengineering/avahi_0.8/releases/latest/download/avahi-dpea_0.8_arm64.deb}"
if curl -fLsS "$AVAHI_DEB_URL" -o /tmp/avahi.deb; then
# The .deb Replaces stock avahi and ships /etc/avahi/avahi-daemon.conf as a
# conffile. The base image's copy is RPi-modified, so dpkg would raise an
# interactive conffile prompt and abort with "end of file on stdin" in this
# non-interactive chroot. --force-confold keeps the base config (our 5358 patch
# is in the binary/lib, not the conf), --force-confdef handles the rest.
# Keep Raspberry Pi OS's modified config to avoid an interactive dpkg prompt.
apt-get install -y \
-o Dpkg::Options::=--force-confdef \
-o Dpkg::Options::=--force-confold \
/tmp/avahi.deb
# The stock RPi OS Desktop base already ships avahi, so the .deb must overwrite
# its files (it declares Replaces: for them). A dpkg file-overwrite conflict can
# leave the package uninstalled while apt still exits 0, so assert it is actually
# present instead of trusting the exit code.
# A dpkg file conflict can leave the package absent even when apt exits 0.
dpkg -s avahi-dpea >/dev/null 2>&1 \
|| { echo "ERROR: avahi-dpea did not install (dpkg file conflict / missing Replaces:?)" >&2; exit 1; }
# Refresh the linker cache so the patched libavahi-core (mDNS on 5358) it just
# dropped in is the one the daemon loads, not the stock lib it overwrote.
ldconfig
# Hold both so an apt upgrade cannot restore the stock 5353 build over ours.
apt-mark hold avahi-daemon avahi-dpea >/dev/null 2>&1 || true
rm -f /tmp/avahi.deb
else
echo "ERROR: prebuilt 5358 avahi .deb not found at $AVAHI_DEB_URL" >&2
exit 1
fi

# --- uv, system-wide ---
curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh

# --- lock down the baked NetworkManager profile ---
chown root:root /etc/NetworkManager/system-connections/dpea-eth0.nmconnection
chmod 600 /etc/NetworkManager/system-connections/dpea-eth0.nmconnection

Expand Down
4 changes: 1 addition & 3 deletions files/dpea-eth0.nmconnection
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ interface-name=eth0
autoconnect=true

[ipv4]
# DHCP for internet when plugged into a real network, PLUS an always-on static
# for a direct laptop-to-Pi cable (set the laptop's ethernet to 172.17.21.1).
# No gateway on the static, so it never competes for the default route.
# The gateway-free static address supports direct connections without replacing DHCP's route.
method=auto
address1=172.17.21.2/22
may-fail=true
Expand Down
9 changes: 1 addition & 8 deletions hardware-smoke-test.sh
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
#!/usr/bin/env bash
# Smoke-test a flashed DPEA Pi. Run ON the Pi after its first boot:
# sudo ./hardware-smoke-test.sh
# Exits non-zero if any check fails. Covers the things the CI container cannot:
# real device nodes, the serial mapping, live mDNS port, and the eth0 address.

set -u
pass=0; fail=0; skipped=0
chk(){ if eval "$2" >/dev/null 2>&1; then echo "PASS $1"; pass=$((pass+1)); else echo "FAIL $1"; fail=$((fail+1)); fi; }
Expand All @@ -16,9 +11,7 @@ chk "/dev/serial0 -> ttyAMA0" "[ \"\$(readlink -f /dev/serial0)\" = /dev/tty
chk "serial login console off" "! systemctl is-active --quiet serial-getty@ttyAMA0.service"
chk "avahi listening on 5358" "ss -lun | grep -q ':5358'"
chk "avahi NOT on 5353" "! ss -lun | grep -q ':5353'"
# The eth0 static (172.17.21.2) only activates when the link has carrier. Running
# this over wifi with no ethernet cable is not an image defect, so SKIP rather than
# FAIL when eth0 has no carrier; plug a cable (or the direct laptop link) and re-run.
# The static address cannot activate without an Ethernet carrier.
if [ "$(cat /sys/class/net/eth0/carrier 2>/dev/null || echo 0)" = 1 ]; then
chk "eth0 holds 172.17.21.2" "ip -4 addr show eth0 | grep -q '172.17.21.2'"
else
Expand Down
2 changes: 0 additions & 2 deletions imager-launchers/dpea-pi-imager.bat
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
@echo off
rem Windows: double-click to launch Raspberry Pi Imager preloaded with the DPEA
rem image list, so "DPEA Pi" appears with the Customisation panel enabled.
start "" "%ProgramFiles%\Raspberry Pi Imager\rpi-imager.exe" --repo https://github.com/dpengineering/DPEA-RaspberryPiImage/releases/latest/download/os-list.json
2 changes: 0 additions & 2 deletions imager-launchers/dpea-pi-imager.command
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
#!/bin/bash
# macOS: double-click to launch Raspberry Pi Imager preloaded with the DPEA image
# list, so "DPEA Pi" appears in the OS list with the Customisation panel enabled.
exec "/Applications/Raspberry Pi Imager.app/Contents/MacOS/rpi-imager" \
--repo https://github.com/dpengineering/DPEA-RaspberryPiImage/releases/latest/download/os-list.json
24 changes: 1 addition & 23 deletions packages.txt
Original file line number Diff line number Diff line change
@@ -1,41 +1,19 @@
# DPEA Raspberry Pi image: shared apt packages (single source of truth)
# ----------------------------------------------------------------------
# NON-Python OS packages ONLY. Every Python library (kivy, pidev, dpeaDPi,
# RPi.GPIO, spidev, smbus2, adafruit-*) is a per-repo uv dependency declared in
# that repo's pyproject.toml. apt python3-* LIBRARY packages do NOT populate a uv
# venv, so they never belong here. The lone exception is build tooling: python3-dev
# ships the CPython headers used to COMPILE a C extension (e.g. lgpio) into a venv,
# it is not itself a venv library.
#
# Read during the image build (build-image.sh copies it in, customize.sh installs
# it). Lines starting with # and blank lines are ignored. Keep this list
# documented in README.md.

# --- core ---
git
curl
ca-certificates

# --- I2C bus tooling (SlushEngine + DPi boards; SPI is left off, see customize.sh) ---
i2c-tools

# --- rpi-lgpio build toolchain (DPi boards) ---
# dpeaDPi imports RPi.GPIO, provided on modern Pi OS / Python 3.13 by rpi-lgpio,
# whose lgpio dep has no aarch64 cp313 wheel and so compiles from source at
# `uv sync` time. swig + python3-dev build the C extension, liblgpio-dev links it
# (and pulls the runtime liblgpio shared lib). See the python3-dev note up top.
# lgpio has no aarch64 Python 3.13 wheel, so uv builds it from source.
swig
python3-dev
liblgpio-dev

# --- kivy runtime: SDL2 + multitouch + GL (the kivy wheel links these at runtime) ---
libsdl2-2.0-0
libsdl2-image-2.0-0
libsdl2-mixer-2.0-0
libsdl2-ttf-2.0-0
libmtdev1
libgl1-mesa-dri

# --- mDNS / .local name resolution (client-side NSS glue; the patched 5358
# avahi daemon itself is installed separately from a prebuilt .deb) ---
libnss-mdns
Loading