Skip to content

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
.docker/firmware/run.sh pio run -e teensy40
1
.docker/firmware/run.sh pio run -e teensy31

.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
Processing teensy40 (platform: teensy; board: teensy40; framework: arduino)
--------------------------------------------------------------------------------
Verbose mode can be enabled via `-v, --verbose` option
CONFIGURATION: https://docs.platformio.org/page/boards/teensy/teensy40.html
PLATFORM: Teensy (6.0.0) > Teensy 4.0
…
Checking size .pio/build/teensy40/firmware.elf
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
teensy_size: Memory Usage on Teensy 4.0:
teensy_size:   FLASH: code:54832, data:10424, headers:8468   free for files:1957892
teensy_size:    RAM1: variables:13536, code:52712, padding:12824   free for local variables:445216
teensy_size:    RAM2: variables:12416  free for malloc/new:511872
Building .pio/build/teensy40/firmware.hex
========================= [SUCCESS] Took 1.50 seconds =========================
Environment    Status    Duration
-------------  --------  ------------
teensy40       SUCCESS   00:00:01.501
========================= 1 succeeded in 00:00:01.501 =========================

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
uid=1234(pio) gid=1234(pio) groups=1234(pio)
Without the script, or without the container

With Docker Compose directly, export both IDs first, because compose.yaml requires them:

1
2
3
export LOCAL_UID=$(id -u) LOCAL_GID=$(id -g)
docker compose -f .docker/firmware/compose.yaml build firmware
docker compose -f .docker/firmware/compose.yaml run --rm firmware pio run -e teensy40

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
sudo apt install python3-venv
python3 -m venv ~/.venvs/platformio
~/.venvs/platformio/bin/pip install platformio
cd diffbot_firmware
~/.venvs/platformio/bin/pio run -e teensy40

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
usage() {
    echo "Usage: $0 COMMAND [ARGUMENT...], for example: $0 pio run -e teensy40"
    echo "Runs COMMAND in the firmware container, in diffbot_firmware, as your user."
}

# Without a command, say how to use the script, before Docker starts
if [ "$#" -eq 0 ]; then
    usage >&2
    exit 2
fi
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
    usage
    exit 0
fi

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="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export LOCAL_UID="${LOCAL_UID:-$(id -u)}"
export LOCAL_GID="${LOCAL_GID:-$(id -g)}"
compose=(docker compose -f "$here/compose.yaml")

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
# Build the image. The first build shows Docker's progress, because it downloads the base image
# and the Python packages. Later builds usually take seconds, because Docker reuses the cached
# result of each step when the Dockerfile and requirements.txt haven't changed. The script keeps
# the output of these later builds and prints it only when the build fails.
image="$("${compose[@]}" config --images firmware)"
if ! docker image inspect "$image" > /dev/null 2>&1; then
    "${compose[@]}" build firmware
else
    status=0
    build_log="$("${compose[@]}" --progress plain build firmware 2>&1)" || status=$?
    if [ "$status" -ne 0 ]; then
        printf '%s\n' "$build_log" >&2
        exit "$status"
    fi
fi

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 and requirements.txt haven't changed. Its output goes into the variable build_log, not to the terminal. When the build fails, status gets 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
# Without a terminal for the input or the output, for example in CI, run the container without
# one. Docker Compose checks only the output before version 2.39.4.
tty_flag=()
if [ ! -t 0 ] || [ ! -t 1 ]; then
    tty_flag=(-T)
fi
exec "${compose[@]}" run --rm ${tty_flag[@]+"${tty_flag[@]}"} firmware "$@"

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 --rm starts a container for this one command and removes it afterwards.
  • ${tty_flag[@]+"${tty_flag[@]}"} inserts -T when 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, because set -u treats it as unset.
  • firmware "$@": firmware is the service in compose.yaml, and "$@" is your command with its arguments, for example pio run -e teensy40. Each argument reaches the container unchanged, because no shell reads it in between.
  • exec replaces the script's Bash process with the docker command, 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
services:
  firmware:
    build:
      context: .
      args:
        USER_UID: ${LOCAL_UID:?set LOCAL_UID and LOCAL_GID to your user and group ID, see the comment above}
        USER_GID: ${LOCAL_GID:?set LOCAL_UID and LOCAL_GID to your user and group ID, see the comment above}
    image: diffbot-firmware-build:pio-6.2.0-${LOCAL_UID}-${LOCAL_GID}
    user: "${LOCAL_UID}:${LOCAL_GID}"
    volumes:
      # The whole repository, so that PlatformIO finds the project in diffbot_firmware
      - ../..:/workspace
      # Where PlatformIO keeps its downloads (platforms, toolchains, frameworks): /pio-core in the
      # container. Without PIO_CORE, that's the Docker volume pio-core below, which keeps them
      # between runs on your PC. With PIO_CORE set to a folder, it's that folder instead. CI does
      # that, because each CI job starts on a fresh machine: it saves the folder in its cache.
      - ${PIO_CORE:-pio-core}:/pio-core
    working_dir: /workspace/diffbot_firmware

