Firmware Container
The Teensy firmware in diffbot_firmware builds with PlatformIO, which isn't in the dev container. So the firmware has a container of its own, .docker/firmware: Debian 12 with Python 3.12, PlatformIO 6.2.0 and a compiler, without ROS. A script runs PlatformIO in it as your user, and CI builds the firmware the same way. You need only Docker.
Why not in the dev container
The dev container can't run the current Teensy tools: its Ubuntu 20.04 has glibc 2.31, the C library, and the PlatformIO Teensy tools need 2.34 or later. In our test, the tool teensy_size stopped there with a missing GLIBC_2.34. The firmware container's Debian 12 has glibc 2.36.
A VS Code dev container for the firmware is planned in diffbot#149. Until then, build from a terminal on your PC, as below.
Use it
Open a terminal on your PC and go to the root folder of your diffbot clone. On Windows, use the Ubuntu of WSL 2, not the dev container. Then build the firmware for your robot's Teensy:
1 | |
1 | |
.docker/firmware/run.sh is our script. It runs a command in the firmware container, here PlatformIO's command pio, and the script below shows how. pio run builds the firmware. -e selects an environment in platformio.ini, and each environment builds for one board: teensy40 for the Teensy 4.0, teensy31 for the Teensy 3.1 and 3.2. Without -e, pio run builds teensy40. Which Microcontroller? lists the Teensy of each robot.
For your robot, one build is enough. If you change the firmware, build the other board too, because CI checks that the firmware builds for both.
The first build downloads the base image, PlatformIO and the Teensy toolchain, which needs the internet and took about a minute on our test PC. Later builds take seconds. The end of a Teensy 4.0 build looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
PlatformIO writes each build into its standard build folder, diffbot_firmware/.pio/build/<environment>/. So the firmware is diffbot_firmware/.pio/build/teensy40/firmware.hex for Remo and diffbot_firmware/.pio/build/teensy31/firmware.hex for DiffBot. Git ignores .pio. The file belongs to your user, not to root, so you can copy or delete it without sudo.
Flash it with PJRC's Teensy Loader, an app that you install on your PC. It isn't part of the container. It loads firmware.hex onto the Teensy over USB. On Linux, it needs PJRC's udev rules, a file that you copy to /etc/udev/rules.d/, so that it may write to the Teensy. Flashing from the container is planned in diffbot#149. For that, the container needs access to the Teensy's USB port.
Other commands run the same way, for example .docker/firmware/run.sh bash for a shell in the container. Shell syntax such as && needs a shell in the container, for example .docker/firmware/run.sh bash -c 'pwd && id'. Otherwise the shell on your PC reads it. Commands start in diffbot_firmware. Paths in them are paths in the container, where your clone is /workspace, so a folder on your PC outside your clone doesn't exist there.
Another user and group ID: the script uses yours (id -u and id -g) unless you set LOCAL_UID and LOCAL_GID, for example LOCAL_UID=1234 LOCAL_GID=1234 .docker/firmware/run.sh pio run -e teensy40. That ID needs write access to your clone. In the container, id then shows:
1 | |
Without the script, or without the container
With Docker Compose directly, export both IDs first, because compose.yaml requires them:
1 2 3 | |
With PlatformIO on your PC, outside the dev container, in a virtual environment. These steps were tested on Ubuntu 24.04 (WSL 2) with Python 3.12 and PlatformIO 6.2:
1 2 3 4 5 | |
PlatformIO on your PC as well
Don't build with it and in the container at the same time. Both write to diffbot_firmware/.pio.
How it works
The excerpts show the files at the diffbot commit that this page pins, with their line numbers. The link above each block opens the same lines on GitHub.
The script
.docker/firmware/run.sh runs from anywhere, because it finds compose.yaml next to itself. Without a command, it says how to use it and stops before Docker starts, lines 15–28:
| run.sh: without a command | |
|---|---|
15 16 17 18 19 20 21 22 23 24 25 26 27 28 | |
It writes the usage to stderr, the error output, and exits with 2, so that a CI job or another script that forgot the command fails. --help writes the same to the normal output and exits with 0.
Then it sets the user and group IDs, lines 30–33:
| run.sh: the IDs | |
|---|---|
30 31 32 33 | |
here is the script's folder: dirname takes the folder from the script's path, and cd … && pwd turns it into a full path. ${LOCAL_UID:-$(id -u)} keeps a LOCAL_UID that you set, and takes your user ID otherwise. The same goes for the group ID. export makes both environment variables, which the programs that the script starts can read. Docker Compose fills ${LOCAL_UID} and ${LOCAL_GID} in compose.yaml with them. compose, a variable like here, is a list with the start of every Compose command: docker compose -f <folder>/compose.yaml. The script adds the rest, for example build firmware.
Next, it builds the image, lines 35–49:
| run.sh: the image | |
|---|---|
35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | |
config --images asks Compose for the image's name, which contains your user and group IDs. docker image inspect checks whether that image exists:
- Not yet (
!): the build shows Docker's progress, because it downloads the base image and the Python packages for a while. - It exists (
else): the build usually takes seconds, because Docker reuses the cached result of each step when the Dockerfile andrequirements.txthaven't changed. Its output goes into the variablebuild_log, not to the terminal. When the build fails,statusgets the build's exit code, and the script prints the log to stderr and stops with that code.
Last, it runs your command in the container, lines 51–57:
| run.sh: the run | |
|---|---|
51 52 53 54 55 56 57 | |
tty_flag starts as an empty list. [ ! -t 0 ] is true when the input isn't a terminal, and [ ! -t 1 ] when the output isn't, for example in CI or when the output goes into a file. Then the list gets -T, and the container starts without a terminal. Docker Compose before version 2.39.4 checks only the output, so the script checks both itself.
The last line runs your command:
"${compose[@]}" run --rmstarts a container for this one command and removes it afterwards.${tty_flag[@]+"${tty_flag[@]}"}inserts-Twhen the list has it, and nothing when it's empty. The shorter"${tty_flag[@]}"would stop the script for an empty list in Bash before 4.4, such as macOS's Bash 3.2, becauseset -utreats it as unset.firmware "$@":firmwareis the service incompose.yaml, and"$@"is your command with its arguments, for examplepio run -e teensy40. Each argument reaches the container unchanged, because no shell reads it in between.execreplaces the script's Bash process with thedockercommand, so no Bash waits in between. A signal sent to the script, such as a stop, then reaches Docker. When your command in the container finishes, Docker Compose returns its exit code, 0 for success and another number for an error, so a failed build fails the CI step.
The container
compose.yaml describes the image and the container, lines 17–38:
| compose.yaml | |
|---|---|
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | |
build: the image is built from the Dockerfile in this folder, with your user and group IDs as build arguments.${LOCAL_UID:?…}stops with that message whenLOCAL_UIDisn't set.image: the image's name carries your user and group IDs, so another user on the same PC gets an image of their own.user: the container runs with your user and group IDs, so the files it writes into the repository belong to your user.volumes: the whole repository is mounted at/workspace, and the container starts indiffbot_firmware. PlatformIO keeps its downloads in/pio-core: on your PC that's a Docker volume, a storage area that Docker keeps between runs, again with your user and group IDs in its name. WithPIO_COREset to a folder, that folder is mounted there instead, which CI uses.
The image
The Dockerfile starts from Python on Debian 12 and installs a compiler, for programs that run on the PC such as unit tests, and PlatformIO in the version that requirements.txt pins, lines 5–16:
| Dockerfile: the tools | |
|---|---|
5 6 7 8 9 10 11 12 13 14 15 16 | |
requirements.txt holds PlatformIO's version, line 1:
| requirements.txt | |
|---|---|
1 | |
== installs exactly this version, so the builds in this container, on your PC and in CI, use the same PlatformIO. A newer version is a change of this line, which also starts a new cache in CI.
The Teensy toolchain isn't in the image: PlatformIO downloads it into /pio-core on the first build. Then the image gets a user with your user and group IDs, lines 18–35:
| Dockerfile: your user | |
|---|---|
18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | |
No default IDs
The build arguments USER_UID and USER_GID have no default value. A build without both stops with an error, instead of quietly creating the user with the ID 1000, which may not be yours.
Your user ID, and the other containers
The ID travels in four steps: the script takes id -u, Compose passes it to the build as USER_UID, the Dockerfile creates the user pio with it, and the container runs as that ID. Files that the build writes into your clone, such as firmware.hex, then belong to your user, not to root or another ID.
The other containers solve the same problem differently:
- The dev container creates its user
roswith 1000, the usual ID of the first user on Linux. VS Code changes that user to your user and group IDs when it creates the container (updateRemoteUserUID, on by default on Linux). The firmware container runs without VS Code, so it gets your user and group IDs at build time instead. - The robot container runs as
roswith 1000 on the Raspberry Pi, the robot's first user. The planned published image (diffbot#151) is built once per architecture with the IDs 1000, not for each robot's user. Setup covers a robot whose first user has another ID.
In CI
The Firmware workflow builds each board through the same script, lines 20–46:
| firmware.yml: the steps | |
|---|---|
20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | |
Each CI job starts on a fresh machine, where a Docker volume would be empty, so PlatformIO would download the Teensy toolchain in every job. The steps avoid that:
- Settings for the firmware container:
PIO_COREgets a folder in$RUNNER_TEMP, a temporary folder that GitHub empties at the start and end of each job. Each step runs in a new shell, so a variable set withexportwould be gone in the next step. Instead, the step appends the linePIO_CORE=<folder>to a file whose path GitHub puts in$GITHUB_ENV. After the step, GitHub sets eachNAME=valueline of that file as an environment variable in the later steps (environment files). actions/cache: restores thePIO_COREfolder from an earlier run at the start of the job, and saves it when needed after a successful job. The cache below explains how.- Build the firmware: runs the script as on your PC. It sets the user and group IDs to the runner's, and Compose mounts the
PIO_COREfolder as the container's/pio-core.
The cache
PIO_CORE holds what PlatformIO downloads for the Teensy, about 510 MiB in our test: mostly the compiler for the Teensy's ARM processor (275 MiB) and the Arduino framework for the Teensy (100 MiB), plus PJRC's Teensy tools, PlatformIO's Teensy platform and the downloaded archives of the libraries in platformio.ini. Both boards need the same packages. The installed libraries aren't part of it, because PlatformIO installs them into the project, in diffbot_firmware/.pio/libdeps.
The cache is reused between workflow runs, not between the two boards. matrix runs the job twice at the same time, once with matrix.env set to teensy31 and once with teensy40. The board is part of the cache key, so each board has a cache of its own, and the two jobs never try to save the same one. For Remo's board, the key looks like pio-core-Linux-X64-teensy40-1a2b…. The hash at the end comes from requirements.txt and platformio.ini, so it changes when PlatformIO's version or the configuration of the boards changes. For that board, the runs go like this, with 1a2b and 5e6f standing for whole keys with these hashes:
| Run | Restored at the start | Saved at the end |
|---|---|---|
| The very first run | Nothing. PlatformIO downloads everything. | 1a2b |
| A later run with the same key | 1a2b, the exact key | Nothing, because it exists |
After a change of requirements.txt or platformio.ini, which makes the key 5e6f | 1a2b, through restore-keys | 5e6f |
In the third run, no cache has the exact key. restore-keys then restores the newest cache whose key starts with pio-core-Linux-X64-teensy40-, the same board with an older hash. PlatformIO uses the packages that are already in the folder and downloads only the missing ones. A saved cache can't change, so the job saves the folder under the new key. A run on a pull request also finds the caches of its base branch, noetic-devel, so the first run on a new branch usually gets the exact key. GitHub's documentation explains the matching and which branches' caches a run can use.
Planned
diffbot#149 tracks the next steps: the firmware's unit tests in this container and in CI (diffbot#157), flashing from the container, a VS Code dev container for the firmware, and pinning the Teensy platform and libraries.
Comments
Comments are GitHub Discussions, shown with giscus. Loading them connects to giscus.app (hosted by Vercel, USA) and GitHub (USA); you need a GitHub account to comment.