Working on this project¶
How to change this repository without breaking a board: where things are, the checks every change has to pass, and the maintenance jobs that are nobody's favorite.
The reasoning behind the checks is the chapter Knowing it works. This page is the detail.
Where things are¶
├── bootstrap.sh stock board -> working board, one command
├── env.sh shell shortcuts + Zephyr environment (sourced by ~/.bashrc)
├── mkdocs.yml the documentation site: theme, navigation, offline rules
├── docs/
│ ├── learn/ the course, in reading order
│ └── reference/ the detail, for someone who already knows
├── mcu/ everything for the STM32U585
│ ├── app/ the firmware: shell + SMP + NVS + LED matrix
│ │ ├── include/ app_proto.h - the MPU<->MCU contract, shared
│ │ └── src/ main.c, matrix.c (charlieplex driver), bars.c (rasterizer)
│ ├── tests/ ztest suites, run on the host with native_sim
│ ├── board-support/ the OpenOCD configuration that names this board's SWD pins
│ ├── zbuild.sh west build + sign + compile_commands
│ ├── flash.sh flash over SWD
│ ├── flash-all.sh bootloader + application, for first flash and recovery
│ ├── ztest.sh the firmware tests
│ ├── link-up.sh BOOT0 + serial-link enable (unoq-link.service runs root's copy)
│ └── restore-arduino-firmware.sh
├── python/ the Linux-side package (an editable install)
│ ├── unoq/ link.py (GPIOs), mcu.py (shell), fota.py,
│ │ cpu.py (/proc/stat), cpubars.py + learn.py (daemons)
│ └── tests/ pytest, all against fakes - no hardware
├── provision/ what bootstrap.sh runs; see provisioning.md
│ ├── lib.sh the primitives that make every step idempotent
│ └── user/ the half that must not run as root
├── usb/ the composite USB gadget: network + fileshare (the services run root's copies)
├── status/ the status LEDs Linux can drive, and their unit (likewise)
├── share/ the drive image the board hands out
├── tests/ bats suites for the shell scripts
└── tools/ check.sh (every gate), and the maintenance scripts
Two paths are load-bearing inside a checkout and must not move: python/ (the
editable install target) and provision/lib.sh (every script sources it by
relative path). env.sh and the scripts named in unit files may move with
the checkout — provision/user/40-shell-env.sh repoints ~/.bashrc, and
install_unit rewrites the unit paths — but not independently of it.
Trying a script change on the board¶
The services that run as root do not run the scripts in the checkout. They run
root-owned copies in /usr/local/lib/unoq/, which a provisioning step makes.
The reason is in What root runs. The
consequence is one extra command.
After editing anything under usb/, status/leds.sh or mcu/link-up.sh:
Any of steps 45, 50 and 95 refreshes every copy, and 50 is the one
that touches nothing else. It prints changed updated … for each script it
replaced, and restarts the LED service if anything changed. When the change
takes effect depends on the script:
| You edited | It takes effect |
|---|---|
status/leds.sh |
at once: step 50 restarts unoq-leds |
usb/usb-dhcp.sh, usb-route.sh, usb-profile.sh |
at the next lease event |
usb/gadget-up.sh, usb-net-up.sh, bind-guard.sh |
at the next cable plug or boot |
usb/gadget-down.sh, uplink-fallback.sh |
at the next stop or boot |
mcu/link-up.sh |
at the next boot |
To put a USB change into effect at once, run 95-usb-gadget.sh instead. It
restarts the gadget, which drops the USB network for a few seconds.
If the board is not doing what the script you are looking at says, compare the two copies:
The bats suites and tools/check-units.sh test the scripts in the checkout
and need none of this.
Two things did not change. A unit that runs as you (unoq-cpu-bars,
unoq-learn) runs the editable Python install, so a Python edit needs only
sudo systemctl restart <unit>. And a script you run by hand runs the copy
you name.
The quality gates¶
One command runs everything, and it is the same command CI runs:
tools/check.sh # every gate
tools/check.sh --fast # skip the slow ones: the firmware tests and the browser check
tools/check.sh --fix # reformat in place, then check
tools/check.sh python # one area: python | shell | c | docs | workflows | diagrams | mcu
Gates keep running after a failure, so one pass shows you all the work rather than the first thing to break. A missing tool is a failure, not a skip: a gate that silently does nothing is worse than no gate.
| Area | Gate | Tool | Covers |
|---|---|---|---|
python |
lint, format | ruff |
python/ |
| types | mypy --strict |
python/, tests included |
|
| tests | pytest + coverage |
python/unoq, held at 100% of branches |
|
shell |
lint | shellcheck -x |
every tracked *.sh |
| format | shfmt -i 2 -ci |
every tracked *.sh |
|
| behavior | bats |
tests/*.bats — see the suite's own guide |
|
| units | tools/check-units.sh |
systemd accepts every unit file and udev rule as provisioning would install them, and everything they start exists — for a root unit, in the set of scripts that is copied for root | |
c |
version pin | tools/check-zephyr-pin.sh |
the board and CI build the same Zephyr |
| lock files | tools/check-constraints.sh |
every Python package the project names is pinned in a lock file, and the Zephyr lock matches the pinned Zephyr | |
| format | clang-format |
mcu/**/*.[ch], in Zephyr's own style |
|
docs |
build | mkdocs --strict |
every internal link and anchor resolves |
docs |
spelling | tools/check-spelling.sh |
American spelling throughout, in comments and messages as well as prose. .codespellrc lists what is deliberately not a misspelling |
| offline | tools/check-diagram-assets.sh |
the diagram library is on disk, not at a CDN | |
| settings | tools/check-config-docs.sh |
What you can change names every setting the code reads | |
| site inputs | tools/check-site-inputs.sh |
no page embeds a file the site's path filters do not watch | |
workflows |
lint | actionlint |
.github/workflows/, including the shell inside every run: block |
diagrams |
render | tools/check-diagrams.sh |
every diagram actually draws, and can be read: lettering displayed at 12px or more and no diagram wider than the text column, in a 1440px window. One headless browser, driven over every page that has one, a few seconds |
mcu |
tests | ztest on native_ |
the protocol in app_ and the rasterizer in bars.c |
Install the tools, and the pre-commit hook that runs --fast:
install-dev-tools.sh puts ruff, mypy, pytest, mkdocs and clang-format in the
project venv, and fetches pinned shellcheck, shfmt, bats and actionlint into
~/.local/bin. It detects the architecture, so the same script works on the
board (aarch64) and on an ordinary x86_64 machine. You do not need a board for
any of them: mcu wants a Zephyr workspace and diagrams wants a browser,
and the rest want nothing.
The pre-commit framework is deliberately not used. It fetches its own pinned
copies of ruff and shellcheck: a second toolchain to keep in step with the one
check.sh uses, on a board with 3.6 GiB of RAM.
The tests¶
(cd python && ../.venv/bin/pytest) # the Linux-side package
(cd python && ../.venv/bin/pytest -m hardware --no-cov) # opt-in: needs a live MCU
bats tests/ # the shell scripts
mcu/ztest.sh # the firmware, on native_sim
Nothing in the default suites touches hardware. Every serial port, GPIO chip and system command is faked, because the suites have to be runnable on a board that is mid-flash — and on a laptop with no board at all — and a test that quietly drove the real BOOT0 line would be worse than no test.
Three Python suites are not about behavior at all. They pin facts that have no other source of truth:
- The GPIO line numbers (
test_link.py). Nothing upstream records them. A "tidy up" has nothing to check against except that test. - The unsigned-image guard (
test_fota.py). Getting it wrong stops the board booting. - The panel constants (
test_contract.py). C macros cannot be imported into Python, sounoq/mcu.pyholds a second copy of the LED matrix geometry. That test parsesapp_proto.hand fails if the two drift, which is what makes the duplication safe to have.
Coverage of python/unoq gates at 100%. That is a ratchet, not a boast — new
code needs a test — and it is one line in pyproject.toml if you want it
lower.
The shell suites run the real scripts with fake commands at the front of
PATH. tests/README.md explains how, what that does
and does not prove, and how to add one.
CI¶
.github/workflows/ci.yml runs on every push
to main and every pull request.
| Job | What it does |
|---|---|
checks |
tools/check.sh python shell c docs workflows. Fast, so a missing comma fails in seconds rather than behind a toolchain download. |
changes |
Decides what a change touched, on a pull request and on the push that merges it, so the two jobs below run only when something they test has moved. When it cannot tell, it says everything. |
diagrams |
tools/check.sh diagrams, in a chrome-headless-shell pinned by version and hash rather than whatever browser the runner image ships. Skipped when nothing the site is built from changed. |
firmware |
Runs mcu/zbuild.sh — the script the board runs, not a copy — to cross-compile and sign the firmware for the real board, then mcu/ztest.sh for the firmware tests. |
Three more workflows:
provision.ymlruns the user-level provisioning steps themselves in an empty home directory, on x86_64 and on arm64, runs them a second time to prove nothing changes, then builds and tests the firmware with what they installed. Weekly, and whenever a provisioning script changes. It exists because the first stranger's board stopped at a step that had passed on every board it was developed on.pages.ymlbuilds and publishes this site.links.ymlchecks the external links once a month and opens an issue if any have died, rather than failing a build over somebody else's website.
The Zephyr version is pinned in two places: west.yml, which CI builds from,
and provision/user/30-zephyr-workspace.sh, which sets up the board. A gate
fails if they disagree, because CI compiling a different Zephyr from the board
is worse than no CI.
What CI cannot do is touch a board. Flashing, the serial link, the USB gadget and the LEDs are verified by hand on real hardware, and the findings log is the record of what that turned up.
What it costs, and what keeps that down¶
The repository is public, so the minutes are free. They are still somebody's machines, and a slow CI is one people stop waiting for.
- A newer commit cancels the run it replaces, on the same branch or pull
request, in
ci.ymlandprovision.yml. A superseded build of the site is canceled too; a deployment that has started is left to finish. - The expensive jobs are filtered by what changed: the firmware build and
the browser check in
ci.yml, by thechangesjob;provision.ymlby itspaths:; andpages.ymlonly rebuilds the site when the site's inputs moved. - The firmware job waits for
checks, so a missing comma does not buy a toolchain download. - Everything has a timeout.
Two things run on every pull request that are not in this repository and cannot be tuned from it: GitHub's code scanning, and Copilot's review if it is switched on for the account. Both are settings.
Conventions worth knowing¶
- Scripts explain themselves. The header comment of each script says what it does and why it is the way it is, including what went wrong before. Keep that up when you change one.
- Shell behavior gets a test. Pick by blast radius: what happens when this is wrong? Then check the new test fails when you break the script on purpose.
unoq/*ignores ruff'sPTrules. They are pytest-style checks and misfire onfota.test(), which is MCUboot's "mark this image pending".__all__is not sorted (RUF022off). It is grouped by meaning.- A blind
except Exceptioniscontextlib.suppress, with the reason in a comment, on hardware paths where raising would make the API unusable. - C follows Zephyr's own
.clang-format, copied from the Zephyr tree. Re-copy it after a Zephyr version bump.
The documentation¶
tools/build-docs.sh # render docs/ -> share/learn/
tools/build-docs.sh --serve # live preview on :8000 while you write
sudo bash share/build-image.sh # on the board: put the result on the drive and the web server
build-image.sh renders the docs itself and re-inserts the drive's medium
without unbinding the gadget. Do not run usb/gadget-down.sh first: that drops
the USB network, and restarting unoq-usb-gadget does not bring it back.
Every page has to work in three places: on GitHub as plain markdown, on the built site, and opened as a file from the USB drive with no network. In practice that means:
- Links are relative paths to
.mdfiles. The strict build fails on one that does not resolve. - Callouts use GitHub's syntax (
> [!NOTE]), which a build hook converts for the site. The site's own!!!syntax shows up as stray text on GitHub. - Nothing is loaded from a CDN. What the site needs is fetched at build time and shipped with it.
share/learn/ is generated and is not in the repository.
Maintenance¶
tools/check-versions.sh # audit every dependency; reports only
tools/check-versions.sh pins # just the versions this project pins itself
tools/check-links.sh # do the external links in the docs still resolve?
Nothing on the board updates itself, on purpose, so run the first of these now
and then. The script's header lists the things that look out of date and are
not — cbor2 held at 5.x, mkdocs held below 2.0, OpenOCD built from master —
and why.
Upgrading Zephyr has its own checklist: mcu.md.
The documentation toolchain is held back on purpose¶
mkdocs is pinned below 2.0 and mkdocs-material below 10 in
python/pyproject.toml. MkDocs 2.0 removes the plugin system, and this site
depends on three plugins: search, privacy and offline. The last two are
what make it work opened as a file from the USB drive with no network, which is
the one requirement that cannot be traded away.
The successor is Zensical, from the same author. It builds this site faster and cannot yet build it offline: it leaves the diagram library at a CDN. That is the whole blocker, and issue #52 holds the ten-minute test that says when it has gone.
So the rule is: do not move on a version number. Move when one of these is true.
- Zensical ships its
privacyequivalent and the test in #52 passes. - The pinned range stops receiving security fixes.
- A build with the pinned range stops working on a supported Python.
tools/check-versions.sh pins prints the range and the newest release inside
it, so a range that has gone quiet is visible. If it ever has to be replaced in
a hurry, what would not survive is small: tools/mkdocs_hooks.py, the theme
configuration in mkdocs.yml, and the two index pages. The chapters are plain
Markdown and would.
Rebuilding OpenOCD¶
/opt/openocd is owned by no Debian package, so apt can never reinstall it.
This script removes that as a single point of failure.
sudo bash tools/build-openocd.sh # build + self-test only
sudo bash tools/build-openocd.sh --promote # then replace /opt/openocd
It builds upstream OpenOCD from a pinned commit on master (override with
OPENOCD_COMMIT). Master is required: the newest release, 0.12.0, predates the
libgpiod v2 API this board's SWD connection uses. The only configuration this
repository supplies is mcu/board-support/openocd_gpiod.cfg, which names the
SWD pins.
The build goes to /opt/openocd-rebuilt and tests itself against the real
MCU: it must see Cortex-M33, or it exits non-zero and leaves /opt/openocd
alone. --promote keeps the old install as /opt/openocd.<timestamp>. Every
tool here honors OCD_ROOT, so you can try the new one first:
Keep your own copies¶
Two things on a provisioned board cannot be downloaded again, and neither is
this project's to redistribute (see third-party.md).
bootstrap.sh copies the first aside for you; check both are there before
running provision/55-purge-arduino.sh:
ls ~/uno-q-backup/ # the stock MCU firmware (.hex)
mkdir -p ~/uno-q-backup && cp -a /opt/openocd ~/uno-q-backup/ # rebuildable, but slowly
Python versions are pinned¶
Two lock files decide the version of every Python package this project installs, on the board and in CI:
| File | Pins |
|---|---|
python/requirements-lock.txt |
this project's .venv: the hardware libraries, the docs tooling, the dev tools |
mcu/requirements-zephyr-lock.txt |
the Zephyr workspace's .venv, and cmake, ninja and west |
They are passed to every install as constraints (-c): each step still asks
for what it wants by name, and the file only decides the version. That is why
a board set up next month gets what a board set up today got.
Then run the gates, and bootstrap on a board: a new version of a package is a change like any other. Each environment records which lock it was built to, so that bootstrap notices the file has changed and brings the board to the new versions; without a change it skips the installs as before. Dependabot proposes updates to the project's own lock once a month, as one pull request. For the Zephyr lock it proposes security updates only: that file is compiled from Zephyr's requirement lists, which are not in this repository, so Dependabot can move one pin and cannot re-resolve the rest around it. Take newer versions there with the script above.
Recovery¶
mcu/flash-all.sh # rebuild the bootloader + application chain over SWD
mcu/restore-arduino-firmware.sh # back to the stock Arduino firmware
The second needs the stock image from ~/uno-q-backup, passed as STOCK_FW.
For a board you cannot reach at all, see
Troubleshooting.