volumes:
  pio-core:
    name: diffbot-firmware-pio-core-${LOCAL_UID}-${LOCAL_GID}
  • 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 when LOCAL_UID isn'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 in diffbot_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. With PIO_CORE set 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
FROM python:3.12-slim-bookworm

# A C/C++ compiler for builds on the PC (native tests), Git for PlatformIO's libraries
RUN apt-get update && apt-get install -y --no-install-recommends \
        build-essential \
        ca-certificates \
        git \
    && rm -rf /var/lib/apt/lists/*

# PlatformIO Core, in the version that CI used before (see requirements.txt)
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt

requirements.txt holds PlatformIO's version, line 1:

requirements.txt
1
platformio==6.2.0

== 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
# A user with your user and group ID, so that the build's files in the mounted repository belong
# to your user. compose.yaml passes the two IDs as the build arguments USER_UID and USER_GID,
# from LOCAL_UID and LOCAL_GID, which run.sh sets to `id -u` and `id -g`. USER_UID and USER_GID
# have no default, so a build without both stops instead of silently using 1000. The user's home
# and the core folder belong to it, so a new volume mounted at /pio-core is writable too.
ARG USER_UID
ARG USER_GID
RUN if [ -z "$USER_UID" ] || [ -z "$USER_GID" ]; then \
        echo "USER_UID and USER_GID are missing: build through compose.yaml or run.sh" >&2; \
        exit 1; \
    fi
RUN (getent group "$USER_GID" || groupadd --gid "$USER_GID" pio) \
    && useradd --non-unique --uid "$USER_UID" --gid "$USER_GID" --create-home pio \
    && mkdir /pio-core \
    && chown "$USER_UID:$USER_GID" /pio-core
ENV PLATFORMIO_CORE_DIR=/pio-core
USER pio
WORKDIR /workspace/diffbot_firmware

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 ros with 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 ros with 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
- uses: actions/checkout@v7
# The build runs in the firmware image (.docker/firmware), through the same script as a local
# build, so CI uses the same pinned PlatformIO. The script sets LOCAL_UID and LOCAL_GID to
# the runner's user.
# PIO_CORE is a folder for PlatformIO's downloads 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 an
# exported variable would be gone in the next step. Instead, the step appends the line
# PIO_CORE=<folder> to the file whose path is in $GITHUB_ENV, and GitHub sets PIO_CORE in
# every later step of this job. runner.temp below is the same folder as $RUNNER_TEMP.
- name: Settings for the firmware container
  run: |
    echo "PIO_CORE=$RUNNER_TEMP/pio-core" >> "$GITHUB_ENV"
    mkdir -p "$RUNNER_TEMP/pio-core"
# Each job starts on a fresh machine, so PlatformIO would download the Teensy toolchain every
# time. actions/cache restores the PIO_CORE folder from an earlier run, and saves it after a
# successful job when no cache had the exact key. compose.yaml mounts that folder as the
# container's /pio-core. One cache per board, so the two jobs don't write the same one. When
# requirements.txt or platformio.ini changed, no cache has the exact key, and restore-keys
# restores the newest cache of the same board instead.
- uses: actions/cache@v6
  with:
    path: ${{ runner.temp }}/pio-core
    key: pio-core-${{ runner.os }}-${{ runner.arch }}-${{ matrix.env }}-${{ hashFiles('.docker/firmware/requirements.txt', 'diffbot_firmware/platformio.ini') }}
    restore-keys: |
      pio-core-${{ runner.os }}-${{ runner.arch }}-${{ matrix.env }}-
- name: Build the firmware
  run: .docker/firmware/run.sh pio run -e ${{ matrix.env }}

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_CORE gets 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 with export would be gone in the next step. Instead, the step appends the line PIO_CORE=<folder> to a file whose path GitHub puts in $GITHUB_ENV. After the step, GitHub sets each NAME=value line of that file as an environment variable in the later steps (environment files).
  • actions/cache: restores the PIO_CORE folder 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_CORE folder 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:

RunRestored at the startSaved at the end
The very first runNothing. PlatformIO downloads everything.1a2b
A later run with the same key1a2b, the exact keyNothing, because it exists
After a change of requirements.txt or platformio.ini, which makes the key 5e6f1a2b, through restore-keys5e6f

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.