# Yocto Image for Raspberry Pi — ECU Test Framework as a Bench Appliance

This guide explains how to build a custom Linux distribution with
the Yocto Project so a Raspberry Pi *is* the test bench: power it
on, it boots, the test framework runs the configured suites against
a connected MUM and ECU, and reports land in a known location (or
get pushed to a server). No PC in the loop.

If you only want to run the framework on a stock Raspberry Pi OS
install, you're looking at
[`docs/09_raspberry_pi_deployment.md`](09_raspberry_pi_deployment.md).
If you want a pre-baked Pi OS image (still Debian, just snapshotted
with the framework already installed), see
[`docs/10_build_custom_image.md`](10_build_custom_image.md). This
document is about Yocto specifically — building a minimal, hardened,
reproducible OS from sources around the framework.

---

## 1. Why Yocto vs. Raspberry Pi OS?

| Concern | Raspberry Pi OS (Debian) | Yocto |
|---|---|---|
| First image up | Hours | First build: a day. Subsequent builds: hours. |
| Image size | ~2–4 GB minimum (Lite) | ~150–500 MB realistic |
| Reproducibility | Snapshot of `apt` state at image time | Full source pinning via layer revisions |
| Auditing "what's installed" | `dpkg -l` of a moving target | Single manifest, version-pinned |
| Hardening / removing surface | Have to disable / uninstall | Just don't include the recipe |
| Boot time to test | 30–60 s | 5–15 s with a tuned image |
| Building a fleet | Re-snapshot per change | Rebuild image, push artifact |
| Build host requirements | Pi + SD card | Linux build host with ~100 GB free and ~16 GB RAM ideally |

Pick Yocto when **the Pi is a deployed appliance**, not a
workstation — a permanent bench, a HIL rack, a customer-shipped test
fixture. For day-to-day developer work the Pi OS path is fine.

---

## 2. Architecture

```
                                ┌────────────────────────────────┐
                                │  Raspberry Pi (Yocto image)    │
                                │                                │
                ┌── Ethernet ───┤  192.168.7.1   (host on RNDIS) │
                │               │                                │
┌───────────────┴──────────┐    │  ecu-test-framework systemd    │
│  MUM @ 192.168.7.2       │    │   service:                     │
│  USB-RNDIS (or wired)    │    │     pytest -m "hardware and    │
└──────────────────────────┘    │            mum and not slow"   │
                                │                                │
                                │  /opt/ecu-tests/   (the repo)  │
                                │  /opt/ecu-tests/.venv/         │
                                │                                │
                ┌── USB-serial ─┤  /dev/ttyUSB0  (Owon PSU)      │
                │               │                                │
┌───────────────┴──────────┐    │  /var/log/ecu-tests/           │
│  Owon PSU                │    │     report.html, junit.xml,    │
└──────────────────────────┘    │     summary.md                 │
                                │                                │
                                │  rsync/scp/HTTP push of /var/  │
                                │  log/ecu-tests/ to a server    │
                                └────────────────────────────────┘
```

Key choices made by this document:

- **`meta-raspberrypi`** as the BSP for `raspberrypi4-64` (or `-3`,
  `-cm4`, depending on your hardware).
- **`meta-openembedded`** for Python + general userspace.
- **A new layer `meta-ecu-tests`** holds: the framework recipe,
  recipes for the non-PyPI Python deps, the image recipe, and the
  systemd unit.
- **`systemd`** init system (Yocto's `core-image-minimal` defaults
  to sysvinit; we override to `systemd`).
- **Pinned Yocto release**: `scarthgap` (LTS, May 2024). Pick a
  current LTS at build time; this doc shows scarthgap.

---

## 3. Build-host prerequisites

A Linux machine (Ubuntu 22.04 LTS or Debian 12 are the smoothest;
WSL2 works but is slower and consumes a lot of disk).

**Resources:**

- 100 GB free disk (the first `bitbake` run downloads sources and
  builds toolchains)
- 16 GB RAM ideal, 8 GB workable
- Multi-core CPU; expect 1–4 h for the first image build

**Packages (Ubuntu 22.04):**

