Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
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
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
"chat.useAgentsMdFile": true,
"chat.useNestedAgentsMdFiles": true,
"python.analysis.extraPaths": [
"./firmware-packages/atgm336h",
"./firmware-packages/boot_status_led",
"./firmware-packages/i2c_bus",
"./firmware-packages/mpu6050",
"./firmware-packages/qmc5883p",
"./firmware-packages/smoothing",
"./firmware-packages/vl53l0x",
"./firmware-packages/vl53l5cx",
"./cpython-packages/serial_over_web",
"./cpython-packages/micropython_stubs/micropython_stubs"
]
Expand Down
2 changes: 0 additions & 2 deletions .vulture_allowlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@

# pytest fixtures: pytest discovers and applies these by name; the test
# function parameter is how it requests the fixture.
chip # noqa: F821
monkeypatch # noqa: F821
fake_tof # noqa: F821

# machine.Pin mirrors MicroPython's real signature so client code that
# passes extra positional/keyword args works under the stub too.
Expand Down
26 changes: 8 additions & 18 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Firmware runs MicroPython (`ujson` built-in); host tests run CPython, where `ujs
| host | CPython in Docker. Runs the dashboard, build toolchains, and pytest. [micropython_stubs](cpython-packages/micropython_stubs/) lets pytest exercise MCU code on the host. |

### Design
This is a shared-projects monorepo. Projects (`projects/<project>/`) contain general, chip-agnostic firmware that builds for all chips the project supports. Chip-specific behavior belongs exclusively in package backends (`firmware-packages/`), following the pattern established by `boot_status_led`. Project `main.py` files must not contain chip-detection branches.
This is a shared-projects monorepo. Each project (`projects/<project>/`) contains firmware that builds for every chip it supports. Pin assignments — which GPIOs carry SPI, I2C, UART, and device chip-selects — live in the project's `main.py` as a `BOARD` table of **plain pin numbers** dispatched by `os.uname().machine` at import time, because different projects wire their boards differently and the mapping is pure project-specific configuration. Chip-specific *behavior* (driver implementations, hardware abstraction) belongs in package backends (`firmware-packages/`), following the pattern established by `boot_status_led`. Each driver constructor takes those pins as flat keyword arguments (hardware I²C: `sda=`/`scl=`/`bus_id=`; bit-banged soft I²C: `sda=`/`scl=` only; UART: `bus_id=`/`tx=`/`rx=`; SPI: `spi_id=`/`sck=`/`mosi=`/`cs=`), opens whatever bus it needs internally via the `i2c_bus` utility (projects never see a bus object), and scans — raising a specific `DeviceNotFoundError` (not a bare `OSError`) when the expected device is absent, so the project's retry loop can tell "nothing on the bus" from "init failed". Packages receive pins as arguments from the caller; they never claim pins at import time.

`<project>` denotes any subdirectory under `projects/` — list it with `ls projects/` to see what's currently present, and substitute the real name when running commands.

Expand All @@ -33,11 +33,11 @@ Before changing anything, identify the area you're touching:

| Area | Path | Key files |
| --- | --- | --- |
| Entry point | `projects/<project>/firmware/` | `main.py` — I²C scan, sensor init, JSON streaming loop |
| Entry point | `projects/<project>/firmware/` | `main.py` — BOARD pin table, sensor init/retry, JSON streaming loop |
| LED state machine | `firmware-packages/boot_status_led/boot_status_led/` | `status.py` — named transitions + colour constants, chip dispatch |
| I²C bus | `firmware-packages/i2c_bus/i2c_bus/` | `rp2040.py` / `rp2350.py` / `esp32s3.py` — `soft_i2c` / `hard_i2c`, chip dispatch |
| ToF driver | `firmware-packages/vl53l0x/vl53l0x/` | `vl53l0x.py` — `VL53L0X(i2c, skip_spad_info=False, interrupt_status_mask=0x07)` |
| IMU driver | `firmware-packages/mpu6050/mpu6050/` | `mpu6050.py` — `MPU6050(i2c, addr=0x68)` |
| I²C bus (internal) | `firmware-packages/i2c_bus/i2c_bus/` | `__init__.py` — `soft_i2c(sda, scl)` / `hard_i2c(bus_id, sda, scl)` + `DeviceNotFoundError`; consumed only by drivers, never by projects |
| ToF driver | `firmware-packages/vl53l0x/vl53l0x/` | `vl53l0x.py` — `VL53L0X(sda=, scl=, skip_spad_info=True, interrupt_status_mask=0xFF)`; opens its own soft I²C, scans → `DeviceNotFoundError` |
| IMU driver | `firmware-packages/mpu6050/mpu6050/` | `mpu6050.py` — `MPU6050(sda=, scl=, bus_id=0)`; opens its own hard I²C, auto-detects 0x68/0x69 → `DeviceNotFoundError` |
| Viz backend | `projects/<project>/viz/` | `app.py` — serial reader + WebSocket broadcaster on `/ws` |
| Viz dashboard | `projects/<project>/viz/static/` | `index.html` — Plotly line chart + numeric readout |
| Firmware compile | repo root | `Dockerfile.firmware` — stages: `pi-compile`, `esp32-compile`, `esp32-flash` |
Expand All @@ -48,17 +48,7 @@ Before changing anything, identify the area you're touching:
| RP firmware output | `projects/<project>/outputs/` | `app.rp2040.rp2350.uf2` — Universal UF2 for RP2040 + RP2350 |
| ESP32 firmware output | `projects/<project>/outputs/` | `app.esp32-s3.bin` — ESP-IDF `.bin`, flashed by `esp32-flash` service |

## Workflow & commands

### Workflow (follow in order)
| Step | Action |
| --- | --- |
| 1 | Identify the chip(s) affected (RP2040, RP2350, ESP32-S3, or all three). |
| 2 | If the change requires chip-specific behavior, add or update a package backend — do not branch inside project firmware. |
| 3 | Make the smallest change that achieves the goal — don't add shared abstractions for a single chip. |
| 4 | Compile firmware and confirm it succeeds before reporting done. |

### Commands (copy/paste, run from `projects/<project>/`)
## Commands (copy/paste, run from `projects/<project>/`)

#### Compile firmware
```
Expand All @@ -67,7 +57,7 @@ docker compose up --build esp32-compile # ESP32-S3 → ./outputs/app.esp32-
docker compose run --rm --build esp32-flash # ESP32-S3 → compiles then flashes $SERIAL_PORT (default /dev/ttyACM0)
```

#### Run tests
#### Run tests (run from the repo root — the `pytest` service lives in the root `docker-compose.yaml`, not the per-project ones)
```
docker compose up pytest --build --exit-code-from pytest # everything
docker compose up pytest --build --exit-code-from pytest -- /projects/distance-stream/tests # one project
Expand Down Expand Up @@ -108,7 +98,7 @@ Use standard Python ordering: docstring → imports → constants → public API
- MicroPython has no package manager and no 3rd-party packages to install; all dependencies must be vendored or frozen into firmware via `manifest.py`. Do not use `mip`. Use `const()` for register addresses; pre-allocate buffers in tight loops.
- Never spin without `sleep` (≥ 10 ms) — starves the MicroPython scheduler.
- Wrap `sensor.read()` in `try/except` — sensors occasionally NACK. On exception, call `status.read_err()` and `continue` the loop; never let a stray exception crash the loop.
- Chip-specific logic belongs in packages, not in project firmware. Each package keeps MCU code under `firmware-packages/<pkg>/<pkg>/` (flat `.py` + `__init__.py`). Host tests live under `firmware-packages/<pkg>/tests/`. Use the backend-dispatch pattern (`os.uname().machine` at import time) that `boot_status_led` already establishes.
- Chip-specific *behavior* belongs in packages, not in project firmware. Each package keeps MCU code under `firmware-packages/<pkg>/<pkg>/` (flat `.py` + `__init__.py`). Host tests live under `firmware-packages/<pkg>/tests/`. Use the backend-dispatch pattern (`os.uname().machine` at import time) that `boot_status_led` already establishes. Pin assignments are the exception: each project defines its own `BOARD` table of plain pin numbers in `main.py` via `os.uname().machine` dispatch, because pin maps are project-specific configuration — not reusable behavior — and different projects wire their boards differently. Drivers take those pins as flat keyword arguments and open their own bus (via the internal `i2c_bus` utility for I²C); a driver re-exports `DeviceNotFoundError` so the project imports its retry-loop exception from the driver, never from `i2c_bus`.
- Don't install tools on the host. All toolchains (esptool, ARM cross-compiler, ESP-IDF, uv, MicroPython source) live inside Docker images.
- Don't add dependencies without tests + `uv lock`.