```bash
sudo apt update
sudo apt install -y \
    gawk wget git diffstat unzip texinfo gcc build-essential chrpath socat \
    cpio python3 python3-pip python3-pexpect xz-utils debianutils iputils-ping \
    python3-git python3-jinja2 python3-subunit zstd liblz4-tool file locales \
    libacl1
sudo locale-gen en_US.UTF-8
```

Make sure your user can run docker if you plan to use the
`kas-container` shortcut; not required for the manual `bitbake`
flow shown here.

---

## 4. Layer layout

```
~/yocto/
├── poky/                    # Yocto core, ~3 GB
├── meta-openembedded/       # community python/network/etc. layers
├── meta-raspberrypi/        # Raspberry Pi BSP
├── meta-ecu-tests/          # ← we create this
│   ├── conf/
│   │   └── layer.conf
│   ├── recipes-ecu-tests/
│   │   ├── ecu-test-framework_git.bb
│   │   ├── ecu-test-framework/
│   │   │   ├── ecu-test-framework.service
│   │   │   ├── ecu-test-runner.sh
│   │   │   └── push-reports.sh
│   │   └── python3-melexis/
│   │       ├── python3-pylin_1.2.0.bb
│   │       ├── python3-pymumclient_1.2.0.bb
│   │       └── python3-pylinframe_1.2.0.bb
│   ├── recipes-python/
│   │   └── python3-ldfparser_<ver>.bb
│   └── recipes-images/
│       └── ecu-tests-image.bb
└── build/                   # bitbake's TMPDIR (huge)
```

---

## 5. Setting up the build environment

### 5.1 Clone Yocto + BSP + needed layers

```bash
mkdir -p ~/yocto && cd ~/yocto

BRANCH=scarthgap

git clone -b $BRANCH https://git.yoctoproject.org/git/poky
git clone -b $BRANCH https://git.openembedded.org/meta-openembedded
git clone -b $BRANCH https://git.yoctoproject.org/git/meta-raspberrypi
```

### 5.2 Bootstrap the build directory

```bash
source poky/oe-init-build-env build
# you are now in ~/yocto/build/
```

### 5.3 Tell bitbake which layers exist

`conf/bblayers.conf` should look like:

```bitbake
BBLAYERS ?= " \
  ${TOPDIR}/../poky/meta \
  ${TOPDIR}/../poky/meta-poky \
  ${TOPDIR}/../poky/meta-yocto-bsp \
  ${TOPDIR}/../meta-openembedded/meta-oe \
  ${TOPDIR}/../meta-openembedded/meta-python \
  ${TOPDIR}/../meta-openembedded/meta-networking \
  ${TOPDIR}/../meta-raspberrypi \
  ${TOPDIR}/../meta-ecu-tests \
"
```