Expand Down
31 changes: 22 additions & 9 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
SHELL := /bin/bash

.PHONY: init precommit remove-ci
init:
LINT_IMAGES := local/ruff:latest local/pydoclint:latest local/typecheck:latest

.PHONY: init build-linters precommit remove-ci

build-linters:
@docker buildx bake -f docker-bake.hcl ruff pydoclint typecheck

init: build-linters
@set -euo pipefail; \
repo_root="$$(git rev-parse --show-toplevel 2>/dev/null || true)"; \
if [[ -z "$$repo_root" ]]; then \
Expand All @@ -20,24 +26,31 @@ init:
touch "$$initialized_marker"; \
echo "pre-commit hook ready (core.hooksPath=$$hooks_dir)."

# Self-contained pre-commit: the full local check, depending only on docker and
# git. It does not source any project script. Two phases, mirroring the old
# hook: (1) auto-fix staged Python in place (ruff format, ruff check --fix) and
# Self-contained pre-commit: depends only on docker and git. Two phases:
# (1) auto-fix staged Python in place (ruff format, ruff check --fix) and
# re-stage; (2) verify ruff + pydoclint + ty on staged Python. Heavier global
# gates (the version-bump guard, vendored-file enforcement, vulture, and the
# repo-wide hadolint / yamllint sweep) stay in CI. Linter images
# reuse the same tags CI builds, so a clean checkout pays the build once.
# repo-wide hadolint / yamllint sweep) stay in CI.
#
# Images must already exist (built by `make init` or `make build-linters`).
# No builds happen in this path — it is pure docker-run.
precommit:
@set -uo pipefail; \
repo_root="$$(git rev-parse --show-toplevel)"; \
cd "$$repo_root"; \
bake_file="docker-bake.hcl"; \
image_ruff="local/ruff:latest"; \
image_pydoclint="local/pydoclint:latest"; \
image_typecheck="local/typecheck:latest"; \
missing=0; \
for img in $$image_ruff $$image_pydoclint $$image_typecheck; do \
if ! docker image inspect "$$img" >/dev/null 2>&1; then \
echo "error: image $$img not found. Run 'make init' first." >&2; \
missing=1; \
fi; \
done; \
if (( missing )); then exit 1; fi; \
mapfile -d '' -t py_files < <(git diff --cached --name-only --diff-filter=ACMR -z | grep -zE '[.]py$$' || true); \
if (( $${#py_files[@]} > 0 )); then \
docker buildx bake -f "$$bake_file" ruff pydoclint typecheck; \
echo "[pre-commit] ruff format (auto-fix) on staged files"; \
docker run --rm -v "$$repo_root":/work -w /work "$$image_ruff" format -- "$${py_files[@]}" || exit 1; \
git add -- "$${py_files[@]}"; \
Expand Down
57 changes: 50 additions & 7 deletions cpython-packages/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# cpython-packages
Shared host-CPython packages. Installed editable into the `pytest` and
`viz` Docker stage venvs via uv workspace. Never frozen onto the device
for MCU code see [`../firmware-packages/`](../firmware-packages/).
`viz` Docker stage venvs via uv workspace. Never frozen onto the device;
for MCU code see [`../firmware-packages/`](../firmware-packages/).


## Package Layout
Expand All @@ -13,16 +13,59 @@ Shared host-CPython packages. Installed editable into the `pytest` and
README.md usage, public API
```

`micropython_stubs` is the one exception: its modules also re-export at
the top level via hatch `force-include`, so host tests resolve
`import machine` exactly as on-device firmware does.
`micropython_stubs` is the one exception: hatch `force-include` also ships
its replacement modules at the wheel root, so host tests resolve `import machine`
exactly as on-device firmware does.


## Packages
| Package | What it does |
|---|---|
| [serial_over_web](serial_over_web/) | Shared FastAPI dashboard server. Tails `/dev/ttyACM0`, validates JSON lines, fans out over `/ws` WebSocket. Per-project static dashboards mount on top. |
| [micropython_stubs](micropython_stubs/) | Host shims for the MicroPython builtins firmware code imports (`machine`, `neopixel`, `ujson`, `ustruct`, `utime`, `micropython`). Lets host pytest exercise firmware without a board attached. |
| [micropython_stubs](micropython_stubs/) | Test replacements for the MicroPython modules firmware code imports (`machine`, `neopixel`, `ujson`, `ustruct`, `utime`, `micropython`). Lets host CPython pytest run MicroPython code on CPython by providing test versions of MicroPython-only modules. This enables testing firmware logic separately from firmware-and-hardware performance testing. |


## micropython_stubs
`micropython_stubs` is a tests-only uv workspace member. The `pytest`
Docker stage installs it into the test venv, where its files are available
both as `micropython_stubs.<module>` and as MicroPython-style top-level
imports.

```
micropython_stubs/
pyproject.toml uv workspace metadata plus hatch force-include
micropython_stubs/
__init__.py package marker
machine.py fake Pin, I2C, SoftI2C, and UART
neopixel.py fake NeoPixel strip with recorded writes
ujson.py CPython json exported as ujson
ustruct.py CPython struct exported as ustruct
utime.py no-op sleep_ms plus monotonic ticks
micropython.py const(x) returns x
testing.py shared fakes and firmware main.py AST helpers
```

Replacement module behavior:
- `machine.py` records every `Pin(...)` construction in
`pin_constructions`, routes I2C reads and writes to devices registered
with `machine.register_device(addr, dev)`, and feeds `UART.readline()`
from byte lines queued with `machine.feed_uart(...)`.
- `neopixel.py` records `NeoPixel` instances and appends the current LED 0
color to `writes` on each `write()`.
- `ujson.py` and `ustruct.py` re-export CPython's `json` and `struct`
APIs used by firmware tests.
- `utime.py` makes `sleep_ms()` a no-op and implements `ticks_ms()` /
`ticks_diff()` with host time.
- `micropython.py` exposes `const(x)` as an identity function.
- `testing.py` provides `FakeTime`, `FakeStatus`, and helpers that load
selected assignments/functions from a firmware `main.py` into a test
namespace.

Reset mutable test-module state in autouse fixtures with `machine.reset()` and
`neopixel.reset()`. Add new top-level replacements by creating the module under
`micropython_stubs/micropython_stubs/` and adding it to
`tool.hatch.build.targets.wheel.force-include` in
[`micropython_stubs/pyproject.toml`](micropython_stubs/pyproject.toml).


## Notes
Expand All @@ -44,7 +87,7 @@ From the repo root:
docker compose run --rm --build pytest /cpython-packages
docker compose run --rm pytest /cpython-packages/serial_over_web/tests
```
`micropython_stubs` has no standalone tests every firmware package's
`micropython_stubs` has no standalone tests; every firmware package's
host pytest suite exercises it.

`/cpython-packages/serial_over_web/` is a bind-mount inside the test container (mapped from the host by the root [docker-compose.yaml](../docker-compose.yaml) at runtime, read-only), so edits take effect without rebuilding the image.
56 changes: 3 additions & 53 deletions cpython-packages/micropython_stubs/README.md
Original file line number Diff line number Diff line change
@@ -1,56 +1,6 @@
# micropython_stubs

Host shims for the MicroPython-only modules firmware code imports
(`machine`, `neopixel`, `ujson`, `ustruct`, `utime`, `micropython`).
Lets host pytest import firmware code and assert what it did, without a
board attached.
Tests-only replacement modules for MicroPython runtime imports.

## Layout
```
cpython-packages/micropython_stubs/
pyproject.toml uv workspace member; force-include re-ships
each shim at the wheel root so `import machine`
resolves top-level in the test venv
micropython_stubs/
__init__.py empty (marks the dir as a package)
machine.py fake Pin / I2C / SoftI2C
neopixel.py fake NeoPixel, records every write()
ujson.py re-exports CPython's json
ustruct.py re-exports CPython's struct
utime.py sleep_ms = no-op; ticks_ms via time.monotonic
micropython.py const(x) → x
```

## Usage
- `machine.py` — fake `Pin`, `I2C`, `SoftI2C`. Records every `Pin(...)`
call in `pin_constructions`, and routes I²C reads/writes to fake
devices that tests register with `machine.register_device(addr, dev)`.
- `neopixel.py` — fake `NeoPixel`. Records every `write()` so tests can
assert what colour the firmware would have shown.
- `ujson.py` — re-exports CPython's `json`.
- `ustruct.py` — re-exports CPython's `struct`.
- `utime.py` — `sleep_ms` is a no-op (tests don't wait); `ticks_ms` /
`ticks_diff` are backed by `time.monotonic`.
- `micropython.py` — `const(x)` returns `x` unchanged.

Tests reset the recorded state between cases by calling
`machine.reset()` (and `NeoPixel.instances.clear()` where relevant)
from an autouse fixture.

## Notes
The package is a uv workspace member. The `pytest` Docker stage runs
`uv sync`, which installs these six modules into the test venv as
top-level imports. There's exactly one copy on the host, so every
package's tests see the same `machine` module — no per-package stub
directories, no `sys.path` tricks. Never copied to the device.

**Adding a new shim:**
1. Extend the relevant `micropython_stubs/<module>.py` (or add a new file).
2. Add the new file to the `force-include` table in `pyproject.toml`.
3. Re-run the tests:
```
docker compose run --rm --build test
```

## Tests
Shims are exercised by every other package's host pytest suite. Behaviour regressions surface there.
The package documentation lives in
[`../README.md`](../README.md#micropython_stubs).
Original file line number Diff line number Diff line change
@@ -1,6 +1 @@
"""Host CPython package — shared MicroPython stubs re-promoted to top-level imports.
Provides `machine`, `neopixel`, `micropython`, `ujson`, `ustruct`, and `utime`
so pytest can exercise MCU code on the host. The wheel uses hatch
`force-include` to expose the stub modules at the top level.
"""
"""Tests-only CPython package for MicroPython runtime replacements."""
Loading