(`meta-ecu-tests` will be created in §6 — bitbake will warn until
it exists, that's fine.)

### 5.4 Configure the build target

`conf/local.conf` — append/edit:

```bitbake
MACHINE = "raspberrypi4-64"

DISTRO = "poky"

# Init manager
DISTRO_FEATURES:append = " systemd"
VIRTUAL-RUNTIME:init_manager = "systemd"
VIRTUAL-RUNTIME:initscripts = ""
DISTRO_FEATURES_BACKFILL_CONSIDERED += "sysvinit"

# We want SSH for first-boot diagnosis
EXTRA_IMAGE_FEATURES ?= "debug-tweaks ssh-server-openssh"

# Make sure Python 3 ends up in the image
IMAGE_INSTALL:append = " python3 python3-modules"

# Speed up downloads and rebuilds by sharing caches
DL_DIR ?= "${TOPDIR}/downloads"
SSTATE_DIR ?= "${TOPDIR}/sstate-cache"
BB_NUMBER_THREADS = "${@oe.utils.cpu_count()}"
PARALLEL_MAKE = "-j${@oe.utils.cpu_count()}"

# Raspberry Pi specifics
ENABLE_UART = "1"            # serial console on UART
RPI_USE_U_BOOT = "0"
DISABLE_RPI_BOOT_LOGO = "1"
```

For a Raspberry Pi 3, change `MACHINE = "raspberrypi3-64"`. For
Compute Module 4: `MACHINE = "raspberrypi-cm4"`. Each lists in
`meta-raspberrypi/conf/machine/`.

---

## 6. Create `meta-ecu-tests`

### 6.1 Skeleton

```bash
cd ~/yocto
mkdir -p meta-ecu-tests/{conf,recipes-ecu-tests,recipes-python,recipes-images}
mkdir -p meta-ecu-tests/recipes-ecu-tests/ecu-test-framework
mkdir -p meta-ecu-tests/recipes-ecu-tests/python3-melexis
```

`meta-ecu-tests/conf/layer.conf`:

```bitbake
BBPATH .= ":${LAYERDIR}"
BBFILES += "${LAYERDIR}/recipes-*/*/*.bb \
            ${LAYERDIR}/recipes-*/*/*.bbappend"

BBFILE_COLLECTIONS += "ecu-tests"
BBFILE_PATTERN_ecu-tests = "^${LAYERDIR}/"
BBFILE_PRIORITY_ecu-tests = "10"
LAYERSERIES_COMPAT_ecu-tests = "scarthgap"

LAYERDEPENDS_ecu-tests = "core meta-python openembedded-layer raspberrypi"
```

### 6.2 Recipe — the framework itself

`meta-ecu-tests/recipes-ecu-tests/ecu-test-framework_git.bb`:

```bitbake
SUMMARY = "ECU Test Framework (pytest-based MUM/PSU bench runner)"
DESCRIPTION = "Hardware-in-the-loop test suite for the 4SEVEN ALM ECU."
LICENSE = "CLOSED"
LIC_FILES_CHKSUM = ""

SRC_URI = " \
    git://your-git-host/ecu-tests.git;branch=main;protocol=https \
    file://ecu-test-framework.service \
    file://ecu-test-runner.sh \
    file://push-reports.sh \
"

SRCREV = "${AUTOREV}"
PV = "0.1+git${SRCPV}"
S = "${WORKDIR}/git"

inherit systemd

SYSTEMD_SERVICE:${PN} = "ecu-test-framework.service"
SYSTEMD_AUTO_ENABLE = "enable"

RDEPENDS:${PN} = " \
    python3-pytest \
    python3-pytest-html \
    python3-pytest-cov \
    python3-pytest-xdist \
    python3-pyserial \
    python3-pyyaml \
    python3-ldfparser \
    python3-pylin \
    python3-pymumclient \
    python3-pylinframe \
    bash \
"

do_install() {
    install -d ${D}/opt/ecu-tests
    cp -a ${S}/* ${D}/opt/ecu-tests/
    # Strip any committed venv / cache / reports
    rm -rf ${D}/opt/ecu-tests/.venv ${D}/opt/ecu-tests/.pytest_cache \
           ${D}/opt/ecu-tests/reports/*
    install -d ${D}/var/log/ecu-tests
    install -d ${D}/etc/ecu-tests
    install -m 0755 ${WORKDIR}/ecu-test-runner.sh ${D}/opt/ecu-tests/
    install -m 0755 ${WORKDIR}/push-reports.sh   ${D}/opt/ecu-tests/
    install -d ${D}${systemd_system_unitdir}
    install -m 0644 ${WORKDIR}/ecu-test-framework.service \
                    ${D}${systemd_system_unitdir}/
}

FILES:${PN} = " \
    /opt/ecu-tests \
    /var/log/ecu-tests \
    /etc/ecu-tests \
    ${systemd_system_unitdir}/ecu-test-framework.service \
"
```

Replace `git://your-git-host/ecu-tests.git;branch=main;protocol=https`
with your actual remote. For an air-gapped build, ship the repo as
a tarball: `SRC_URI = "file://ecu-tests.tar.gz"` and place it next
to the recipe.

### 6.3 Recipe — runner script

`meta-ecu-tests/recipes-ecu-tests/ecu-test-framework/ecu-test-runner.sh`:

```bash
#!/bin/sh
set -eu

REPO=/opt/ecu-tests
LOG=/var/log/ecu-tests
RUN_TS=$(date -u +%Y%m%dT%H%M%SZ)
OUT="$LOG/$RUN_TS"
mkdir -p "$OUT"

cd "$REPO"

# Marker selection lives in /etc/ecu-tests/marker (a single line, e.g.:
#   hardware and mum and not slow
# Defaults to a safe non-slow MUM run.
MARKER=$(cat /etc/ecu-tests/marker 2>/dev/null || echo "hardware and mum and not slow")

ECU_TESTS_CONFIG=/etc/ecu-tests/test_config.yaml \
python3 -m pytest -m "$MARKER" -v \
    --junitxml="$OUT/junit.xml" \
    --html="$OUT/report.html" --self-contained-html \
    --tb=short 2>&1 | tee "$OUT/run.log" || true

# Symlink "latest" for convenience
ln -sfn "$RUN_TS" "$LOG/latest"

# Optional: rsync to a server. Reads RSYNC_DEST from
# /etc/ecu-tests/push.env. Silently no-ops if unset.
. /etc/ecu-tests/push.env 2>/dev/null || true
[ -n "${RSYNC_DEST:-}" ] && /opt/ecu-tests/push-reports.sh "$OUT" "$RSYNC_DEST" || true
```

`push-reports.sh` is a thin `rsync` wrapper — left as an exercise
for your network setup (or replace with `curl` to an HTTP collector,
or `mosquitto_pub` to MQTT — whatever your infra prefers).

### 6.4 Recipe — systemd unit

`meta-ecu-tests/recipes-ecu-tests/ecu-test-framework/ecu-test-framework.service`:

```ini
[Unit]
Description=ECU Test Framework one-shot run
After=network-online.target dev-ttyUSB0.device
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/opt/ecu-tests/ecu-test-runner.sh
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
```

Pair this with a `.timer` if you want periodic runs, or leave as a
one-shot triggered by reboot or `systemctl start
ecu-test-framework.service` over SSH.

For continuous runs (every N minutes), add
`meta-ecu-tests/recipes-ecu-tests/ecu-test-framework/ecu-test-framework.timer`:

```ini
[Unit]
Description=Run ECU tests every 30 minutes

[Timer]
OnBootSec=2min
OnUnitActiveSec=30min
Unit=ecu-test-framework.service

[Install]
WantedBy=timers.target
```

…and add it to `SYSTEMD_SERVICE:${PN}` in the recipe.

### 6.5 Recipe — `python3-ldfparser`

ldfparser **is** on PyPI but isn't in stock OpenEmbedded. Add a
minimal recipe at
`meta-ecu-tests/recipes-python/python3-ldfparser_0.27.0.bb`
(update the version):

```bitbake
SUMMARY = "Pure-Python LDF parser (LIN Description File)"
HOMEPAGE = "https://github.com/c4deszes/ldfparser"
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://LICENSE;md5=<fill-in-md5>"

SRC_URI = "https://files.pythonhosted.org/packages/source/l/ldfparser/ldfparser-${PV}.tar.gz"
SRC_URI[sha256sum] = "<fill-in-sha256>"

S = "${WORKDIR}/ldfparser-${PV}"

inherit setuptools3 pypi

RDEPENDS:${PN} = "python3-lark python3-bitstruct"
```

Run `bitbake -c devshell python3-ldfparser` and use
`devtool` to compute the hashes if you don't already have them, or
fetch them with:

```bash
pip download ldfparser==0.27.0 --no-deps -d /tmp/ldfparser
sha256sum /tmp/ldfparser/ldfparser-0.27.0.tar.gz
```

### 6.6 Recipes — Melexis non-PyPI packages

`pylin`, `pymumclient`, and `pylinframe` ship inside the Melexis IDE
installer; they're **not** on PyPI and you must source them from a
legally-licensed Melexis install. Each gets a recipe that consumes a
pre-staged tarball under `${BSPDIR}/downloads/`.

Stage once on the build host:

```bash
# On a machine that has Melexis IDE installed
MELEXIS_SITE="/mnt/c/Program Files/Melexis/Melexis IDE/plugins/com.melexis.mlxide.python_1.2.0.202408130945/python/Lib/site-packages"
mkdir -p ~/yocto/downloads
for pkg in pylin pymumclient pylinframe; do
    tar -czf ~/yocto/downloads/${pkg}-1.2.0.tar.gz -C "$MELEXIS_SITE" $pkg
done
```

`meta-ecu-tests/recipes-ecu-tests/python3-melexis/python3-pylin_1.2.0.bb`:

```bitbake
SUMMARY  = "Melexis pylin — proprietary, not redistributable"
DESCRIPTION = "Vendored copy of the pylin package shipped with Melexis IDE."
LICENSE  = "Proprietary"
LIC_FILES_CHKSUM = ""

# License Mode:
# This recipe ships proprietary code. Yocto will refuse to build unless
# you whitelist it. In your conf/local.conf:
#   LICENSE_FLAGS_ACCEPTED += "commercial_pylin commercial_pymumclient commercial_pylinframe"
LICENSE_FLAGS = "commercial_pylin"

# The tarball must be pre-staged at DL_DIR/pylin-${PV}.tar.gz
SRC_URI = "file://pylin-${PV}.tar.gz"
SRC_URI[sha256sum] = "<fill-in>"

S = "${WORKDIR}"

RDEPENDS:${PN} = "python3 python3-modules"

do_install() {
    install -d ${D}${PYTHON_SITEPACKAGES_DIR}
    cp -a ${S}/pylin ${D}${PYTHON_SITEPACKAGES_DIR}/
}

FILES:${PN} = "${PYTHON_SITEPACKAGES_DIR}/pylin"
```

`python3-pymumclient_1.2.0.bb` and `python3-pylinframe_1.2.0.bb`
are the same shape with the package name and `LICENSE_FLAGS`
swapped.

Add to `conf/local.conf`:

```bitbake
LICENSE_FLAGS_ACCEPTED += "commercial_pylin commercial_pymumclient commercial_pylinframe"
```

> **License hygiene**: the resulting image embeds proprietary
> packages. Treat the image artifact as proprietary — same access
> controls as the Melexis IDE installer.

### 6.7 Image recipe

`meta-ecu-tests/recipes-images/ecu-tests-image.bb`:

```bitbake
SUMMARY = "ECU bench image — Raspberry Pi as a test runner"
DESCRIPTION = "Minimal Linux image that boots, configures network, \
and runs the ECU test framework on a schedule."

LICENSE = "MIT"

IMAGE_FEATURES += "ssh-server-openssh"

IMAGE_INSTALL = " \
    packagegroup-core-boot \
    packagegroup-core-ssh-openssh \
    ${CORE_IMAGE_EXTRA_INSTALL} \
    \
    python3 \
    python3-pip \
    python3-pytest \
    python3-pytest-html \
    python3-pytest-cov \
    python3-pytest-xdist \
    python3-pyserial \
    python3-pyyaml \
    \
    python3-ldfparser \
    python3-pylin \
    python3-pymumclient \
    python3-pylinframe \
    \
    ecu-test-framework \
    \
    rsync openssh-sftp-server curl \
    htop nano vim-tiny \
    kernel-modules \
    chrony \
"

# Be explicit about init system in the image
DISTRO_FEATURES:append = " systemd"
VIRTUAL-RUNTIME:init_manager = "systemd"

inherit core-image

# Size constraint (raise if you add a lot of debug tools)
IMAGE_OVERHEAD_FACTOR = "1.3"
IMAGE_ROOTFS_EXTRA_SPACE = "524288"
```

---

## 7. Network configuration

The bench MUM exposes itself as a USB-RNDIS Ethernet device at
`192.168.7.2/24` with the host expected at `192.168.7.1`. Bake the
host-side address into the image so the Pi takes it automatically.

`meta-ecu-tests/recipes-ecu-tests/ecu-test-framework/files/20-mum.network`
(append to the recipe's `SRC_URI` and `do_install`):

```ini
[Match]
# usbX is what the Pi's kernel names the USB-RNDIS device. Verify
# with `ip link` on a running image and adjust if needed (it may be
# enxXXXXXXXXXXXX based on MAC address).
Name=usb0 enx*

[Network]
Address=192.168.7.1/24
LinkLocalAddressing=no
IPMasquerade=no
ConfigureWithoutCarrier=yes
```

The recipe installs this to `/etc/systemd/network/20-mum.network`.
`systemd-networkd` is already enabled when `systemd` is the init
manager.

For a wired connection to the lab network as well, add a second
profile:

```ini
[Match]
Name=eth0

[Network]
DHCP=yes
```

---

## 8. USB / serial configuration

The Owon PSU is a USB-serial device, typically `/dev/ttyUSB0`. To
keep the path stable across reboots when the host has other USB
adapters, add a udev rule.

`meta-ecu-tests/recipes-ecu-tests/ecu-test-framework/files/99-owon-psu.rules`:

```
# Adjust idVendor/idProduct for your specific adapter (check `lsusb` on
# a booted image). The symlink lets config use /dev/owon_psu instead of
# /dev/ttyUSBn, which can shift if multiple adapters are present.
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", \
    MODE="0660", GROUP="dialout", SYMLINK+="owon_psu"
```

Install to `/etc/udev/rules.d/99-owon-psu.rules`. The framework's
`config/test_config.yaml` then carries `port: /dev/owon_psu`
regardless of enumeration order.

---

## 9. The configuration file shipped in the image

`/etc/ecu-tests/test_config.yaml` (installed by the recipe):

```yaml
interface:
  type: mum
  host: 192.168.7.2
  lin_device: lin0
  power_device: power_out0
  bitrate: 19200
  boot_settle_seconds: 0.5
  ldf_path: /opt/ecu-tests/vendor/4SEVEN_color_lib_test.ldf

flash:
  enabled: false

power_supply:
  enabled: true
  port: /dev/owon_psu        # from the udev rule
  baudrate: 115200
  timeout: 2.0
  parity: N
  stopbits: 1
  idn_substr: OWON
  do_set: true
  set_voltage: 13.0
  set_current: 1.0
```

And `/etc/ecu-tests/marker` (single line):

```
hardware and mum and not slow
```

Operators can edit either over SSH without rebuilding the image.

---

## 10. Build, flash, boot

### 10.1 Build

From `~/yocto/build/`:

```bash
bitbake ecu-tests-image
```

First run: 1–4 h depending on your machine. Subsequent rebuilds
(with `sstate-cache` intact): minutes.

Output ends up at
`~/yocto/build/tmp/deploy/images/raspberrypi4-64/ecu-tests-image-raspberrypi4-64.wic.bz2`.

### 10.2 Flash

```bash
# Find the SD card
lsblk
# Assume /dev/sdX is the SD card; double-check before running!

bzcat ~/yocto/build/tmp/deploy/images/raspberrypi4-64/ecu-tests-image-raspberrypi4-64.wic.bz2 \
    | sudo dd of=/dev/sdX bs=4M conv=fsync status=progress
sync
```

Or use `bmaptool` from Yocto for faster flashing of sparse images.

### 10.3 First boot

- Insert the SD card into the Pi.
- Connect: power, USB-Ethernet (MUM), USB-serial (Owon PSU), and
  either Ethernet or HDMI+keyboard for diagnosis.
- Boot.
- SSH in: `ssh root@<ip>` (no password by default thanks to
  `debug-tweaks` — disable that for production builds, see §13).

```bash
journalctl -u ecu-test-framework.service -e
ls /var/log/ecu-tests/latest
cat /var/log/ecu-tests/latest/junit.xml | head
```

---

## 11. Updating the image

There are three ways to push updates without a full re-flash:

| Approach | When | How |
|---|---|---|
| Re-flash | Major changes, package adds | `bitbake ecu-tests-image` → flash |
| In-place git pull | Test-code-only changes | `git -C /opt/ecu-tests pull && systemctl restart ecu-test-framework` |
| RAUC / Mender A/B | Production fleets | Adds an A/B partition layout and an update agent; out of scope for this doc |

For developer iteration, the git-pull path is fastest. The image
should ship with the framework's git remote so `git pull` works
out of the box.

---

## 12. Air-gapped or no-network builds

Yocto can fetch everything locally if you stage:

1. `downloads/` populated by a one-time `bitbake -c fetchall
   ecu-tests-image` on a connected machine.
2. `sstate-cache/` similarly.

Then on the air-gapped builder set:

```bitbake
BB_NO_NETWORK = "1"
BB_FETCH_PREMIRRORONLY = "1"
```

And copy `downloads/` and `sstate-cache/` from the staging machine.

---

## 13. Hardening for production

Before shipping the image to a customer or a permanent installation:

- **Disable `debug-tweaks`** in `EXTRA_IMAGE_FEATURES`. This
  reinstates root password requirement, removes the empty-password
  bypass, and hardens the SSH config.
- **Set a real `ROOT_HOME` password** in a `.bbappend` for the base
  recipe, OR provision SSH keys at first boot, OR enforce
  password-only logins.
- **Read-only rootfs** — Yocto supports `IMAGE_FEATURES +=
  "read-only-rootfs"`. Anything mutable (configs, logs) needs to
  move to `/var` on tmpfs or a persistent partition.
- **Watchdog** — enable the hardware watchdog so the Pi reboots if
  it locks up. `meta-raspberrypi` exposes the BCM watchdog; pair
  with `systemd`'s `WatchdogSec=`.
- **Boot-time integrity** — `secureboot` is not viable on Raspberry
  Pi to the degree it is on automotive ECUs, but you can checksum
  the rootfs and refuse to run tests if it's been tampered with.
- **TLS for report uploads** — if the push step talks HTTP/MQTT to
  a collector, pin the server certificate.

---

## 14. Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| `do_compile` fails for `python3-pylin` with "license not accepted" | Missing `LICENSE_FLAGS_ACCEPTED` entry | Add the three `commercial_*` flags to `conf/local.conf` |
| `pylin` not importable in the running image | Recipe installed to the wrong site-packages path | Confirm `PYTHON_SITEPACKAGES_DIR` in the recipe matches the image's actual path (`python3 -c "import site; print(site.getsitepackages())"` on a booted image) |
| MUM unreachable at boot | `systemd-networkd` profile didn't match the USB iface | `ip link` to find the real name; widen the `Name=` glob |
| Tests fail with "ECU not responding" | Same as above, or the MUM hasn't come up by the time the timer fires | Add `After=network-online.target` (already done) and a startup delay in the runner |
| PSU port not found | udev rule didn't match the adapter; check `lsusb` for VID/PID | Adjust the rule and rebuild, or fall back to `/dev/ttyUSB0` and hope nothing else enumerates first |
| `bitbake` runs out of disk | TMPDIR fills up | Mount `~/yocto/build` on its own disk, or change `TMPDIR` in `local.conf` to a bigger volume |
| First build runs forever | All-from-source compile | This is normal; subsequent builds use the populated `sstate-cache` |
| Image too big for the SD card | Too many extras in `IMAGE_INSTALL` | Trim `htop nano vim-tiny chrony` etc. if you don't need them |

---

## 15. What this gives you vs. running on the Pi directly

| | Pi OS + `pi_install.sh` | Yocto image |
|---|---|---|
| Reproducible | ad-hoc | yes |
| Image footprint | ~2 GB | ~400 MB realistic |
| Boot to first test | ~45 s | ~12 s with a tuned image |
| Updates over the air | manual | feasible with RAUC/Mender |
| Day-to-day dev | comfortable | painful — every change rebuilds |
| Auditing the OS | dpkg snapshot | full source manifest |

Use the Yocto path when the Pi is part of a **deliverable**, when
you need to ship N benches identical, or when an auditor needs a
list of every byte on the device.

---

## 16. Related docs

- [`docs/09_raspberry_pi_deployment.md`](09_raspberry_pi_deployment.md)
  — run the framework on stock Raspberry Pi OS (the lighter path).
- [`docs/10_build_custom_image.md`](10_build_custom_image.md) — a
  preseeded Pi OS image, the middle ground between vanilla Pi OS
  and a Yocto build.
- [`docs/20_docker_image.md`](20_docker_image.md) — if you'd rather
  the framework run from a container on a regular Linux host rather
  than as the host itself.
- [`docs/14_power_supply.md`](14_power_supply.md) — PSU port
  resolution; on the image the udev rule makes `/dev/owon_psu`
  stable, so the resolver's job is trivial.
- [`docs/02_configuration_resolution.md`](02_configuration_resolution.md)
  — how `ECU_TESTS_CONFIG` selects `/etc/ecu-tests/test_config.yaml`
  at runtime.
- `vendor/automated_lin_test/install_packages.sh` — the equivalent
  of the Melexis-recipe step for a developer venv on a workstation.
