diff --git a/.vscode/settings.json b/.vscode/settings.json index 1a062cf..d974587 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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" ] diff --git a/.vulture_allowlist.py b/.vulture_allowlist.py index 843633d..eef783a 100644 --- a/.vulture_allowlist.py +++ b/.vulture_allowlist.py @@ -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. diff --git a/AGENTS.md b/AGENTS.md index c689c19..8a07b9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//`) 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//`) 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. `` denotes any subdirectory under `projects/` — list it with `ls projects/` to see what's currently present, and substitute the real name when running commands. @@ -33,11 +33,11 @@ Before changing anything, identify the area you're touching: | Area | Path | Key files | | --- | --- | --- | -| Entry point | `projects//firmware/` | `main.py` — I²C scan, sensor init, JSON streaming loop | +| Entry point | `projects//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//viz/` | `app.py` — serial reader + WebSocket broadcaster on `/ws` | | Viz dashboard | `projects//viz/static/` | `index.html` — Plotly line chart + numeric readout | | Firmware compile | repo root | `Dockerfile.firmware` — stages: `pi-compile`, `esp32-compile`, `esp32-flash` | @@ -48,17 +48,7 @@ Before changing anything, identify the area you're touching: | RP firmware output | `projects//outputs/` | `app.rp2040.rp2350.uf2` — Universal UF2 for RP2040 + RP2350 | | ESP32 firmware output | `projects//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//`) +## Commands (copy/paste, run from `projects//`) #### Compile firmware ``` @@ -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 @@ -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///` (flat `.py` + `__init__.py`). Host tests live under `firmware-packages//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///` (flat `.py` + `__init__.py`). Host tests live under `firmware-packages//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`. diff --git a/Makefile b/Makefile index ffc57d1..25f1613 100644 --- a/Makefile +++ b/Makefile @@ -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 \ @@ -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[@]}"; \ diff --git a/cpython-packages/README.md b/cpython-packages/README.md index 15a855d..dedb97e 100644 --- a/cpython-packages/README.md +++ b/cpython-packages/README.md @@ -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 @@ -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.` 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 @@ -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. diff --git a/cpython-packages/micropython_stubs/README.md b/cpython-packages/micropython_stubs/README.md index a33d3ae..a7e93f8 100644 --- a/cpython-packages/micropython_stubs/README.md +++ b/cpython-packages/micropython_stubs/README.md @@ -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/.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). diff --git a/cpython-packages/micropython_stubs/micropython_stubs/__init__.py b/cpython-packages/micropython_stubs/micropython_stubs/__init__.py index 5c48598..78c7a60 100644 --- a/cpython-packages/micropython_stubs/micropython_stubs/__init__.py +++ b/cpython-packages/micropython_stubs/micropython_stubs/__init__.py @@ -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.""" diff --git a/cpython-packages/micropython_stubs/micropython_stubs/machine.py b/cpython-packages/micropython_stubs/micropython_stubs/machine.py index fd80286..4a797bd 100644 --- a/cpython-packages/micropython_stubs/micropython_stubs/machine.py +++ b/cpython-packages/micropython_stubs/micropython_stubs/machine.py @@ -1,19 +1,11 @@ -"""Host CPython stub of the `machine` module used by host pytest. - -Records Pin construction and routes I2C / SoftI2C reads + writes to fake -devices registered at the matching address. Union of the per-package stubs -previously kept under each `packages//stubs/` directory — exactly one -copy now lives here so every test in the session sees the same module. - -Test state (`pin_constructions`, `_devices`) is module-level; tests reset -it between cases by calling `reset()` from an autouse fixture. -""" +"""Host CPython stub of MicroPython's `machine` module.""" from __future__ import annotations -# Test state. Cleared by tests' autouse fixtures via reset(). +# Mutable test state. Clear it between cases with reset(). pin_constructions: list[tuple] = [] _devices: dict[int, object] = {} +_uart_lines: list[bytes] = [] def register_device(address: int, device: object) -> None: @@ -21,10 +13,16 @@ def register_device(address: int, device: object) -> None: _devices[address] = device +def feed_uart(lines: list[bytes]) -> None: + """Queue byte lines for UART.readline() to return in order (FIFO).""" + _uart_lines.extend(lines) + + def reset() -> None: - """Clear recorded pin constructions and the device registry.""" + """Clear recorded pin constructions, the device registry, and UART queue.""" pin_constructions.clear() _devices.clear() + _uart_lines.clear() class Pin: @@ -54,12 +52,7 @@ def value(self, v: int | None = None) -> int | None: class _I2CBase: - """Common fake I2C / SoftI2C — records bus id (positional) + sda/scl + freq. - - `I2C(0, sda=..., scl=...)` (hardware peripheral style, used by i2c_bus) - and `I2C(sda=..., scl=...)` (kwarg-only style, used by other packages) - both work. - """ + """Common fake I2C / SoftI2C implementation.""" def __init__( self, @@ -69,7 +62,7 @@ def __init__( freq: int = 100_000, **_kwargs: object, ) -> None: - """Record positional bus id (if any) and sda/scl/freq.""" + """Record bus id, pins, and frequency.""" self.id = args[0] if args else None self.sda = sda self.scl = scl @@ -79,14 +72,18 @@ def scan(self) -> list[int]: """Return registered device addresses in ascending order.""" return sorted(_devices.keys()) - def readfrom_mem(self, addr: int, reg: int, nbytes: int) -> bytes: - """Read `nbytes` from `addr`/`reg`; raises OSError when unregistered.""" + def readfrom_mem(self, addr: int, reg: int, nbytes: int, **_kwargs: object) -> bytes: + """Read `nbytes` from `addr`/`reg`; raises OSError when unregistered. + + `addrsize` is accepted and ignored; the fake register file is keyed by + `reg` as-is. + """ dev = _devices.get(addr) if dev is None: raise OSError("ENODEV") return dev.read(reg, nbytes) - def readfrom_mem_into(self, addr: int, reg: int, buf: bytearray) -> None: + def readfrom_mem_into(self, addr: int, reg: int, buf: bytearray, **_kwargs: object) -> None: """Read `len(buf)` from `addr`/`reg` into `buf` in place.""" dev = _devices.get(addr) if dev is None: @@ -95,7 +92,7 @@ def readfrom_mem_into(self, addr: int, reg: int, buf: bytearray) -> None: for i, b in enumerate(data): buf[i] = b - def writeto_mem(self, addr: int, reg: int, buf: bytes) -> None: + def writeto_mem(self, addr: int, reg: int, buf: bytes, **_kwargs: object) -> None: """Write `buf` to `addr`/`reg`; raises OSError when unregistered.""" dev = _devices.get(addr) if dev is None: @@ -109,3 +106,28 @@ class I2C(_I2CBase): class SoftI2C(_I2CBase): """Fake `machine.SoftI2C` (bit-banged).""" + + +class UART: + """Fake `machine.UART` backed by a queued byte-line reader.""" + + def __init__( + self, + id: int | None = None, # noqa: A002 + *_args: object, + baudrate: int = 9600, + tx: object = None, + rx: object = None, + timeout: int = 0, + **_kwargs: object, + ) -> None: + """Record the positional bus id and the tx/rx/baudrate/timeout kwargs.""" + self.id = id + self.baudrate = baudrate + self.tx = tx + self.rx = rx + self.timeout = timeout + + def readline(self) -> bytes | None: + """Return the next queued byte line, or None when the queue is empty.""" + return _uart_lines.pop(0) if _uart_lines else None diff --git a/cpython-packages/micropython_stubs/micropython_stubs/micropython.py b/cpython-packages/micropython_stubs/micropython_stubs/micropython.py index 1f1882d..76c3e19 100644 --- a/cpython-packages/micropython_stubs/micropython_stubs/micropython.py +++ b/cpython-packages/micropython_stubs/micropython_stubs/micropython.py @@ -1,10 +1,4 @@ -"""Host CPython stub of the `micropython` module — exposes `const` as an identity function. - -CPython has no compile-time constant folding analogous to MicroPython's -`micropython.const`, so the stub returns its argument unchanged. The -signature is `(x: object) -> object` to accommodate both integer register -addresses and the occasional `const(...)` over a non-int. -""" +"""Host CPython stub of MicroPython's `micropython` module.""" def const(x: object) -> object: diff --git a/cpython-packages/micropython_stubs/micropython_stubs/neopixel.py b/cpython-packages/micropython_stubs/micropython_stubs/neopixel.py index 82ba6f4..9e0f5ae 100644 --- a/cpython-packages/micropython_stubs/micropython_stubs/neopixel.py +++ b/cpython-packages/micropython_stubs/micropython_stubs/neopixel.py @@ -1,4 +1,4 @@ -"""Host CPython stub of the `neopixel` module that records every (r, g, b) written to each LED.""" +"""Host CPython stub of MicroPython's `neopixel` module.""" from __future__ import annotations @@ -6,7 +6,7 @@ class NeoPixel: - """Fake `neopixel.NeoPixel` strip that records every writeout for assertions.""" + """Fake `neopixel.NeoPixel` strip with recorded writes.""" instances: ClassVar[list[NeoPixel]] = [] diff --git a/cpython-packages/micropython_stubs/micropython_stubs/testing.py b/cpython-packages/micropython_stubs/micropython_stubs/testing.py new file mode 100644 index 0000000..c53b588 --- /dev/null +++ b/cpython-packages/micropython_stubs/micropython_stubs/testing.py @@ -0,0 +1,190 @@ +"""Shared fakes and AST-loading helpers for firmware main.py tests.""" + +from __future__ import annotations + +import ast +import io +import json +import pathlib +import types +from contextlib import redirect_stdout +from typing import Any, ClassVar + +import pytest +import ujson + +# (os.uname().machine string, expected BOARD.name) — exercises every per-chip +# branch of a project main.py's BOARD table on a real import. +BOARD_CHIPS = [ + ("RP2040 with RP2040", "RP2040-Zero"), + ("RP2350 with RP2350", "RP2350"), + ("Generic ESP32S3 module with ESP32S3", "ESP32-S3-Zero"), +] + + +class DeviceNotFoundError(Exception): + """Stand-in for a driver's DeviceNotFoundError in full-import tests. + + The happy path never raises it; it only needs to exist so the driver stub + can expose it under the name main.py imports. + """ + + +class FakeTime: + """Monotonic ticks_ms counter, ticks_diff, and no-op sleep_ms.""" + + def __init__(self) -> None: + """Initialise the monotonic counter at zero.""" + self.ticks = 0 + + def ticks_ms(self) -> int: + """Advance and return the monotonic tick counter.""" + self.ticks += 1 + return self.ticks + + def ticks_diff(self, a: int, b: int) -> int: + """Return the difference between two tick values.""" + return a - b + + def sleep_ms(self, _ms: int) -> None: + """No-op sleep for test determinism.""" + + +class FakeStatus: + """Record every transition call by name into self.calls.""" + + def __init__(self) -> None: + """Initialise an empty transition call log.""" + self.calls: list[str] = [] + + def __getattr__(self, name: str) -> Any: + """Return a recorder closure for any non-private attribute.""" + if name.startswith("_"): + raise AttributeError(name) + + def _rec() -> None: + self.calls.append(name) + + return _rec + + +def load_firmware_code(firmware_path: pathlib.Path, keep_funcs: set[str]) -> types.CodeType: + """AST-load a firmware main.py, keeping only assignments and named functions. + + Args: + firmware_path: Path to the firmware main.py file. + keep_funcs: Function names to retain from the module body. + + Returns: + Compiled code object ready for ``exec()`` into a namespace dict. + """ + src = firmware_path.read_text() + tree = ast.parse(src) + kept: list[ast.stmt] = [ + node + for node in tree.body + if isinstance(node, ast.Assign) + or (isinstance(node, ast.FunctionDef) and node.name in keep_funcs) + ] + module = ast.Module(body=kept, type_ignores=[]) + ast.fix_missing_locations(module) + return compile(module, str(firmware_path), "exec") + + +def firmware_namespace( + firmware_path: pathlib.Path, + keep_funcs: set[str], + **namespace: Any, +) -> types.SimpleNamespace: + """Exec AST-filtered firmware into a namespace with standard fakes. + + Args: + firmware_path: Path to the firmware main.py file. + keep_funcs: Function names to retain from the module body. + **namespace: Project-specific globals required by the retained code. + + Returns: + SimpleNamespace with ``ns``, ``time``, and ``status`` attributes. + """ + fake_time = FakeTime() + fake_status = FakeStatus() + ns = {"time": fake_time, "status": fake_status, "ujson": ujson, **namespace} + exec(load_firmware_code(firmware_path, keep_funcs), ns) # noqa: S102 + return types.SimpleNamespace(ns=ns, time=fake_time, status=fake_status) + + +def build_full_import_stubs( + driver_key: str, + driver_stub: types.SimpleNamespace, + status_stub: FakeStatus, +) -> dict[str, Any]: + """Build the sys.modules stub map for a project's full-import test. + + Every project's main.py imports the same base set (time, ujson, + boot_status_led) plus its one driver; only the driver name and stub differ. + + Args: + driver_key: Top-level module name main.py imports the driver from. + driver_stub: Namespace exposing the driver class and DeviceNotFoundError. + status_stub: FakeStatus recording LED transitions. + + Returns: + Mapping of module name to stub, ready for monkeypatch.setitem. + """ + return { + "time": FakeTime(), + "ujson": json, + "boot_status_led": types.SimpleNamespace(status=status_stub), + "boot_status_led.status": status_stub, + driver_key: driver_stub, + } + + +class ScriptedFake: + """Base for init-test driver fakes that pop a `script` on each construction. + + Subclasses declare their own ``script: ClassVar[list]`` and assign their + distinguishing attributes after calling ``super().__init__()``. The base + reads ``type(self).script`` (never ``self.script``) so each subclass keeps + its own list rather than sharing this base's mutable default. + """ + + script: ClassVar[list] = [] + + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + """Pop the next scripted outcome and raise it when it is an exception.""" + if type(self).script: + outcome = type(self).script.pop(0) + if isinstance(outcome, Exception): + raise outcome + + +class StopLoopError(Exception): + """Sentinel a scripted fake raises to break stream()'s otherwise-infinite loop.""" + + +def run_stream(main_ns: types.SimpleNamespace, sensor: Any) -> list[dict]: + """Drive stream(sensor) until the fake raises StopLoopError; return JSON lines. + + Args: + main_ns: Namespace from firmware_namespace, exposing ``ns["stream"]``. + sensor: Scripted fake passed straight to stream(). + + Returns: + One parsed dict per non-blank JSON line emitted to stdout. + """ + stream = main_ns.ns["stream"] + buf = io.StringIO() + with redirect_stdout(buf), pytest.raises(StopLoopError): + stream(sensor) + return [json.loads(line) for line in buf.getvalue().splitlines() if line.strip()] + + +def samples(lines: list[dict]) -> list[dict]: + """Return the data lines — those without a "diag" key.""" + return [ln for ln in lines if "diag" not in ln] + + +def diags(lines: list[dict]) -> list: + """Return the "diag" values from the diagnostic lines.""" + return [ln["diag"] for ln in lines if "diag" in ln] diff --git a/cpython-packages/micropython_stubs/micropython_stubs/ujson.py b/cpython-packages/micropython_stubs/micropython_stubs/ujson.py index 763ae77..dd23957 100644 --- a/cpython-packages/micropython_stubs/micropython_stubs/ujson.py +++ b/cpython-packages/micropython_stubs/micropython_stubs/ujson.py @@ -1,3 +1,3 @@ -"""Host CPython stub of `ujson` — re-exports CPython's `json` under the MicroPython name.""" +"""Host CPython stub of MicroPython's `ujson` module.""" from json import dumps # noqa: F401 diff --git a/cpython-packages/micropython_stubs/micropython_stubs/ustruct.py b/cpython-packages/micropython_stubs/micropython_stubs/ustruct.py index c42272a..e0ad1cc 100644 --- a/cpython-packages/micropython_stubs/micropython_stubs/ustruct.py +++ b/cpython-packages/micropython_stubs/micropython_stubs/ustruct.py @@ -1,3 +1,3 @@ -"""Host CPython stub of `ustruct` — re-exports CPython's `struct` under the MicroPython name.""" +"""Host CPython stub of MicroPython's `ustruct` module.""" from struct import calcsize, pack, unpack # noqa: F401 diff --git a/cpython-packages/micropython_stubs/micropython_stubs/utime.py b/cpython-packages/micropython_stubs/micropython_stubs/utime.py index f8771a8..2f31e85 100644 --- a/cpython-packages/micropython_stubs/micropython_stubs/utime.py +++ b/cpython-packages/micropython_stubs/micropython_stubs/utime.py @@ -1,10 +1,10 @@ -"""Host CPython stub of the `utime` module — sleep_ms is a no-op so tests never wait.""" +"""Host CPython stub of MicroPython's `utime` module.""" import time as _time def sleep_ms(ms: int) -> None: - """No-op stand-in for `utime.sleep_ms`; tests never need to actually wait.""" + """Return immediately instead of sleeping.""" def ticks_ms() -> int: diff --git a/cpython-packages/micropython_stubs/pyproject.toml b/cpython-packages/micropython_stubs/pyproject.toml index 95cf20f..21e02b3 100644 --- a/cpython-packages/micropython_stubs/pyproject.toml +++ b/cpython-packages/micropython_stubs/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "hatchling.build" [project] name = "micropython_stubs" -version = "0.2.0" -description = "Host CPython shims for MicroPython runtime modules (machine, neopixel, micropython, ustruct, utime, ujson). Tests only — never copied to the device." +version = "0.3.0" +description = "Host CPython replacements for MicroPython runtime modules (machine, neopixel, micropython, ustruct, utime, ujson). Tests only; never copied to the device." requires-python = ">=3.12" -# force-include re-ships each stub at the wheel root so host tests can `import machine` exactly as on-device firmware does. +# Host tests import these replacements by their MicroPython top-level names. [tool.hatch.build.targets.wheel] packages = ["micropython_stubs"] diff --git a/cpython-packages/serial_over_web/pyproject.toml b/cpython-packages/serial_over_web/pyproject.toml index 76bdbf4..56b3b50 100644 --- a/cpython-packages/serial_over_web/pyproject.toml +++ b/cpython-packages/serial_over_web/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "hatchling.build" [project] name = "serial_over_web" -version = "0.1.0" +version = "0.1.1" description = "Shared FastAPI dashboard server for boardfarm project serial streams" requires-python = ">=3.12" dependencies = [ - "fastapi>=0.136.3", + "fastapi>=0.138.0", "pydantic>=2.13.4", "uvicorn[standard]>=0.48.0", "pyserial>=3.5", diff --git a/docker-bake.hcl b/docker-bake.hcl index 9806a41..41a5709 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -1,7 +1,7 @@ // Build definitions for the build-only call sites: the lint/typecheck images -// (.githooks/run-linters.sh, `make precommit`) and the CVE-scan images -// (.github/workflows/ci.yml). Compose owns the *runtime* services — pytest, -// firmware compile, viz, uv — and supplies the wheels context itself via +// (`make build-linters`) and the CVE-scan images (.github/workflows/ci.yml). +// Compose owns the *runtime* services — pytest, firmware compile, viz, uv — +// and supplies the wheels context itself via // `additional_contexts: wheels: service:wheels`. Bake supplies the same context // here via `contexts = { wheels = "target:wheels" }`, so the wheels build logic // lives only once in Dockerfile.host (no mirrored stage in Dockerfile.tests). diff --git a/firmware-packages/atgm336h/README.md b/firmware-packages/atgm336h/README.md new file mode 100644 index 0000000..dbbbc37 --- /dev/null +++ b/firmware-packages/atgm336h/README.md @@ -0,0 +1,27 @@ +# atgm336h + +MCU package for the ATGM336H GNSS module: reads NMEA sentences over UART. The +constructor takes flat pin numbers and opens its own UART, so the project's +`BOARD` table supplies only pins. + +## Public API +```python +from atgm336h import GPS, DeviceNotFoundError + +gps = GPS(bus_id=0, tx=0, rx=1) # opens the UART at 9600 baud + probes for bytes +line = gps.readline() # "$GPRMC,..." or None on timeout +``` + +`readline()` returns one decoded NMEA sentence (starting with `$`), or `None` on +timeout, decode error, or a non-NMEA line. The constructor raises +`DeviceNotFoundError` if no NMEA bytes arrive within the probe budget (~2 s). + +## Pin numbers live in the project +Pin numbers are not in this package. Each project defines its own `BOARD` table +of plain pin numbers in `main.py` via `os.uname().machine` dispatch and passes +them as flat keyword arguments to `GPS()`. + +## Tests +``` +docker compose up pytest --build --exit-code-from pytest -- /firmware-packages/atgm336h/tests +``` diff --git a/firmware-packages/atgm336h/atgm336h/__init__.py b/firmware-packages/atgm336h/atgm336h/__init__.py index 21fd6cc..b9d7ba2 100644 --- a/firmware-packages/atgm336h/atgm336h/__init__.py +++ b/firmware-packages/atgm336h/atgm336h/__init__.py @@ -1,27 +1,59 @@ -"""MCU-micropython ATGM336H package — chip-dispatched UART NMEA reader. +"""MCU-micropython ATGM336H package — a UART NMEA reader. + +Takes flat pin numbers and opens its own UART. Since UART has no address scan, +the constructor probes the line: a wired ATGM336H streams NMEA continuously, so +if no bytes arrive within ``probe_ms`` it raises ``DeviceNotFoundError``. Example: - from atgm336h import connect - gps = connect() # chip-specific UART initialised here - line = gps.readline() # "$GPRMC,..." or None on timeout + from atgm336h import GPS, DeviceNotFoundError + gps = GPS(bus_id=0, tx=0, rx=1) # UART opened + probed here + line = gps.readline() # "$GPRMC,..." or None on timeout """ -import os +import utime + +__all__ = ["GPS", "DeviceNotFoundError"] + +# Default probe budget. The ATGM336H emits at least one sentence per second, so +# ~2 s reliably catches a wired module while staying short on a dead line. +_PROBE_MS = 2_000 +_PROBE_POLL_MS = 10 -__all__ = ["GPS", "connect"] + +class DeviceNotFoundError(Exception): + """No NMEA bytes arrived on the UART within the probe budget. + + Raised (instead of a generic exception) so a project's retry loop can tell + "nothing connected" — bad wiring, power, or TX/RX swap — apart from a + transient read error once streaming. + """ class GPS: - """NMEA sentence reader that wraps a chip-specific UART instance.""" + """NMEA sentence reader over a UART the driver opens from flat pins.""" - def __init__(self, uart: object) -> None: - """Store the UART instance for later reads. + def __init__(self, *, bus_id: int, tx: int, rx: int, probe_ms: int = _PROBE_MS) -> None: + """Open the wired UART at 9600 baud and confirm the module is alive. Args: - uart: A ``machine.UART`` instance configured at 9600 baud with a - short timeout so readline() returns without blocking the loop. + bus_id: Selects the UART peripheral. + tx: GPIO number driving the GPS RX line. + rx: GPIO number carrying the NMEA stream back. + probe_ms: How long to wait for the first bytes before giving up. """ - self._uart = uart + from machine import UART, Pin # noqa: PLC0415 + + self._uart = UART(bus_id, baudrate=9600, tx=Pin(tx), rx=Pin(rx), timeout=100) + self._probe(probe_ms) + + def _probe(self, probe_ms: int) -> None: + """Wait for the first byte line; raise DeviceNotFoundError if none arrives.""" + t_start = utime.ticks_ms() + while utime.ticks_diff(utime.ticks_ms(), t_start) < probe_ms: + if self._uart.readline() is not None: + return + utime.sleep_ms(_PROBE_POLL_MS) + raise DeviceNotFoundError(f"no NMEA bytes within {probe_ms} ms") def readline(self) -> str | None: """Read one NMEA sentence from UART. @@ -39,19 +71,3 @@ def readline(self) -> str | None: except (ValueError, UnicodeError): return None return line if line.startswith("$") else None - - -def connect() -> "GPS": - """Open the chip-specific UART and return a ready-to-use GPS instance. - - Returns: - A GPS instance wrapping the chip-specific UART. - """ - _machine = os.uname().machine - if "ESP32S3" in _machine: - from atgm336h.esp32s3 import uart as _uart # noqa: PLC0415 - elif "RP2350" in _machine: - from atgm336h.rp2350 import uart as _uart # noqa: PLC0415 - else: - from atgm336h.rp2040 import uart as _uart # noqa: PLC0415 - return GPS(_uart) diff --git a/firmware-packages/atgm336h/atgm336h/esp32s3.py b/firmware-packages/atgm336h/atgm336h/esp32s3.py deleted file mode 100644 index 2660705..0000000 --- a/firmware-packages/atgm336h/atgm336h/esp32s3.py +++ /dev/null @@ -1,8 +0,0 @@ -"""MCU-micropython backend for the ESP32-S3 UART1 bus (GPIO17=TX, GPIO18=RX) at 9600 baud.""" - -from machine import UART - -# ATGM336H TX → GPIO18 (MCU RX). GPIO17 (MCU TX) is optional for NMEA commands. -# timeout=100 ms keeps readline() non-blocking so the 10-second collection window -# can check elapsed time between calls without stalling. -uart: UART = UART(1, baudrate=9600, tx=17, rx=18, timeout=100) diff --git a/firmware-packages/atgm336h/atgm336h/rp2040.py b/firmware-packages/atgm336h/atgm336h/rp2040.py deleted file mode 100644 index 81fcee4..0000000 --- a/firmware-packages/atgm336h/atgm336h/rp2040.py +++ /dev/null @@ -1,8 +0,0 @@ -"""MCU-micropython backend for the RP2040 UART0 bus (GP0=TX, GP1=RX) at 9600 baud.""" - -from machine import UART, Pin - -# ATGM336H TX → GP1 (MCU RX). GP0 (MCU TX) is optional for sending NMEA commands. -# timeout=100 ms keeps readline() non-blocking so the 10-second collection window -# can check elapsed time between calls without stalling. -uart: UART = UART(0, baudrate=9600, tx=Pin(0), rx=Pin(1), timeout=100) diff --git a/firmware-packages/atgm336h/atgm336h/rp2350.py b/firmware-packages/atgm336h/atgm336h/rp2350.py deleted file mode 100644 index f4c4adb..0000000 --- a/firmware-packages/atgm336h/atgm336h/rp2350.py +++ /dev/null @@ -1,8 +0,0 @@ -"""MCU-micropython backend for the RP2350 UART0 bus (GP0=TX, GP1=RX) at 9600 baud.""" - -from machine import UART, Pin - -# Same pin layout as RP2040-Zero / RP2350-Zero. -# timeout=100 ms keeps readline() non-blocking so the 10-second collection window -# can check elapsed time between calls without stalling. -uart: UART = UART(0, baudrate=9600, tx=Pin(0), rx=Pin(1), timeout=100) diff --git a/firmware-packages/atgm336h/pyproject.toml b/firmware-packages/atgm336h/pyproject.toml index 155c56a..3f65833 100644 --- a/firmware-packages/atgm336h/pyproject.toml +++ b/firmware-packages/atgm336h/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "atgm336h" -version = "0.1.0" +version = "0.2.0" description = "MicroPython UART NMEA reader for the ATGM336H GPS module, with chip-specific backends." requires-python = ">=3.12" diff --git a/firmware-packages/atgm336h/tests/conftest.py b/firmware-packages/atgm336h/tests/conftest.py deleted file mode 100644 index 765a655..0000000 --- a/firmware-packages/atgm336h/tests/conftest.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Shared fixtures for atgm336h package tests.""" - -import machine -import neopixel -import pytest - - -@pytest.fixture(autouse=True) -def _reset_devices() -> None: - """Clear machine and neopixel state between tests.""" - machine.reset() - neopixel.reset() diff --git a/firmware-packages/atgm336h/tests/fake_uart.py b/firmware-packages/atgm336h/tests/fake_uart.py deleted file mode 100644 index 49059b0..0000000 --- a/firmware-packages/atgm336h/tests/fake_uart.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Fake UART for injecting NMEA byte lines into GPS driver tests.""" - -from __future__ import annotations - - -class FakeUART: - """Minimal UART stand-in that replays a fixed list of byte lines.""" - - def __init__(self, lines: list[bytes]) -> None: - """Store the line queue. - - Args: - lines: Sequence of byte strings to emit in order via readline(). - readline() returns None once the list is exhausted. - """ - self._lines: list[bytes] = list(lines) - - def readline(self) -> bytes | None: - """Return the next queued line, or None when the queue is empty.""" - return self._lines.pop(0) if self._lines else None diff --git a/firmware-packages/atgm336h/tests/test_gps.py b/firmware-packages/atgm336h/tests/test_gps.py index 0568f83..5dba88f 100644 --- a/firmware-packages/atgm336h/tests/test_gps.py +++ b/firmware-packages/atgm336h/tests/test_gps.py @@ -1,75 +1,51 @@ -"""Tests for atgm336h.GPS readline() behaviour using a FakeUART.""" +"""Tests for atgm336h: NMEA line parsing, UART open, and the presence probe.""" +import machine import pytest -from fake_uart import FakeUART -from atgm336h import GPS +from atgm336h import GPS, DeviceNotFoundError _GPRMC = b"$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A\r\n" _GPGGA = b"$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47\r\n" +_GPGSV = b"$GPGSV,2,1,08,01,40,083,46,02,17,308,41,12,07,344,39,14,22,228,45*75\r\n" -def test_readline_none_when_no_data(): - gps = GPS(FakeUART([])) - assert gps.readline() is None - - -def test_readline_returns_gprmc(): - gps = GPS(FakeUART([_GPRMC])) - result = gps.readline() - assert result is not None - assert result.startswith("$GPRMC") - - -def test_readline_returns_gpgga(): - gps = GPS(FakeUART([_GPGGA])) - result = gps.readline() - assert result is not None - assert result.startswith("$GPGGA") - +def _make_gps(uart_lines): + """Create a GPS instance with pre-fed UART data (first line consumed by probe).""" + machine.reset() + machine.feed_uart(uart_lines) + return GPS(bus_id=0, tx=0, rx=1) -def test_readline_strips_crlf(): - gps = GPS(FakeUART([_GPRMC])) - result = gps.readline() - assert result is not None - assert not result.endswith("\r\n") - assert not result.endswith("\n") - -def test_readline_filters_non_nmea_line(): - gps = GPS(FakeUART([b"junk data\r\n"])) - assert gps.readline() is None - - -def test_readline_filters_empty_line(): - gps = GPS(FakeUART([b"\r\n"])) - assert gps.readline() is None +@pytest.mark.parametrize( + "raw,expected", + [ + (None, None), + (b"junk data\r\n", None), + (b"\r\n", None), + (b"\xff\xfe\r\n", None), + (_GPRMC, _GPRMC.decode().strip()), + (_GPGGA, _GPGGA.decode().strip()), + (_GPGSV, _GPGSV.decode().strip()), + ], +) +def test_readline_parses(raw, expected): + """readline() decodes, strips, and validates the NMEA ``$`` prefix.""" + probe_line = [_GPRMC] + data_line = [] if raw is None else [raw] + gps = _make_gps(probe_line + data_line) + assert gps.readline() == expected -def test_readline_handles_decode_error(): - gps = GPS(FakeUART([b"\xff\xfe\r\n"])) - assert gps.readline() is None +def test_gps_probe_raises_device_not_found_on_quiet_line(): + """No bytes within the probe budget → DeviceNotFoundError (probe_ms=0 = one pass).""" + machine.reset() + with pytest.raises(DeviceNotFoundError): + GPS(bus_id=0, tx=0, rx=1, probe_ms=0) -def test_readline_drains_queue_in_order(): - gps = GPS(FakeUART([_GPRMC, _GPGGA])) - first = gps.readline() - second = gps.readline() - assert first is not None and first.startswith("$GPRMC") - assert second is not None and second.startswith("$GPGGA") +def test_gps_readline_streams_after_probe(): + """The probe consumes the first line; readline() yields the next parsed one.""" + gps = _make_gps([_GPRMC, _GPGGA]) + assert gps.readline().startswith("$GPGGA") assert gps.readline() is None - - -@pytest.mark.parametrize( - "raw", - [ - b"$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A\r\n", - b"$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47\r\n", - b"$GPGSV,2,1,08,01,40,083,46,02,17,308,41,12,07,344,39,14,22,228,45*75\r\n", - ], -) -def test_readline_accepts_standard_nmea_sentences(raw: bytes): - gps = GPS(FakeUART([raw])) - result = gps.readline() - assert result is not None - assert result.startswith("$") diff --git a/firmware-packages/boot_status_led/boot_status_led/status.py b/firmware-packages/boot_status_led/boot_status_led/status.py index bc88ca8..78d7f43 100644 --- a/firmware-packages/boot_status_led/boot_status_led/status.py +++ b/firmware-packages/boot_status_led/boot_status_led/status.py @@ -5,6 +5,7 @@ Public API: status.boot() # white — firmware running, before I/O status.i2c_init() # cyan — I²C bus configured, scanning for device(s) + status.uart_init() # blue — UART bus configured, scanning for device(s) status.no_device() # orange — bus reachable, device(s) not present status.init_err() # magenta — device(s) ACKed but driver init raised status.streaming() # green — device(s) live, samples flowing @@ -30,6 +31,7 @@ # RP2350 backend treats anything that isn't pure green as "off" BOOT = (255, 255, 255) I2C_INIT = (0, 255, 255) +UART_INIT = (0, 0, 255) NO_DEVICE = (255, 128, 0) INIT_ERR = (255, 0, 255) STREAMING = (0, 255, 0) @@ -46,6 +48,11 @@ def i2c_init() -> None: _show(I2C_INIT) +def uart_init() -> None: + """Signal UART bus configured, scanning for sensor (blue).""" + _show(UART_INIT) + + def no_device() -> None: """Signal bus reachable, device not present (orange).""" _show(NO_DEVICE) diff --git a/firmware-packages/boot_status_led/pyproject.toml b/firmware-packages/boot_status_led/pyproject.toml index 734a111..8625153 100644 --- a/firmware-packages/boot_status_led/pyproject.toml +++ b/firmware-packages/boot_status_led/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "boot_status_led" -version = "0.2.0" +version = "0.3.0" description = "Chip-dispatched boot/error status LED state machine." requires-python = ">=3.12" diff --git a/firmware-packages/boot_status_led/tests/conftest.py b/firmware-packages/boot_status_led/tests/conftest.py deleted file mode 100644 index ec9949a..0000000 --- a/firmware-packages/boot_status_led/tests/conftest.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Host CPython pytest fixtures for boot_status_led. - -`chip` swaps os.uname so the dispatcher picks each backend in turn. -`_reset_stubs` clears recorded pin/NeoPixel state and the -`boot_status_led.*` import cache between tests so chip-dispatch reruns -from a clean slate. -""" - -import os -import sys - -import machine -import neopixel -import pytest - - -@pytest.fixture -def chip(request, monkeypatch): - """Set os.uname().machine to `request.param` while the test runs.""" - - class _Uname: - machine = request.param - - monkeypatch.setattr(os, "uname", _Uname) - return request.param - - -@pytest.fixture(autouse=True) -def _reset_stubs(): - """Clear shared stub state and cached imports each test.""" - machine.reset() - neopixel.reset() - for mod in list(sys.modules): - if mod.startswith("boot_status_led"): - del sys.modules[mod] - yield diff --git a/firmware-packages/boot_status_led/tests/test_status.py b/firmware-packages/boot_status_led/tests/test_status.py index d7f395f..d2cfe0b 100644 --- a/firmware-packages/boot_status_led/tests/test_status.py +++ b/firmware-packages/boot_status_led/tests/test_status.py @@ -6,11 +6,22 @@ import importlib import os +import sys +import machine import neopixel import pytest +def _reset_stubs(): + """Clear shared stub state and cached imports.""" + machine.reset() + neopixel.reset() + for mod in list(sys.modules): + if mod.startswith("boot_status_led"): + del sys.modules[mod] + + @pytest.mark.parametrize( "chip,backend_mod", [ @@ -18,13 +29,20 @@ ("RP2350", "boot_status_led.rp2350"), ("ESP32S3", "boot_status_led.esp32s3"), ], - indirect=["chip"], ) -def test_status_dispatch_picks_correct_backend(chip, backend_mod, status_module): +def test_status_dispatch_picks_correct_backend(chip, backend_mod, monkeypatch): + _reset_stubs() + + class _Uname: + machine = chip + + monkeypatch.setattr(os, "uname", _Uname) + status_module = importlib.import_module("boot_status_led.status") assert status_module._show.__module__ == backend_mod def test_rp2040_backend_scales_brightness(): + _reset_stubs() os.uname = type("U", (), {"machine": "RP2040 with RP2040"}) status_module = importlib.import_module("boot_status_led.status") @@ -34,6 +52,7 @@ def test_rp2040_backend_scales_brightness(): def test_rp2350_backend_collapses_to_on_off(): + _reset_stubs() os.uname = type("U", (), {"machine": "RP2350 with RP2350"}) status_module = importlib.import_module("boot_status_led.status") rp2350 = importlib.import_module("boot_status_led.rp2350") @@ -46,6 +65,7 @@ def test_rp2350_backend_collapses_to_on_off(): def test_esp32s3_backend_scales_brightness(): + _reset_stubs() os.uname = type("U", (), {"machine": "Generic ESP32S3 module with ESP32S3"}) status_module = importlib.import_module("boot_status_led.status") @@ -61,18 +81,14 @@ def test_esp32s3_backend_scales_brightness(): ("i2c_init", (0, 255, 255)), ("no_device", (255, 128, 0)), ("init_err", (255, 0, 255)), + ("uart_init", (0, 0, 255)), ], ) def test_named_transitions_write_expected_colour(transition, expected): + _reset_stubs() os.uname = type("U", (), {"machine": "RP2040 with RP2040"}) status_module = importlib.import_module("boot_status_led.status") getattr(status_module, transition)() scaled = tuple(int(c * 0.1) for c in expected) assert neopixel.NeoPixel.instances[0].writes[-1] == scaled - - -@pytest.fixture -def status_module(chip): - """Re-import boot_status_led.status after `chip` patches os.uname.""" - return importlib.import_module("boot_status_led.status") diff --git a/firmware-packages/i2c_bus/README.md b/firmware-packages/i2c_bus/README.md index 8d3b248..eb49c8a 100644 --- a/firmware-packages/i2c_bus/README.md +++ b/firmware-packages/i2c_bus/README.md @@ -1,32 +1,26 @@ # i2c_bus -MCU package exposing `soft_i2c` and `hard_i2c` bus objects. - -## Layout -``` -i2c_bus/ - i2c_bus/ - __init__.py dispatches to a backend based on os.uname().machine - rp2040.py GP0=SDA, GP1=SCL — exposes soft_i2c + hard_i2c - rp2350.py GP0=SDA, GP1=SCL — same wiring as RP2040 - esp32s3.py GPIO1=SDA, GPIO2=SCL — exposes soft_i2c + hard_i2c -``` +Internal I²C bus helpers, consumed only by sensor drivers. A driver supplies +plain pin numbers from the project's `BOARD` table and gets back a ready +`machine.I2C` / `SoftI2C`; the project never sees this package. ## Public API ```python -from i2c_bus import soft_i2c # for sensors that clock-stretch (e.g. VL53L0X) -from i2c_bus import hard_i2c # sensors that don't (e.g. MPU6050) +from i2c_bus import DeviceNotFoundError, hard_i2c, soft_i2c + +bus = hard_i2c(bus_id=0, sda=0, scl=1) # sensors that don't clock-stretch (MPU6050) +bus = soft_i2c(sda=0, scl=1) # sensors that do (VL53L0X / VL53L5CX) ``` -## Notes -None +`soft_i2c` defaults to 100 kHz, `hard_i2c` to 400 kHz; pass `freq=` to override. +`soft_i2c` is bit-banged and has no `bus_id`. -**Adding a new chip:** -1. Add a backend module under `i2c_bus/` named `.py` exposing - both `soft_i2c` and `hard_i2c`. -2. Extend the dispatch in `i2c_bus/__init__.py` with a new - `os.uname().machine` substring match. -3. Update `boot_status_led` similarly so the LED has a backend too. +## Pin numbers live in the project +Pin numbers are not in this package. Each project defines its own `BOARD` table +of plain pin numbers in `main.py` via `os.uname().machine` dispatch and passes +them as flat keyword arguments to the driver, which forwards them here. ## Tests -None, this package is a pin-wiring layer. +``` +docker compose up pytest --build --exit-code-from pytest -- /firmware-packages/i2c_bus/tests +``` diff --git a/firmware-packages/i2c_bus/i2c_bus/__init__.py b/firmware-packages/i2c_bus/i2c_bus/__init__.py index a70553e..61133b0 100644 --- a/firmware-packages/i2c_bus/i2c_bus/__init__.py +++ b/firmware-packages/i2c_bus/i2c_bus/__init__.py @@ -1,46 +1,60 @@ -"""MCU-micropython package selecting the correct I²C chip backend at import time. +"""MCU-micropython internal I²C bus helpers, consumed only by sensor drivers. -Example: - from i2c_bus import soft_i2c # sensors that clock-stretch (VL53L0X) - from i2c_bus import hard_i2c # sensors that don't (MPU6050) +A driver supplies the plain pin numbers from the project's ``BOARD`` table and +gets back a ready ``machine.I2C`` / ``SoftI2C``; the project never sees this +package. Nothing here touches ``os.uname()`` or claims a pin at import time. + +Example (inside a driver): + from i2c_bus import DeviceNotFoundError, hard_i2c + i2c = hard_i2c(bus_id=0, sda=0, scl=1) # sensors that don't clock-stretch + if address not in i2c.scan(): + raise DeviceNotFoundError(...) """ -import os +__all__ = ["DeviceNotFoundError", "hard_i2c", "soft_i2c"] + -import i2c_bus.esp32s3 -import i2c_bus.rp2040 -import i2c_bus.rp2350 +class DeviceNotFoundError(Exception): + """No expected device ACKed on the opened I²C bus. -_machine = os.uname().machine -if "ESP32S3" in _machine: - _backend = i2c_bus.esp32s3 -elif "RP2350" in _machine: - _backend = i2c_bus.rp2350 -else: - _backend = i2c_bus.rp2040 + Drivers raise this (instead of a generic ``OSError``) after scanning so a + project's retry loop can tell "nothing on the bus" — bad wiring, power, or + pull-ups — apart from "device present but init failed". + """ -def __getattr__(name: str) -> object: - """Lazily forward bus lookups to the chip-specific backend. +def soft_i2c(sda: int, scl: int, freq: int = 100_000) -> object: + """Build a bit-banged SoftI2C on the wired pins. - Only the requested bus is instantiated — importing soft_i2c never - creates hard_i2c, so the two I²C peripherals cannot conflict on shared - pins even though both backends define both names. + Soft I²C tolerates the heavy clock-stretching some sensors do during + bring-up (the VL53L0X/VL53L5CX firmware upload), which the hardware + peripheral aborts on. Args: - name: Attribute name; must be ``soft_i2c`` or ``hard_i2c``. + sda: GPIO number for the data line. + scl: GPIO number for the clock line. + freq: Bus clock in Hz; 100 kHz keeps long clock-stretches stable. Returns: - The constructed I²C bus instance for the running chip. - - Raises: - AttributeError: When name is not a known bus export. + A ready ``machine.SoftI2C``. """ - if name not in ("soft_i2c", "hard_i2c"): - raise AttributeError(name) - v = getattr(_backend, name) - globals()[name] = v # cache so subsequent accesses bypass __getattr__ - return v + from machine import Pin, SoftI2C # noqa: PLC0415 + + return SoftI2C(sda=Pin(sda), scl=Pin(scl), freq=freq) + +def hard_i2c(bus_id: int, sda: int, scl: int, freq: int = 400_000) -> object: + """Build a hardware I2C peripheral on the wired pins. + + Args: + bus_id: Selects the hardware I²C peripheral. + sda: GPIO number for the data line. + scl: GPIO number for the clock line. + freq: Bus clock in Hz; 400 kHz for sensors that don't clock-stretch. + + Returns: + A ready ``machine.I2C``. + """ + from machine import I2C, Pin # noqa: PLC0415 -__all__ = ["hard_i2c", "soft_i2c"] + return I2C(bus_id, sda=Pin(sda), scl=Pin(scl), freq=freq) diff --git a/firmware-packages/i2c_bus/i2c_bus/esp32s3.py b/firmware-packages/i2c_bus/i2c_bus/esp32s3.py deleted file mode 100644 index c774f4e..0000000 --- a/firmware-packages/i2c_bus/i2c_bus/esp32s3.py +++ /dev/null @@ -1,30 +0,0 @@ -"""MCU-micropython backend for the ESP32-S3 I²C bus (GPIO1=SDA, GPIO2=SCL, soft + hardware).""" - -from machine import I2C, Pin, SoftI2C - - -def __getattr__(name: str) -> object: - """Lazily instantiate the requested bus so the other is never created. - - Both buses share GPIO1/GPIO2. See rp2040.py for the pin-conflict rationale. - - Args: - name: ``soft_i2c`` or ``hard_i2c``. - - Returns: - A SoftI2C or I2C instance on GPIO1/GPIO2. - - Raises: - AttributeError: When name is not ``soft_i2c`` or ``hard_i2c``. - """ - if name == "soft_i2c": - # SoftI2C: hw I²C NACKs on register 0x83 during VL53L0X SPAD-info - # because the sensor clock-stretches across that operation and hw I²C - # loses sync. - v = SoftI2C(sda=Pin(1), scl=Pin(2), freq=100_000) - elif name == "hard_i2c": - v = I2C(0, sda=Pin(1), scl=Pin(2), freq=400_000) - else: - raise AttributeError(name) - globals()[name] = v - return v diff --git a/firmware-packages/i2c_bus/i2c_bus/rp2040.py b/firmware-packages/i2c_bus/i2c_bus/rp2040.py deleted file mode 100644 index a7a2fdb..0000000 --- a/firmware-packages/i2c_bus/i2c_bus/rp2040.py +++ /dev/null @@ -1,34 +0,0 @@ -"""MCU-micropython backend for the RP2040 I²C bus (GP0=SDA, GP1=SCL, soft + hardware).""" - -from machine import I2C, Pin, SoftI2C - - -def __getattr__(name: str) -> object: - """Lazily instantiate the requested bus so the other is never created. - - Both buses share GP0/GP1. Instantiating I2C(0, ...) configures the pad - multiplexer for the hardware peripheral, which prevents SoftI2C from - bit-banging those pins. Lazy creation ensures only the imported bus - touches the hardware. - - Args: - name: ``soft_i2c`` or ``hard_i2c``. - - Returns: - A SoftI2C or I2C instance on GP0/GP1. - - Raises: - AttributeError: When name is not ``soft_i2c`` or ``hard_i2c``. - """ - if name == "soft_i2c": - # SoftI2C tolerates VL53L0X clock-stretching — the hardware peripheral - # aborts stretched transactions and range reads silently return 0. - v = SoftI2C(sda=Pin(0), scl=Pin(1), freq=100_000) - elif name == "hard_i2c": - # Hardware I2C0 on the same pins — use for sensors that don't - # clock-stretch (e.g. MPU6050). - v = I2C(0, sda=Pin(0), scl=Pin(1), freq=400_000) - else: - raise AttributeError(name) - globals()[name] = v # cache so subsequent accesses bypass __getattr__ - return v diff --git a/firmware-packages/i2c_bus/i2c_bus/rp2350.py b/firmware-packages/i2c_bus/i2c_bus/rp2350.py deleted file mode 100644 index a00124e..0000000 --- a/firmware-packages/i2c_bus/i2c_bus/rp2350.py +++ /dev/null @@ -1,28 +0,0 @@ -"""MCU-micropython backend for the RP2350 I²C bus (GP0=SDA, GP1=SCL — same wiring as RP2040).""" - -from machine import I2C, Pin, SoftI2C - - -def __getattr__(name: str) -> object: - """Lazily instantiate the requested bus so the other is never created. - - Both buses share GP0/GP1. See rp2040.py for the pin-conflict rationale. - - Args: - name: ``soft_i2c`` or ``hard_i2c``. - - Returns: - A SoftI2C or I2C instance on GP0/GP1. - - Raises: - AttributeError: When name is not ``soft_i2c`` or ``hard_i2c``. - """ - if name == "soft_i2c": - # SoftI2C: same clock-stretch rationale as rp2040.py. - v = SoftI2C(sda=Pin(0), scl=Pin(1), freq=100_000) - elif name == "hard_i2c": - v = I2C(0, sda=Pin(0), scl=Pin(1), freq=400_000) - else: - raise AttributeError(name) - globals()[name] = v - return v diff --git a/firmware-packages/i2c_bus/pyproject.toml b/firmware-packages/i2c_bus/pyproject.toml index 84f7e40..862806c 100644 --- a/firmware-packages/i2c_bus/pyproject.toml +++ b/firmware-packages/i2c_bus/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "i2c_bus" -version = "0.2.0" +version = "0.3.0" description = "Chip-dispatched lazy I2C bus accessors for RP2040/RP2350/ESP32-S3." requires-python = ">=3.12" diff --git a/firmware-packages/i2c_bus/tests/conftest.py b/firmware-packages/i2c_bus/tests/conftest.py deleted file mode 100644 index b3dc86d..0000000 --- a/firmware-packages/i2c_bus/tests/conftest.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Host CPython pytest fixtures for i2c_bus. - -`chip` swaps os.uname so the dispatcher picks each backend in turn. -`_reset_modules` clears cached i2c_bus modules between tests so the -dispatcher re-runs its os.uname() lookup on every parametrization. -""" - -import os -import sys - -import pytest - - -@pytest.fixture -def chip(request, monkeypatch): - """Set os.uname().machine to `request.param` while the test runs.""" - - class _Uname: - machine = request.param - - monkeypatch.setattr(os, "uname", _Uname) - return request.param - - -@pytest.fixture(autouse=True) -def _reset_modules(): - """Drop cached i2c_bus modules each test so import-time dispatch reruns.""" - for mod in list(sys.modules): - if mod.startswith("i2c_bus"): - del sys.modules[mod] - yield diff --git a/firmware-packages/i2c_bus/tests/test_i2c_bus.py b/firmware-packages/i2c_bus/tests/test_i2c_bus.py deleted file mode 100644 index b0df394..0000000 --- a/firmware-packages/i2c_bus/tests/test_i2c_bus.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Host CPython pytest tests covering i2c_bus dispatch and per-chip backends. - -Asserts each chip backend exposes soft_i2c/hard_i2c on the correct pins -at the correct frequencies, the package dispatcher selects the right -backend per os.uname().machine, and the lazy __getattr__ caches so the -unused bus is never instantiated — the load-bearing invariant that lets -the two buses share physical pins without conflict. -""" - -import importlib - -import pytest - -# (machine string, expected backend module, sda pin, scl pin) -_CHIPS = [ - ("RP2040 with RP2040", "i2c_bus.rp2040", 0, 1), - ("RP2350 with RP2350", "i2c_bus.rp2350", 0, 1), - ("Generic ESP32S3 module with ESP32S3", "i2c_bus.esp32s3", 1, 2), -] - - -@pytest.mark.parametrize( - "chip,backend_mod,_sda,_scl", - _CHIPS, - indirect=["chip"], -) -def test_dispatch_picks_correct_backend(chip, backend_mod, _sda, _scl, i2c_bus_module): - assert i2c_bus_module._backend is importlib.import_module(backend_mod) - - -@pytest.mark.parametrize( - "chip,_backend_mod,sda,scl", - _CHIPS, - indirect=["chip"], -) -def test_soft_i2c_uses_100khz_on_chip_pins(chip, _backend_mod, sda, scl, i2c_bus_module): - bus = i2c_bus_module.soft_i2c - # type(bus).__name__ instead of isinstance: see conftest re: machine-stub seeding. - assert type(bus).__name__ == "SoftI2C" - assert bus.sda.id == sda - assert bus.scl.id == scl - assert bus.freq == 100_000 - - -@pytest.mark.parametrize( - "chip,_backend_mod,sda,scl", - _CHIPS, - indirect=["chip"], -) -def test_hard_i2c_uses_400khz_on_chip_pins(chip, _backend_mod, sda, scl, i2c_bus_module): - bus = i2c_bus_module.hard_i2c - assert type(bus).__name__ == "I2C" - assert bus.id == 0 - assert bus.sda.id == sda - assert bus.scl.id == scl - assert bus.freq == 400_000 - - -@pytest.mark.parametrize("chip", [c[0] for c in _CHIPS], indirect=True) -def test_dispatcher_caches(chip, i2c_bus_module): - assert i2c_bus_module.soft_i2c is i2c_bus_module.soft_i2c - - -@pytest.mark.parametrize( - "chip,backend_mod,_sda,_scl", - _CHIPS, - indirect=["chip"], -) -def test_backend_caches(chip, backend_mod, _sda, _scl): - # Backend's own __getattr__ also caches via globals(). - backend = importlib.import_module(backend_mod) - assert backend.soft_i2c is backend.soft_i2c - assert backend.hard_i2c is backend.hard_i2c - - -@pytest.mark.parametrize("chip", [c[0] for c in _CHIPS], indirect=True) -def test_dispatcher_unknown_attribute_raises(chip, i2c_bus_module): - with pytest.raises(AttributeError): - _ = i2c_bus_module.spi_bus - - -@pytest.mark.parametrize( - "chip,backend_mod,_sda,_scl", - _CHIPS, - indirect=["chip"], -) -def test_backend_unknown_attribute_raises(chip, backend_mod, _sda, _scl): - backend = importlib.import_module(backend_mod) - with pytest.raises(AttributeError): - backend.__getattr__("spi_bus") - - -@pytest.fixture -def i2c_bus_module(chip): - """Re-import i2c_bus after `chip` patches os.uname so dispatch re-runs.""" - return importlib.import_module("i2c_bus") diff --git a/firmware-packages/mpu6050/mpu6050/__init__.py b/firmware-packages/mpu6050/mpu6050/__init__.py index 380bb2a..4bdfd7e 100644 --- a/firmware-packages/mpu6050/mpu6050/__init__.py +++ b/firmware-packages/mpu6050/mpu6050/__init__.py @@ -1,5 +1,11 @@ -"""MCU-micropython chip-agnostic register-level driver for the MPU6050 / MPU6500 / MPU9250 IMU.""" +"""MCU-micropython driver for the MPU6050 / MPU6500 / MPU9250 IMU family. +The driver takes flat pin numbers, opens its own hardware I²C bus, and +auto-detects the device address. ``DeviceNotFoundError`` is re-exported so a project +imports its retry-loop exception from here, never from ``i2c_bus``. +""" + +from i2c_bus import DeviceNotFoundError from mpu6050.mpu6050 import MPU6050 -__all__ = ["MPU6050"] +__all__ = ["MPU6050", "DeviceNotFoundError"] diff --git a/firmware-packages/mpu6050/mpu6050/mpu6050.py b/firmware-packages/mpu6050/mpu6050/mpu6050.py index f4630d6..d3e2774 100644 --- a/firmware-packages/mpu6050/mpu6050/mpu6050.py +++ b/firmware-packages/mpu6050/mpu6050/mpu6050.py @@ -5,14 +5,22 @@ transfer function. The driver auto-detects the chip via WHO_AM_I and applies the right temperature scale + offset accordingly. -Chip-agnostic with respect to the MCU: takes a `machine.I2C` (or -`SoftI2C`) instance from the caller and never imports `machine` itself. +The constructor takes flat pin numbers and opens its own 400 kHz hardware I²C +bus (the MPU6050 doesn't clock-stretch) via the internal ``i2c_bus`` helper, and +auto-detects the device address (0x68 with AD0 low, else 0x69) by scanning — the +project supplies pins, not a bus object or address. """ import struct from micropython import const +from i2c_bus import DeviceNotFoundError, hard_i2c + +# 7-bit I²C addresses the MPU6050 family answers on: 0x68 (AD0=GND/floating), +# 0x69 (AD0=3V3). Probed in this order at construction. +_ADDRESSES = (0x68, 0x69) + _WHO_AM_I = const(0x75) _PWR_MGMT_1 = const(0x6B) _SMPLRT_DIV = const(0x19) @@ -39,32 +47,41 @@ class MPU6050: """Configured ±2 g / ±250 °/s ranges with 44 Hz DLPF, 125 Hz internal sample rate. Attributes: - i2c: The bus passed by the caller. - addr: 7-bit I²C address (0x68 or 0x69 depending on AD0). + i2c: The hardware I²C bus this driver opened. + addr: 7-bit I²C address auto-detected at construction (0x68 or 0x69). kind: One of "MPU6050", "MPU6500", or "MPU9250" — set after the WHO_AM_I dispatch at construction. last_saturated: Set by `read_all()` to True if any raw axis hit the int16 rail on the most recent sample (range exceeded). """ - def __init__(self, i2c: object, addr: int = 0x68) -> None: - """Detect the chip via WHO_AM_I and write the standard init sequence. + def __init__(self, *, sda: int, scl: int, bus_id: int = 0) -> None: + """Open the bus, find the device, then detect the chip and init it. + + Scans for an MPU6050 at 0x68 (AD0=GND/floating), falling back to 0x69 + (AD0=3V3), so the project never has to pick the address. Args: - i2c: A `machine.I2C` or `SoftI2C` (anything with - `readfrom_mem` / `writeto_mem` / `readfrom_mem_into`). - addr: 7-bit I²C address — 0x68 (AD0=GND/floating) or 0x69 - (AD0=3V3). + sda: GPIO number for the I²C data line. + scl: GPIO number for the I²C clock line. + bus_id: Hardware I²C peripheral selector. Raises: + DeviceNotFoundError: Neither 0x68 nor 0x69 ACKed on the scanned bus. OSError: WHO_AM_I returns a value not in the known table (counterfeit chip or wrong device on the bus). """ + i2c = hard_i2c(bus_id, sda, scl) self.i2c = i2c - self.addr = addr self._buf = bytearray(14) self.last_saturated = False + devices = i2c.scan() + addr = next((a for a in _ADDRESSES if a in devices), None) + if addr is None: + raise DeviceNotFoundError("MPU6050 not found at 0x68/0x69") + self.addr = addr + who = self.i2c.readfrom_mem(addr, _WHO_AM_I, 1)[0] if who not in _KNOWN: raise OSError(f"Unknown IMU WHO_AM_I=0x{who:02x}") diff --git a/firmware-packages/mpu6050/pyproject.toml b/firmware-packages/mpu6050/pyproject.toml index faf195d..1170e1b 100644 --- a/firmware-packages/mpu6050/pyproject.toml +++ b/firmware-packages/mpu6050/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mpu6050" -version = "0.1.0" +version = "0.2.0" description = "MicroPython driver for the InvenSense MPU6050 / MPU6500 / MPU9250 IMU family." requires-python = ">=3.12" diff --git a/firmware-packages/mpu6050/tests/conftest.py b/firmware-packages/mpu6050/tests/conftest.py deleted file mode 100644 index b5685b5..0000000 --- a/firmware-packages/mpu6050/tests/conftest.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Host CPython pytest fixtures for mpu6050.""" - -import machine -import pytest -from fake_mpu6050 import FakeMPU6050 - - -@pytest.fixture(autouse=True) -def _reset_devices(): - """Clear the shared machine-stub device registry between tests.""" - machine.reset() - yield - - -@pytest.fixture -def fake_imu(): - """Default fake at 0x68 (MPU6050).""" - dev = FakeMPU6050(who_am_i=0x68) - machine.register_device(0x68, dev) - return dev diff --git a/firmware-packages/mpu6050/tests/test_mpu6050.py b/firmware-packages/mpu6050/tests/test_mpu6050.py index 1b062a1..0e5f05b 100644 --- a/firmware-packages/mpu6050/tests/test_mpu6050.py +++ b/firmware-packages/mpu6050/tests/test_mpu6050.py @@ -1,21 +1,47 @@ """Host CPython pytest tests for the MPU6050 driver against the register simulator. -The simulator is faithful enough to exercise WHO_AM_I dispatch, LSB -conversion arithmetic, chip-specific temperature transfer, and the -saturation flag. Silicon quirks (NACK retries, clock-stretch timeouts) -are out of scope — those need hardware. +The simulator is faithful enough to exercise address auto-detection, WHO_AM_I +dispatch, LSB conversion arithmetic, chip-specific temperature transfer, and the +saturation flag. The driver opens its own bus from flat pins, so the fake is +registered in the machine stub's device registry and the driver's internal +scan() finds it. Silicon quirks (NACK retries, clock-stretch timeouts) are out +of scope — those need hardware. """ import machine import pytest from fake_mpu6050 import FakeMPU6050 -from mpu6050 import MPU6050 +from mpu6050 import MPU6050, DeviceNotFoundError -def test_who_am_i_dispatch_mpu6050(fake_imu): - imu = MPU6050(_make_i2c(), addr=0x68) +def _register_fake(addr=0x68, **kwargs): + """Reset machine state and register a FakeMPU6050 at addr.""" + machine.reset() + dev = FakeMPU6050(**kwargs) + machine.register_device(addr, dev) + return dev + + +def test_who_am_i_dispatch_mpu6050(): + _register_fake(who_am_i=0x68) + imu = MPU6050(sda=0, scl=1) assert imu.kind == "MPU6050" + assert imu.addr == 0x68 + + +def test_missing_device_raises_device_not_found(): + """Nothing registered on the bus → DeviceNotFoundError, not OSError.""" + machine.reset() + with pytest.raises(DeviceNotFoundError): + MPU6050(sda=0, scl=1) + + +def test_secondary_address_auto_detected(): + """AD0=3V3 puts the device at 0x69; the driver finds it without help.""" + _register_fake(addr=0x69, who_am_i=0x68) + imu = MPU6050(sda=0, scl=1) + assert imu.addr == 0x69 @pytest.mark.parametrize( @@ -23,21 +49,21 @@ def test_who_am_i_dispatch_mpu6050(fake_imu): [(0x70, "MPU6500"), (0x71, "MPU9250")], ) def test_who_am_i_dispatch_variants(who, kind): - dev = FakeMPU6050(who_am_i=who) - machine.register_device(0x68, dev) - imu = MPU6050(_make_i2c(), addr=0x68) + _register_fake(who_am_i=who) + imu = MPU6050(sda=0, scl=1) assert imu.kind == kind def test_unknown_who_am_i_raises(): - machine.register_device(0x68, FakeMPU6050(who_am_i=0xAA)) + _register_fake(who_am_i=0xAA) with pytest.raises(OSError): - MPU6050(_make_i2c(), addr=0x68) + MPU6050(sda=0, scl=1) -def test_accel_gyro_lsb_conversion(fake_imu): +def test_accel_gyro_lsb_conversion(): """16384 LSB = 1 g; 131 LSB = 1 °/s — sanity check arithmetic.""" - imu = MPU6050(_make_i2c(), addr=0x68) + fake_imu = _register_fake(who_am_i=0x68) + imu = MPU6050(sda=0, scl=1) fake_imu.set_sample(ax=16384, ay=-16384, az=0, gx=131, gy=-131, gz=0, temp_raw=0) ax, ay, az, gx, gy, gz, _ = imu.read_all() assert ax == pytest.approx(1.0) @@ -48,9 +74,10 @@ def test_accel_gyro_lsb_conversion(fake_imu): assert gz == pytest.approx(0.0) -def test_temperature_transfer_mpu6050(fake_imu): +def test_temperature_transfer_mpu6050(): """MPU6050: T = raw / 340 + 36.53.""" - imu = MPU6050(_make_i2c(), addr=0x68) + fake_imu = _register_fake(who_am_i=0x68) + imu = MPU6050(sda=0, scl=1) fake_imu.set_sample(0, 0, 0, 0, 0, 0, temp_raw=0) _, _, _, _, _, _, t = imu.read_all() assert t == pytest.approx(36.53, abs=1e-2) @@ -58,16 +85,16 @@ def test_temperature_transfer_mpu6050(fake_imu): def test_temperature_transfer_mpu6500(): """MPU6500/MPU9250: T = raw / 333.87 + 21.0.""" - dev = FakeMPU6050(who_am_i=0x70) - machine.register_device(0x68, dev) - imu = MPU6050(_make_i2c(), addr=0x68) + dev = _register_fake(who_am_i=0x70) + imu = MPU6050(sda=0, scl=1) dev.set_sample(0, 0, 0, 0, 0, 0, temp_raw=0) _, _, _, _, _, _, t = imu.read_all() assert t == pytest.approx(21.0, abs=1e-2) -def test_saturation_flag_clear(fake_imu): - imu = MPU6050(_make_i2c(), addr=0x68) +def test_saturation_flag_clear(): + fake_imu = _register_fake(who_am_i=0x68) + imu = MPU6050(sda=0, scl=1) fake_imu.set_sample(0, 0, 0, 0, 0, 0, 0) imu.read_all() assert imu.last_saturated is False @@ -84,14 +111,11 @@ def test_saturation_flag_clear(fake_imu): {"gz": -32768}, ], ) -def test_saturation_flag_set_on_int16_rail(fake_imu, axis_kwargs): - imu = MPU6050(_make_i2c(), addr=0x68) +def test_saturation_flag_set_on_int16_rail(axis_kwargs): + fake_imu = _register_fake(who_am_i=0x68) + imu = MPU6050(sda=0, scl=1) sample = {"ax": 0, "ay": 0, "az": 0, "gx": 0, "gy": 0, "gz": 0, "temp_raw": 0} sample.update(axis_kwargs) fake_imu.set_sample(**sample) imu.read_all() assert imu.last_saturated is True - - -def _make_i2c(): - return machine.SoftI2C(sda=machine.Pin(0), scl=machine.Pin(1)) diff --git a/firmware-packages/qmc5883p/pyproject.toml b/firmware-packages/qmc5883p/pyproject.toml index ed54043..8896246 100644 --- a/firmware-packages/qmc5883p/pyproject.toml +++ b/firmware-packages/qmc5883p/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "qmc5883p" -version = "0.1.0" +version = "0.2.0" description = "MicroPython driver for the QST QMC5883P 3-axis magnetometer." requires-python = ">=3.12" diff --git a/firmware-packages/qmc5883p/qmc5883p/__init__.py b/firmware-packages/qmc5883p/qmc5883p/__init__.py index c94d38a..3264a24 100644 --- a/firmware-packages/qmc5883p/qmc5883p/__init__.py +++ b/firmware-packages/qmc5883p/qmc5883p/__init__.py @@ -1,5 +1,11 @@ -"""MCU-micropython chip-agnostic register-level driver for the QMC5883P magnetometer.""" +"""MCU-micropython QMC5883P magnetometer driver. +The driver takes flat pin numbers and opens its own hardware I²C bus, so the +project's BOARD table supplies only pins. ``DeviceNotFoundError`` is re-exported so a +project imports its retry-loop exception from here, never from ``i2c_bus``. +""" + +from i2c_bus import DeviceNotFoundError from qmc5883p.qmc5883p import QMC5883P -__all__ = ["QMC5883P"] +__all__ = ["QMC5883P", "DeviceNotFoundError"] diff --git a/firmware-packages/qmc5883p/qmc5883p/qmc5883p.py b/firmware-packages/qmc5883p/qmc5883p/qmc5883p.py index c72995e..06d2bb4 100644 --- a/firmware-packages/qmc5883p/qmc5883p/qmc5883p.py +++ b/firmware-packages/qmc5883p/qmc5883p/qmc5883p.py @@ -7,8 +7,9 @@ and starts continuous output at 50 Hz with OSR=512 (max in-sensor averaging). read() blocks on the data-ready bit and returns signed (x, y, z) ints. -Chip-agnostic w.r.t. the MCU: takes a machine.I2C / SoftI2C from the caller and -never imports `machine`. +The constructor takes flat pin numbers and opens its own 400 kHz hardware I²C +bus (the QMC5883P never clock-stretches) via the internal ``i2c_bus`` helper — +the project supplies pins, not a bus object. """ import struct @@ -16,6 +17,8 @@ import utime from micropython import const +from i2c_bus import DeviceNotFoundError, hard_i2c + _REG_CHIP_ID = const(0x00) _REG_DATA = const(0x01) # 6 bytes: X/Y/Z LSB,MSB signed little-endian _REG_STATUS = const(0x09) # bit0=DRDY, bit1=OVL @@ -41,29 +44,35 @@ class QMC5883P: """QMC5883P magnetometer in ±2 G continuous mode at 50 Hz, OSR=512. Attributes: - i2c: The bus passed by the caller. + i2c: The hardware I²C bus this driver opened. address: Fixed 7-bit I²C address (0x2C). last_status: STATUS byte from the most recent read(); bit1 (OVL) flags field saturation. main.py edge-triggers a diag on it. """ - def __init__(self, i2c: object, address: int = 0x2C) -> None: - """Verify the chip-ID and write the standard init sequence. + def __init__(self, *, sda: int, scl: int, bus_id: int = 0, address: int = 0x2C) -> None: + """Open the bus, confirm the device is present, then init the chip. Args: - i2c: machine.I2C / SoftI2C (readfrom_mem / writeto_mem / - readfrom_mem_into). + sda: GPIO number for the I²C data line. + scl: GPIO number for the I²C clock line. + bus_id: Hardware I²C peripheral selector. address: 7-bit I²C address; fixed at 0x2C on this part. Raises: + DeviceNotFoundError: Nothing ACKed at ``address`` on the scanned bus. OSError: CHIP_ID register doesn't read _CHIP_ID (wrong device on the bus or a counterfeit part). """ + i2c = hard_i2c(bus_id, sda, scl) self.i2c = i2c self.address = address self._buf = bytearray(6) self._status = bytearray(1) + if address not in i2c.scan(): + raise DeviceNotFoundError(f"QMC5883P not found at 0x{address:02x}") + i2c.writeto_mem(address, _REG_CTRL_2, bytes((_CTRL_2_SOFT_RESET,))) utime.sleep_ms(_RESET_PAUSE_MS) diff --git a/firmware-packages/qmc5883p/tests/conftest.py b/firmware-packages/qmc5883p/tests/conftest.py deleted file mode 100644 index 8866ddb..0000000 --- a/firmware-packages/qmc5883p/tests/conftest.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Host CPython pytest fixtures for qmc5883p.""" - -import machine -import pytest -from fake_qmc5883p import FakeQMC5883P - - -@pytest.fixture(autouse=True) -def _reset_devices(): - """Clear the shared machine-stub device registry between tests.""" - machine.reset() - yield - - -@pytest.fixture -def fake_mag(): - """Default fake QMC5883P at 0x2C (valid chip-ID).""" - dev = FakeQMC5883P() - machine.register_device(0x2C, dev) - return dev diff --git a/firmware-packages/qmc5883p/tests/test_qmc5883p.py b/firmware-packages/qmc5883p/tests/test_qmc5883p.py index 926c69f..4f8dd1f 100644 --- a/firmware-packages/qmc5883p/tests/test_qmc5883p.py +++ b/firmware-packages/qmc5883p/tests/test_qmc5883p.py @@ -1,34 +1,53 @@ """Host CPython pytest tests for the QMC5883P driver against the register simulator. -Logic-only coverage: chip-ID validation, the init register sequence, signed -little-endian decode, the data-ready poll loop, and the OVL status bit. Silicon -quirks (NACK retries, clock-stretch timeouts) are out of scope — those need -hardware. +Logic-only coverage: device scan, chip-ID validation, the init register +sequence, signed little-endian decode, the data-ready poll loop, and the OVL +status bit. The driver opens its own bus from flat pins, so the fake is +registered in the machine stub's device registry and the driver's internal +scan() finds it. Silicon quirks (NACK retries, clock-stretch timeouts) are out +of scope — those need hardware. """ import machine import pytest from fake_qmc5883p import FakeQMC5883P -from qmc5883p import QMC5883P +from qmc5883p import QMC5883P, DeviceNotFoundError ADDR = 0x2C -def test_chip_id_accepted(fake_mag): - mag = QMC5883P(_make_i2c()) +def _register_fake(**kwargs): + """Reset machine state and register a FakeQMC5883P at 0x2C.""" + machine.reset() + dev = FakeQMC5883P(**kwargs) + machine.register_device(ADDR, dev) + return dev + + +def test_chip_id_accepted(): + _register_fake() + mag = QMC5883P(sda=0, scl=1) assert mag.address == ADDR +def test_missing_device_raises_device_not_found(): + """Nothing registered on the bus → DeviceNotFoundError, not OSError.""" + machine.reset() + with pytest.raises(DeviceNotFoundError): + QMC5883P(sda=0, scl=1) + + def test_unknown_chip_id_raises(): - machine.register_device(ADDR, FakeQMC5883P(chip_id=0xAA)) + _register_fake(chip_id=0xAA) with pytest.raises(OSError): - QMC5883P(_make_i2c()) + QMC5883P(sda=0, scl=1) -def test_init_writes_config_sequence(fake_mag): +def test_init_writes_config_sequence(): """soft-reset (CTRL_2) → AXIS_SIGN → range (CTRL_2) → CTRL_1, in order.""" - QMC5883P(_make_i2c()) + fake_mag = _register_fake() + QMC5883P(sda=0, scl=1) assert fake_mag.writes == [ (0x0B, bytes((0x80,))), # CTRL_2 soft reset (0x29, bytes((0x06,))), # AXIS_SIGN invert X/Y @@ -37,33 +56,33 @@ def test_init_writes_config_sequence(fake_mag): ] -def test_read_returns_signed_xyz(fake_mag): +def test_read_returns_signed_xyz(): """6-byte DATA block decodes as three signed little-endian shorts.""" - mag = QMC5883P(_make_i2c()) + fake_mag = _register_fake() + mag = QMC5883P(sda=0, scl=1) fake_mag.set_sample(100, -200, 300) assert mag.read() == (100, -200, 300) -def test_read_polls_until_drdy(fake_mag): +def test_read_polls_until_drdy(): """read() spins on STATUS DRDY before fetching DATA.""" - mag = QMC5883P(_make_i2c()) + fake_mag = _register_fake() + mag = QMC5883P(sda=0, scl=1) fake_mag.set_sample(1, 2, 3) fake_mag.drdy_after(2) # not-ready twice, then ready assert mag.read() == (1, 2, 3) -def test_last_status_exposes_ovl(fake_mag): - mag = QMC5883P(_make_i2c()) +def test_last_status_exposes_ovl(): + fake_mag = _register_fake() + mag = QMC5883P(sda=0, scl=1) fake_mag.set_overflow(True) mag.read() assert mag.last_status & 0x02 -def test_last_status_clear_without_ovl(fake_mag): - mag = QMC5883P(_make_i2c()) +def test_last_status_clear_without_ovl(): + _register_fake() + mag = QMC5883P(sda=0, scl=1) mag.read() assert not (mag.last_status & 0x02) - - -def _make_i2c(): - return machine.SoftI2C(sda=machine.Pin(0), scl=machine.Pin(1)) diff --git a/firmware-packages/vl53l0x/VENDOR.md b/firmware-packages/vl53l0x/VENDOR.md index 931b49f..f069ecb 100644 --- a/firmware-packages/vl53l0x/VENDOR.md +++ b/firmware-packages/vl53l0x/VENDOR.md @@ -27,6 +27,10 @@ upstream `975d212a`: the wrapper surface described in [README.md](README.md) — `skip_spad_info` and `interrupt_status_mask` constructor options for the ESP32-S3 breakout. +- Project-specific concerns — soft I²C bus creation, device scan, + `DeviceNotFoundError`, soft-reset, and default overrides — live in + ``__init__.py`` as a thin subclass. The vendored file takes a pre-built + ``i2c`` object and has no dependency on ``i2c_bus``. Treat the file as a vendored-with-local-modifications snapshot: don't re-sync from upstream without re-applying these changes, and don't edit diff --git a/firmware-packages/vl53l0x/pyproject.toml b/firmware-packages/vl53l0x/pyproject.toml index 37b3626..868fd7c 100644 --- a/firmware-packages/vl53l0x/pyproject.toml +++ b/firmware-packages/vl53l0x/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "vl53l0x" -version = "0.1.0" +version = "0.2.0" description = "MicroPython wrapper around the vendored VL53L0X time-of-flight driver." requires-python = ">=3.12" diff --git a/firmware-packages/vl53l0x/tests/conftest.py b/firmware-packages/vl53l0x/tests/conftest.py deleted file mode 100644 index 424c8ec..0000000 --- a/firmware-packages/vl53l0x/tests/conftest.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Host CPython pytest fixtures for vl53l0x.""" - -import machine -import pytest -from fake_vl53l0x import FakeVL53L0X - - -@pytest.fixture(autouse=True) -def _reset_devices(): - """Clear the shared machine-stub device registry between tests.""" - machine.reset() - yield - - -@pytest.fixture -def fake_tof(): - """Default fake ToF sensor at 0x29.""" - dev = FakeVL53L0X() - machine.register_device(0x29, dev) - return dev diff --git a/firmware-packages/vl53l0x/tests/fake_vl53l0x.py b/firmware-packages/vl53l0x/tests/fake_vl53l0x.py index fb2b51a..aaee8fa 100644 --- a/firmware-packages/vl53l0x/tests/fake_vl53l0x.py +++ b/firmware-packages/vl53l0x/tests/fake_vl53l0x.py @@ -25,13 +25,21 @@ class FakeVL53L0X: MODEL_ID = 0xEE - def __init__(self, *, interrupt_status_after_write: int = 0x07) -> None: + def __init__( + self, + *, + interrupt_status_after_write: int = 0x07, + soft_reset_behavior: str = "normal", + ) -> None: """Initialise the register file with default boot-time values. Args: interrupt_status_after_write: Bits to set in 0x13 after every write to 0x00 (SYSRANGE_START). Tests covering the ESP32 wide-mask path pass 0x40 here. + soft_reset_behavior: One of "normal" (MODEL_ID remains 0xEE), + "timeout" (MODEL_ID never becomes 0xEE), or "error" + (raise OSError on reads during reset poll). """ self.regs = bytearray(256) self.regs[0xC0] = self.MODEL_ID # identification model id @@ -43,6 +51,9 @@ def __init__(self, *, interrupt_status_after_write: int = 0x07) -> None: # Distance is read from 0x1E/0x1F (big-endian). self.set_distance(0) self._irq_set_bits = interrupt_status_after_write + self._soft_reset_behavior = soft_reset_behavior + self._in_soft_reset = False + self._reset_write_count = 0 def set_distance(self, mm: int) -> None: """Set the simulated big-endian 16-bit distance reading at 0x1E/0x1F.""" @@ -50,19 +61,46 @@ def set_distance(self, mm: int) -> None: self.regs[0x1F] = mm & 0xFF def read(self, reg: int, nbytes: int) -> bytes: - """Return `nbytes` from the register file starting at `reg`.""" + """Return `nbytes` from the register file starting at `reg`. + + Raises OSError if soft_reset_behavior is "error" and we're polling + the model ID during soft-reset. + """ + # Simulate OSError during soft-reset polling for "error" behavior + if self._soft_reset_behavior == "error" and self._in_soft_reset and reg == 0xC0: + raise OSError("Simulated I2C error during soft-reset poll") + return bytes(self.regs[reg : reg + nbytes]) + def _handle_soft_reset_write(self, data: bytes) -> None: + """Track soft-reset sequence for model ID availability.""" + if data == b"\x00": + self._in_soft_reset = True + self._reset_write_count = 0 + elif data == b"\x01" and self._in_soft_reset: + self._reset_write_count += 1 + # For "normal", model ID is already 0xEE; for "timeout", prevent it + if self._soft_reset_behavior == "timeout": + self.regs[0xC0] = 0x00 # Not booted + def write(self, reg: int, data: bytes) -> None: """Write `data` to the register file, applying behavioural side-effects.""" - # SOFT_RESET sequence: write 0 then 1 keeps model-id reachable. - # (No state actually changes — the model_id stays 0xEE throughout.) + # Handle soft-reset sequence tracking (0xBF) + if reg == 0xBF: + self._handle_soft_reset_write(data) + + # Handle measurement start (0x00) if reg == 0x00 and data and (data[0] & 0x01): # Starting a measurement / calibration step. Arm interrupt bits. self.regs[0x13] |= self._irq_set_bits + + # Handle SPAD-info status poll trigger (0x83) if reg == 0x83 and data == b"\x00": # The driver writes 0 then polls until non-zero, mimicking the # SPAD-info status handshake. self.regs[0x83] = 0x01 + return + + # Write data to register file for i, b in enumerate(data): self.regs[reg + i] = b diff --git a/firmware-packages/vl53l0x/tests/test_vl53l0x.py b/firmware-packages/vl53l0x/tests/test_vl53l0x.py index b6386ac..7be6e97 100644 --- a/firmware-packages/vl53l0x/tests/test_vl53l0x.py +++ b/firmware-packages/vl53l0x/tests/test_vl53l0x.py @@ -1,29 +1,46 @@ """Host CPython pytest tests for the VL53L0X driver against the register simulator. -The simulator is faithful enough to let the driver complete init, start -continuous ranging, and return a configured distance. That's all you can -expect from a pure-register fake — it does *not* exercise NACK retries, -clock-stretch timeouts, or the ESP32-S3 bit-6 quirk. Those need hardware. +The simulator is faithful enough to let the driver scan, soft-reset, complete +init, start continuous ranging, and return a configured distance. The driver +opens its own bus from flat pins, so the fake is registered in the machine +stub's device registry and the driver's internal scan() finds it. It does *not* +exercise NACK retries, clock-stretch timeouts, or the ESP32-S3 bit-6 quirk — +those need hardware. """ import machine +import pytest from fake_vl53l0x import FakeVL53L0X -from machine import Pin, SoftI2C -from vl53l0x import VL53L0X +from vl53l0x import VL53L0X, DeviceNotFoundError -def test_driver_inits_with_skip_spad_info(fake_tof): - i2c = SoftI2C(sda=Pin(0), scl=Pin(1)) - tof = VL53L0X(i2c, skip_spad_info=True, interrupt_status_mask=0x07) +def _register_fake(**kwargs): + """Reset machine state and register a FakeVL53L0X at 0x29.""" + machine.reset() + dev = FakeVL53L0X(**kwargs) + machine.register_device(0x29, dev) + return dev + + +def test_driver_inits_with_skip_spad_info(): + _register_fake() + tof = VL53L0X(sda=0, scl=1, skip_spad_info=True, interrupt_status_mask=0x07) assert tof.address == 0x29 assert tof.skip_spad_info is True -def test_read_returns_simulated_distance(fake_tof): - i2c = SoftI2C(sda=Pin(0), scl=Pin(1)) - tof = VL53L0X(i2c, skip_spad_info=True, interrupt_status_mask=0x07) +def test_missing_device_raises_device_not_found(): + """Nothing registered on the bus → DeviceNotFoundError, not OSError.""" + machine.reset() + with pytest.raises(DeviceNotFoundError): + VL53L0X(sda=0, scl=1) + + +def test_read_returns_simulated_distance(): + fake_tof = _register_fake() + tof = VL53L0X(sda=0, scl=1, skip_spad_info=True, interrupt_status_mask=0x07) tof.start() fake_tof.set_distance(1234) @@ -40,11 +57,42 @@ def test_esp32_wider_mask_is_honored(): set. If the driver ignored its mask parameter, read() would TimeoutError. """ - dev = FakeVL53L0X(interrupt_status_after_write=0x40) # bit 6 only - machine.register_device(0x29, dev) + dev = _register_fake(interrupt_status_after_write=0x40) - i2c = SoftI2C(sda=Pin(0), scl=Pin(1)) - tof = VL53L0X(i2c, skip_spad_info=True, interrupt_status_mask=0xFF) + tof = VL53L0X(sda=0, scl=1, skip_spad_info=True, interrupt_status_mask=0xFF) tof.start() dev.set_distance(777) assert tof.read() == 777 + + +def test_soft_reset_success(): + """Soft-reset completes when model ID is readable as 0xEE.""" + _register_fake(soft_reset_behavior="normal") + + tof = VL53L0X(sda=0, scl=1, skip_spad_info=True, interrupt_status_mask=0x07) + assert tof.address == 0x29 + + +def test_soft_reset_timeout_is_swallowed(): + """Soft-reset timeout (model ID never becomes 0xEE) is swallowed. + + Init continues anyway, matching the driver docstring: "A no-show is + swallowed — init() runs regardless, matching the chip's tolerance + for a skipped reset." + """ + _register_fake(soft_reset_behavior="timeout") + + tof = VL53L0X(sda=0, scl=1, skip_spad_info=True, interrupt_status_mask=0x07) + assert tof.address == 0x29 + + +def test_soft_reset_oserror_is_swallowed(): + """Soft-reset OSError during poll is swallowed. + + If an I²C error occurs while polling the model ID, the error is caught + and init continues anyway. + """ + _register_fake(soft_reset_behavior="error") + + tof = VL53L0X(sda=0, scl=1, skip_spad_info=True, interrupt_status_mask=0x07) + assert tof.address == 0x29 diff --git a/firmware-packages/vl53l0x/vl53l0x/__init__.py b/firmware-packages/vl53l0x/vl53l0x/__init__.py index 17328e3..93b347b 100644 --- a/firmware-packages/vl53l0x/vl53l0x/__init__.py +++ b/firmware-packages/vl53l0x/vl53l0x/__init__.py @@ -1,5 +1,84 @@ -"""MCU-micropython driver for the VL53L0X time-of-flight sensor (community MicroPython port).""" +"""MCU-micropython driver for the VL53L0X time-of-flight sensor. -from vl53l0x.vl53l0x import VL53L0X +Thin wrapper around the vendored ``vl53l0x.vl53l0x`` register driver. The +vendored file accepts a pre-built ``i2c`` object; this module adds +project-specific concerns — bus creation, device scan, soft-reset, and +default overrides — so that firmware only passes flat pin numbers. -__all__ = ["VL53L0X"] +``DeviceNotFoundError`` is re-exported so a project imports its retry-loop +exception from here, never from ``i2c_bus``. +""" + +import utime + +from i2c_bus import DeviceNotFoundError, soft_i2c +from vl53l0x.vl53l0x import IDENTIFICATION_MODEL_ID, SOFT_RESET_GO2_SOFT_RESET_N +from vl53l0x.vl53l0x import VL53L0X as _VendorVL53L0X # noqa: N811 + +_SOFT_RESET_PAUSE_MS = 10 +_SOFT_RESET_POLLS = 10 +_MODEL_ID_BOOTED = 0xEE + +__all__ = ["VL53L0X", "DeviceNotFoundError"] + + +class VL53L0X(_VendorVL53L0X): + """VL53L0X with bus management, device scan, and soft-reset. + + The vendored base class takes a ready-made ``i2c`` object with upstream + defaults (``skip_spad_info=False``, ``interrupt_status_mask=0x07``). + This subclass creates a soft I²C bus from flat pin numbers, confirms + the chip is present, soft-resets it, then delegates to the vendor + ``__init__`` with project defaults that work across RP2040/RP2350 + and ESP32-S3. + """ + + def __init__( + self, + *, + sda: int, + scl: int, + address: int = 0x29, + skip_spad_info: bool = True, + interrupt_status_mask: int = 0xFF, + ) -> None: + """Create a VL53L0X driver from flat pin numbers. + + Args: + sda: GPIO number for the I²C data line. + scl: GPIO number for the I²C clock line. + address: 7-bit I²C address; default 0x29. + skip_spad_info: Bypass the SPAD-count read; default True. + interrupt_status_mask: Mask for the interrupt-status poll; default + 0xFF to cover both RP2040 (bits 0-2) and ESP32-S3 (bit 6). + + Raises: + DeviceNotFoundError: Nothing ACKed at ``address`` on the scanned bus. + """ + # Soft I²C: the VL53L0X clock-stretches heavily during the firmware + # upload, which the hardware peripheral aborts on. + i2c = soft_i2c(sda, scl) + if address not in i2c.scan(): + raise DeviceNotFoundError(f"VL53L0X not found at 0x{address:02x}") + _soft_reset(i2c, address) + super().__init__(i2c, address, skip_spad_info, interrupt_status_mask) + + +def _soft_reset(i2c: object, address: int) -> None: + """Reboot the chip and poll until it signals ready (best-effort). + + Clears any half-init state from a previous attempt before init() touches + calibration registers. A NACK or a no-show is swallowed — init() runs + regardless, matching the chip's tolerance for a skipped reset. + """ + try: + i2c.writeto_mem(address, SOFT_RESET_GO2_SOFT_RESET_N, b"\x00") + utime.sleep_ms(_SOFT_RESET_PAUSE_MS) + i2c.writeto_mem(address, SOFT_RESET_GO2_SOFT_RESET_N, b"\x01") + utime.sleep_ms(_SOFT_RESET_PAUSE_MS) + for _ in range(_SOFT_RESET_POLLS): + if i2c.readfrom_mem(address, IDENTIFICATION_MODEL_ID, 1)[0] == _MODEL_ID_BOOTED: + return + utime.sleep_ms(_SOFT_RESET_PAUSE_MS) + except OSError: + return diff --git a/firmware-packages/vl53l5cx/VENDOR.md b/firmware-packages/vl53l5cx/VENDOR.md index 8b8eb49..ca13498 100644 --- a/firmware-packages/vl53l5cx/VENDOR.md +++ b/firmware-packages/vl53l5cx/VENDOR.md @@ -36,6 +36,10 @@ The local copies are **not byte-for-byte verbatim**. Observed differences: rather than `b'\x..'`); the payload bytes themselves are unchanged. - `cp.py` and `_config_file.py` are not included (CircuitPython adapter and file-based firmware loader are not needed in this repo). +- Project-specific concerns — soft I²C bus creation, device scan, + ``DeviceNotFoundError``, LPN pin setup — live in ``__init__.py`` as a + thin subclass. The vendored file takes a pre-built ``i2c`` object and + has no dependency on ``i2c_bus``. ### Local correctness fixes and cleanup diff --git a/firmware-packages/vl53l5cx/pyproject.toml b/firmware-packages/vl53l5cx/pyproject.toml index d756a98..aef8038 100644 --- a/firmware-packages/vl53l5cx/pyproject.toml +++ b/firmware-packages/vl53l5cx/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "vl53l5cx" -version = "0.1.1" +version = "0.2.0" description = "MicroPython driver for the VL53L5CX 8×8 multizone ToF sensor (vendored from mp-extras/vl53l5cx)." requires-python = ">=3.12" diff --git a/firmware-packages/vl53l5cx/tests/conftest.py b/firmware-packages/vl53l5cx/tests/conftest.py deleted file mode 100644 index 2f1a9e3..0000000 --- a/firmware-packages/vl53l5cx/tests/conftest.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Host CPython pytest fixtures for the vl53l5cx package tests. - -Provides a pre-wired VL53L5CX instance with a no-op I2C stub so that -driver internals (init(), start_ranging(), etc.) can be bypassed and the -public read() / check_data_ready() wrapper behaviour tested in isolation. -""" - -import pytest - -from vl53l5cx.vl53l5cx import VL53L5CX - - -class _FakeI2C: - """No-op I2C stub: readfrom_mem returns zeros, writes are discarded.""" - - def readfrom_mem_into(self, _addr, _reg, buf: bytearray, **_kwargs) -> None: - """Fill buf with zeros.""" - for i in range(len(buf)): - buf[i] = 0 - - def readfrom_mem(self, _addr, _reg, size: int, **_kwargs) -> bytes: - """Return size zero bytes.""" - return bytes(size) - - def writeto_mem(self, _addr, _reg, _buf, **_kwargs) -> None: - """Discard the write.""" - - -@pytest.fixture -def tof() -> VL53L5CX: - """VL53L5CX bound to a no-op I2C stub with _data_read_size pre-set. - - Does NOT call init() or start_ranging() — tests that exercise read() - or check_data_ready() can call these methods without hardware present. - """ - sensor = VL53L5CX(_FakeI2C()) - sensor._data_read_size = 32 - return sensor diff --git a/firmware-packages/vl53l5cx/tests/test_vl53l5cx.py b/firmware-packages/vl53l5cx/tests/test_vl53l5cx.py index e9de2e8..3d621a3 100644 --- a/firmware-packages/vl53l5cx/tests/test_vl53l5cx.py +++ b/firmware-packages/vl53l5cx/tests/test_vl53l5cx.py @@ -5,32 +5,66 @@ that require real I²C hardware. """ +import machine +import pytest from fake_vl53l5cx import make_results +from vl53l5cx import VL53L5CX, DeviceNotFoundError from vl53l5cx.vl53l5cx import RESOLUTION_8X8 -def test_read_returns_64_values(tof, monkeypatch): +class _FakeVL53L5CX: + """No-op register responder: present on the bus, returns zeros.""" + + def read(self, _reg: int, nbytes: int) -> bytes: + """Return nbytes of zeros.""" + return bytes(nbytes) + + def write(self, _reg: int, _data: bytes) -> None: + """Discard the write.""" + + +def _make_tof() -> VL53L5CX: + """Reset machine, register a fake at 0x29, return a ready VL53L5CX.""" + machine.reset() + machine.register_device(0x29, _FakeVL53L5CX()) + sensor = VL53L5CX(sda=0, scl=1) + sensor._data_read_size = 32 + return sensor + + +def test_missing_device_raises_device_not_found(): + """Nothing registered on the bus → DeviceNotFoundError, not OSError.""" + machine.reset() + with pytest.raises(DeviceNotFoundError): + VL53L5CX(sda=0, scl=1) + + +def test_read_returns_64_values(monkeypatch): + tof = _make_tof() results = make_results([100] * 64, [5] * 64) monkeypatch.setattr(tof, "get_ranging_data", lambda: results) assert len(tof.read()) == 64 -def test_read_valid_status_5_gives_int(tof, monkeypatch): +def test_read_valid_status_5_gives_int(monkeypatch): + tof = _make_tof() results = make_results([500] * 64, [5] * 64) monkeypatch.setattr(tof, "get_ranging_data", lambda: results) grid = tof.read() assert all(v == 500 for v in grid) -def test_read_valid_status_9_gives_int(tof, monkeypatch): +def test_read_valid_status_9_gives_int(monkeypatch): + tof = _make_tof() results = make_results([300] * 64, [9] * 64) monkeypatch.setattr(tof, "get_ranging_data", lambda: results) grid = tof.read() assert all(v == 300 for v in grid) -def test_read_invalid_status_gives_none(tof, monkeypatch): +def test_read_invalid_status_gives_none(monkeypatch): + tof = _make_tof() statuses = [0, 1, 2, 3, 4, 6, 7, 8, 10, 11, 12, 13, 255] for bad_status in statuses: results = make_results([999] * 64, [bad_status] * 64) @@ -39,7 +73,8 @@ def test_read_invalid_status_gives_none(tof, monkeypatch): assert all(v is None for v in grid), f"status {bad_status} should produce None" -def test_read_mixed_status_maps_individually(tof, monkeypatch): +def test_read_mixed_status_maps_individually(monkeypatch): + tof = _make_tof() distances = list(range(64)) statuses = [5 if i % 2 == 0 else 0 for i in range(64)] results = make_results(distances, statuses) @@ -52,7 +87,8 @@ def test_read_mixed_status_maps_individually(tof, monkeypatch): assert v is None -def test_check_data_ready_true_on_new_streamcount(tof): +def test_check_data_ready_true_on_new_streamcount(): + tof = _make_tof() tof._streamcount = 5 buf = bytes([6, 0x5, 0x5, 0x10]) tof.i2c.readfrom_mem = lambda _addr, _reg, _size, **_kwargs: buf @@ -60,21 +96,24 @@ def test_check_data_ready_true_on_new_streamcount(tof): assert tof._streamcount == 6 -def test_check_data_ready_false_same_streamcount(tof): +def test_check_data_ready_false_same_streamcount(): + tof = _make_tof() tof._streamcount = 6 buf = bytes([6, 0x5, 0x5, 0x10]) tof.i2c.readfrom_mem = lambda _addr, _reg, _size, **_kwargs: buf assert tof.check_data_ready() is False -def test_check_data_ready_false_count_255(tof): +def test_check_data_ready_false_count_255(): + tof = _make_tof() tof._streamcount = 5 buf = bytes([255, 0x5, 0x5, 0x10]) tof.i2c.readfrom_mem = lambda _addr, _reg, _size, **_kwargs: buf assert tof.check_data_ready() is False -def test_start_sets_8x8_resolution(tof, monkeypatch): +def test_start_sets_8x8_resolution(monkeypatch): + tof = _make_tof() resolutions_set = [] start_ranging_calls = [] @@ -102,7 +141,8 @@ def test_start_sets_8x8_resolution(tof, monkeypatch): assert len(start_ranging_calls) == 1 -def test_stop_calls_stop_ranging(tof, monkeypatch): +def test_stop_calls_stop_ranging(monkeypatch): + tof = _make_tof() called = [] monkeypatch.setattr(tof, "stop_ranging", lambda: called.append(True)) tof.stop() diff --git a/firmware-packages/vl53l5cx/vl53l5cx/__init__.py b/firmware-packages/vl53l5cx/vl53l5cx/__init__.py index 72c5f1d..736ee46 100644 --- a/firmware-packages/vl53l5cx/vl53l5cx/__init__.py +++ b/firmware-packages/vl53l5cx/vl53l5cx/__init__.py @@ -1,5 +1,53 @@ -"""MCU-micropython driver for the VL53L5CX 8x8 multizone time-of-flight sensor.""" +"""MCU-micropython driver for the VL53L5CX 8x8 multizone time-of-flight sensor. -from vl53l5cx.vl53l5cx import VL53L5CX +Thin wrapper around the vendored ``vl53l5cx.vl53l5cx`` register driver. The +vendored file accepts a pre-built ``i2c`` object; this module adds +project-specific concerns — bus creation, device scan, LPN pin setup — so +that firmware only passes flat pin numbers. -__all__ = ["VL53L5CX"] +``DeviceNotFoundError`` is re-exported so a project imports its retry-loop +exception from here, never from ``i2c_bus``. +""" + +from i2c_bus import DeviceNotFoundError, soft_i2c +from vl53l5cx.vl53l5cx import VL53L5CX as _VendorVL53L5CX # noqa: N811 + +__all__ = ["VL53L5CX", "DeviceNotFoundError"] + + +class VL53L5CX(_VendorVL53L5CX): + """VL53L5CX with bus management and device scan. + + The vendored base class takes a ready-made ``i2c`` object and an optional + ``machine.Pin`` for LPN. This subclass creates a soft I²C bus from flat + pin numbers, confirms the chip is present, wraps LPN as a Pin, then + delegates to the vendor ``__init__``. + """ + + def __init__(self, *, sda: int, scl: int, address: int = 0x29, lpn: int | None = None) -> None: + """Create a VL53L5CX driver from flat pin numbers. + + Args: + sda: GPIO number for the I²C data line. + scl: GPIO number for the I²C clock line. + address: 7-bit I²C address; default 0x29. + lpn: Optional GPIO number for the LPN (enable) line. Pass a pin + pulled high to enable hardware reset via reset(). None means + the pin is not controlled (safe if the board pulls LPN high). + + Raises: + DeviceNotFoundError: Nothing ACKed at ``address`` on the scanned bus. + """ + i2c = soft_i2c(sda, scl) + if address not in i2c.scan(): + raise DeviceNotFoundError(f"VL53L5CX not found at 0x{address:02x}") + super().__init__(i2c, address, _init_lpn(lpn)) + + +def _init_lpn(lpn: int | None) -> object: + """Wrap an optional LPN GPIO number in an output Pin, or return None.""" + if lpn is None: + return None + from machine import Pin # noqa: PLC0415 + + return Pin(lpn, Pin.OUT) diff --git a/manifest.py b/manifest.py index fa25ad7..7f9d097 100644 --- a/manifest.py +++ b/manifest.py @@ -53,8 +53,9 @@ def _imported_names(root: Path) -> set: # manifest is shared across every project, so freezing all of them would # sweep large unused blobs (e.g. vl53l5cx's ~400 KB config) into firmware # that never touches them. Resolve the transitive closure so a package -# that imports another package still works; today none do, but the -# fixpoint keeps that from becoming a silent device-side ImportError. +# that imports another package still works — the sensor drivers import +# i2c_bus to open their own bus, so that fixpoint pulls i2c_bus in for any +# project whose firmware imports such a driver. _needed: set = set() _frontier = _imported_names(Path("/firmware")) & _packages.keys() while _frontier: diff --git a/projects/compass/README.md b/projects/compass/README.md index f0ca9a0..af8fedd 100644 --- a/projects/compass/README.md +++ b/projects/compass/README.md @@ -70,6 +70,9 @@ you unplug and replug the board. ## Wiring +> The authoritative pin map is the `BOARD` table in [firmware/main.py](firmware/main.py); +> the per-board diagrams below mirror it. + ### QMC5883P magnetometer ``` @@ -94,7 +97,8 @@ you have a bare/unregulated variant instead, power VCC from **3V3**. The QMC5883P responds at the fixed I²C address `0x2C` (not configurable). Same SDA/SCL pins as the other projects — SDA=GP0 / SCL=GP1 on the RP boards, -SDA=GPIO1 / SCL=GPIO2 on the ESP32-S3 — via the shared `i2c_bus` package. +SDA=GPIO1 / SCL=GPIO2 on the ESP32-S3 — set by this project's `BOARD` table and +passed to `i2c_bus.hard_i2c`. ### RP2040-Zero diff --git a/projects/compass/firmware/main.py b/projects/compass/firmware/main.py index a08cb7b..09495be 100644 --- a/projects/compass/firmware/main.py +++ b/projects/compass/firmware/main.py @@ -5,22 +5,33 @@ their smoothed counterparts (xs/ys/zs), and a computed heading as one-JSON-per-line on the serial port at ~50 Hz. -Chip-agnostic: all hardware-specific behaviour lives in the package backends -(boot_status_led, i2c_bus, qmc5883p). +Pin assignments live in this module's BOARD table (dispatched per chip by +os.uname().machine); chip-specific *behaviour* — including which I²C bus the +sensor opens — stays in the packages (boot_status_led, qmc5883p). """ import math +import os import time +from collections import namedtuple import ujson from boot_status_led import status -from i2c_bus import hard_i2c as i2c -from qmc5883p import QMC5883P +from qmc5883p import QMC5883P, DeviceNotFoundError from smoothing import simple_moving_average -# QMC5883P fixed I²C address (not configurable on this part). -MAG_ADDRESS = 0x2C +# Per-chip pin map — the authoritative wiring for this project, plain GPIO +# numbers. i2c_id selects the hardware I²C peripheral the driver opens. Filled +# per chip by os.uname().machine dispatch at import. +Board = namedtuple("Board", ("name", "i2c_id", "sda", "scl")) +_machine = os.uname().machine +if "ESP32S3" in _machine: + BOARD = Board(name="ESP32-S3-Zero", i2c_id=0, sda=1, scl=2) +elif "RP2350" in _machine: + BOARD = Board(name="RP2350", i2c_id=0, sda=0, scl=1) +else: + BOARD = Board(name="RP2040-Zero", i2c_id=0, sda=0, scl=1) # Per-axis simple moving average; each smoothed value equals the raw reading # until its window fills with SMOOTH_WINDOW samples. @@ -46,26 +57,27 @@ def emit(obj: dict) -> None: def init_sensor() -> QMC5883P: - """Scan the bus and initialise the QMC5883P, retrying until it comes up. + """Open the bus and initialise the QMC5883P, retrying until it comes up. - Parks at status.no_device() when nothing responds at 0x2C, and at + The driver opens its own bus from BOARD pins and scans. Parks at + status.no_device() when nothing responds at 0x2C (DeviceNotFoundError), and at status.init_err() when the device ACKs but the chip-ID check or a config write raises. Both states retry every _RETRY_PAUSE_MS. + + Returns: + An initialised QMC5883P driver bound to the bus it opened. """ status.i2c_init() while True: try: - devices = i2c.scan() - emit({"diag": "scan", "devices": devices}) - if MAG_ADDRESS not in devices: - # Bus reachable but no device responded. - # Check SDA/SCL wiring, 3V3 power, GND, pull-ups. - status.no_device() - emit({"diag": "no_device", "devices": devices}) - time.sleep_ms(_RETRY_PAUSE_MS) - continue - mag = QMC5883P(i2c) + mag = QMC5883P(bus_id=BOARD.i2c_id, sda=BOARD.sda, scl=BOARD.scl) emit({"diag": "mag_ok", "addr": mag.address}) + except DeviceNotFoundError as e: + # Bus reachable but no device responded at 0x2C. + # Check SDA/SCL wiring, 3V3 power, GND, pull-ups. + status.no_device() + emit({"diag": "no_device", "err": str(e)}) + time.sleep_ms(_RETRY_PAUSE_MS) except OSError as e: # Device ACKed at 0x2C but the chip-ID check or a config write # failed. Likely a wrong sensor on the bus or a bus glitch. diff --git a/projects/compass/pyproject.toml b/projects/compass/pyproject.toml index 45be23b..e072129 100644 --- a/projects/compass/pyproject.toml +++ b/projects/compass/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "compass" -version = "0.1.0" +version = "0.2.0" description = "QMC5883P magnetometer → JSON-over-USB-CDC → FastAPI/WebSocket → Plotly compass dashboard." readme = "README.md" requires-python = ">=3.12" diff --git a/projects/compass/tests/conftest.py b/projects/compass/tests/conftest.py deleted file mode 100644 index ae37ae6..0000000 --- a/projects/compass/tests/conftest.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Host CPython pytest bootstrap for the compass firmware. - -Exposes the `main_ns` fixture: an AST-loaded namespace containing main.py's -constants, emit(), init_sensor(), and stream(), with fakes for time and status -side effects so the streaming loop is exercisable in tests without the module's -top-level main() call running. -""" - -import ast -import math -import pathlib -from types import SimpleNamespace - -import pytest - -_HERE = pathlib.Path(__file__).parent.resolve() -_FIRMWARE = _HERE.parent / "firmware" / "main.py" -_KEEP_FUNCS = {"emit", "init_sensor", "stream"} - - -def _load_main_namespace(fake_time, fake_status): - """Parse main.py and exec the constants + key functions in a fresh namespace. - - Keeps only module-level Assign nodes (constants) and the FunctionDefs the - tests need. Drops Import nodes (the caller seeds substitutes for `time`, - `status`, `ujson`, `math`) and the trailing Expr statement that calls main() - — which would otherwise block in the streaming loop on import. - """ - src = _FIRMWARE.read_text() - tree = ast.parse(src) - kept = [ - node - for node in tree.body - if isinstance(node, ast.Assign) - or (isinstance(node, ast.FunctionDef) and node.name in _KEEP_FUNCS) - ] - module = ast.Module(body=kept, type_ignores=[]) - ast.fix_missing_locations(module) - code = compile(module, str(_FIRMWARE), "exec") - - import ujson - - from smoothing import simple_moving_average - - ns: dict = { - "time": fake_time, - "status": fake_status, - "ujson": ujson, - # stream() computes heading with math.atan2/math.degrees; main.py's - # `import math` is dropped by the AST filter, so seed the real module. - "math": math, - # The AST filter likewise drops the smoothing import; seed the real - # function stream() calls for the per-axis xs/ys/zs averages. - "simple_moving_average": simple_moving_average, - # init_sensor() does `QMC5883P(i2c)`; the annotation in - # stream(mag: QMC5883P) is evaluated at def time, so the name must - # resolve. Tests override this as needed. - "QMC5883P": object, - } - exec(code, ns) - return ns - - -@pytest.fixture -def main_ns(fake_time, fake_status): - """Fresh AST-loaded main.py namespace with fakes injected. - - Returns a SimpleNamespace with: - - .ns: dict of module-level names (pull stream, emit, init_sensor, ...) - - .time: the _FakeTime instance used as the `time` module - - .status: the _FakeStatus instance; inspect .status.calls for transitions - """ - ns = _load_main_namespace(fake_time, fake_status) - return SimpleNamespace(ns=ns, time=fake_time, status=fake_status) diff --git a/projects/compass/tests/test_emit_schema.py b/projects/compass/tests/test_emit_schema.py deleted file mode 100644 index f3d9a9f..0000000 --- a/projects/compass/tests/test_emit_schema.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Host CPython pytest checks for JSON schema invariants of compass's `emit()`. - -Asserts the 8-key magnetometer sample dict (raw x/y/z, smoothed xs/ys/zs, time, -heading) round-trips and the diag namespace (including the edge-triggered "ovl" -event) survives ujson.dumps. The viz parser at cpython-packages/serial_over_web -drops non-JSON lines, so a regression here silently breaks the dashboard. -""" - -import io -import json -from contextlib import redirect_stdout - - -def test_emit_sample_dict(main_ns): - emit = main_ns.ns["emit"] - sample = { - "t": 100, - "x": 120, - "y": -45, - "z": 300, - "xs": 118.5, - "ys": -44.0, - "zs": 301.25, - "heading_deg": 200.5, - } - assert _run(emit, sample) == sample - - -def test_emit_ovl_diag(main_ns): - emit = main_ns.ns["emit"] - assert _run(emit, {"diag": "ovl"}) == {"diag": "ovl"} - - -def test_emit_diag_lines_are_valid_json(main_ns): - emit = main_ns.ns["emit"] - parsed = _run(emit, {"diag": "scan", "devices": [0x2C]}) - assert parsed["diag"] == "scan" - assert parsed["devices"] == [44] - - -def _run(emit, obj): - buf = io.StringIO() - with redirect_stdout(buf): - emit(obj) - line = buf.getvalue().strip() - return json.loads(line) diff --git a/projects/compass/tests/test_full_import.py b/projects/compass/tests/test_full_import.py index 4e17edc..de2a49d 100644 --- a/projects/compass/tests/test_full_import.py +++ b/projects/compass/tests/test_full_import.py @@ -8,6 +8,7 @@ import importlib.util import io import json +import os import pathlib import sys from contextlib import redirect_stdout @@ -15,11 +16,21 @@ import pytest +from micropython_stubs.testing import ( + BOARD_CHIPS, + DeviceNotFoundError, + FakeStatus, + build_full_import_stubs, +) + _FIRMWARE = pathlib.Path(__file__).parent.parent / "firmware" / "main.py" ADDR = 0x2C -def test_main_executes_init_then_streams_one_sample(monkeypatch, fake_status): +@pytest.mark.parametrize("machine_str,board_name", BOARD_CHIPS) +def test_main_executes_init_then_streams_one_sample(monkeypatch, machine_str, board_name): + fake_status = FakeStatus() + monkeypatch.setattr(os, "uname", lambda: SimpleNamespace(machine=machine_str)) for name, module in _build_stubs(fake_status).items(): monkeypatch.setitem(sys.modules, name, module) monkeypatch.delitem(sys.modules, "main", raising=False) @@ -31,9 +42,9 @@ def test_main_executes_init_then_streams_one_sample(monkeypatch, fake_status): with redirect_stdout(buf), pytest.raises(_StopMainError): spec.loader.exec_module(module) + assert module.BOARD.name == board_name lines = [json.loads(ln) for ln in buf.getvalue().splitlines() if ln.strip()] diags = [ln.get("diag") for ln in lines if "diag" in ln] - assert "scan" in diags assert "mag_ok" in diags assert any("heading_deg" in ln for ln in lines) @@ -42,18 +53,10 @@ class _StopMainError(Exception): """Raised by the fake mag on the second read() to escape stream().""" -class _Bus: - """Minimal I²C bus stub: scans to the QMC5883P fixed address.""" - - @staticmethod - def scan() -> list[int]: - return [ADDR] - - class _FakeMag: - """Stub QMC5883P; second read() raises to escape stream().""" + """Stub QMC5883P that opens its own bus; second read() raises to escape stream().""" - def __init__(self, _bus, address=ADDR) -> None: + def __init__(self, *, sda, scl, bus_id=0, address=ADDR) -> None: self.address = address self.last_status = 0 self._calls = 0 @@ -66,19 +69,8 @@ def read(self): def _build_stubs(status_stub): - time_stub = SimpleNamespace( - sleep_ms=lambda _ms: None, - ticks_ms=lambda: 0, - ) - boot_status_led_stub = SimpleNamespace(status=status_stub) - i2c_bus_stub = SimpleNamespace(hard_i2c=_Bus()) - qmc5883p_stub = SimpleNamespace(QMC5883P=_FakeMag) - - return { - "time": time_stub, - "ujson": __import__("json"), - "boot_status_led": boot_status_led_stub, - "boot_status_led.status": status_stub, - "i2c_bus": i2c_bus_stub, - "qmc5883p": qmc5883p_stub, - } + # main() now builds QMC5883P(id=, sda=, scl=) directly — the driver owns the + # bus — so the project no longer imports i2c_bus; the qmc5883p stub exposes + # the driver class and its DeviceNotFoundError. + qmc5883p_stub = SimpleNamespace(QMC5883P=_FakeMag, DeviceNotFoundError=DeviceNotFoundError) + return build_full_import_stubs("qmc5883p", qmc5883p_stub, status_stub) diff --git a/projects/compass/tests/test_init_sensor.py b/projects/compass/tests/test_init_sensor.py index 2c6a208..a7c8c30 100644 --- a/projects/compass/tests/test_init_sensor.py +++ b/projects/compass/tests/test_init_sensor.py @@ -1,68 +1,72 @@ """Host CPython pytest tests for init_sensor in compass firmware. -Drives the happy path at the fixed 0x2C address, the no_device retry, and the -init_err (OSError, including a chip-ID mismatch) retry. +The driver opens its own bus and scans, so init_sensor() takes no arguments and +constructs QMC5883P(bus_id=, sda=, scl=) from BOARD. A fake driver class drives the +happy path, the no_device retry (DeviceNotFoundError), and the init_err (OSError, +e.g. a chip-ID mismatch) retry. """ -import pytest +import math +import os +import pathlib +from collections import namedtuple +from typing import ClassVar -ADDR = 0x2C - - -def test_init_sensor_happy_path(init_ns): - init_ns.ns["i2c"] = _FakeBus(scans=[[ADDR]]) - mag = init_ns.ns["init_sensor"]() - assert mag.address == ADDR - assert init_ns.status.calls == ["i2c_init"] +from micropython_stubs.testing import ScriptedFake, firmware_namespace +from qmc5883p import DeviceNotFoundError - -def test_init_sensor_retries_when_device_missing(init_ns): - init_ns.ns["i2c"] = _FakeBus(scans=[[], [ADDR]]) - init_ns.ns["init_sensor"]() - assert init_ns.status.calls == ["i2c_init", "no_device"] +_FIRMWARE = pathlib.Path(__file__).parent.parent / "firmware" / "main.py" +_KEEP_FUNCS = {"emit", "init_sensor", "stream"} +Board = namedtuple("Board", ("name", "i2c_id", "sda", "scl")) +_TEST_BOARD = Board(name="RP2040-Zero", i2c_id=0, sda=0, scl=1) +ADDR = 0x2C -def test_init_sensor_handles_init_err(init_ns): - # First construction raises (e.g. chip-ID mismatch); second succeeds. - _FakeMag.raise_oserror_once = True - init_ns.ns["i2c"] = _FakeBus(scans=[[ADDR], [ADDR]]) - init_ns.ns["init_sensor"]() - assert "init_err" in init_ns.status.calls +class _FakeMag(ScriptedFake): + """QMC5883P stand-in (see ScriptedFake): records address + status on success.""" + script: ClassVar[list] = [] -@pytest.fixture(autouse=True) -def _reset_mag(): - _FakeMag.raise_oserror_once = False - _FakeMag._calls = 0 - yield + def __init__(self, *, sda, scl, bus_id=0, address=ADDR) -> None: + super().__init__() + self.address = address + self.last_status = 0 -@pytest.fixture -def init_ns(main_ns): - main_ns.ns["QMC5883P"] = _FakeMag - return main_ns +def _make_init_ns(): + """Create AST-loaded namespace with _FakeMag injected.""" + _FakeMag.script = [] + from smoothing import simple_moving_average + return firmware_namespace( + _FIRMWARE, + _KEEP_FUNCS, + os=os, + namedtuple=namedtuple, + BOARD=_TEST_BOARD, + math=math, + simple_moving_average=simple_moving_average, + QMC5883P=_FakeMag, + DeviceNotFoundError=DeviceNotFoundError, + ) -class _FakeBus: - def __init__(self, *, scans) -> None: - self._scans = list(scans) - def scan(self): - if len(self._scans) == 1: - return self._scans[0] - return self._scans.pop(0) +def test_init_sensor_happy_path(): + init_ns = _make_init_ns() + mag = init_ns.ns["init_sensor"]() + assert mag.address == ADDR + assert init_ns.status.calls == ["i2c_init"] -class _FakeMag: - """QMC5883P stand-in: records address, optionally raises on first init.""" +def test_init_sensor_retries_when_device_missing(): + init_ns = _make_init_ns() + _FakeMag.script = [DeviceNotFoundError("no device"), None] + init_ns.ns["init_sensor"]() + assert init_ns.status.calls == ["i2c_init", "no_device"] - raise_oserror_once = False - _calls = 0 - def __init__(self, bus, address=ADDR) -> None: - type(self)._calls += 1 - if type(self).raise_oserror_once and type(self)._calls == 1: - raise OSError("scripted chip-ID fail") - self.bus = bus - self.address = address - self.last_status = 0 +def test_init_sensor_handles_init_err(): + init_ns = _make_init_ns() + _FakeMag.script = [OSError("scripted chip-ID fail"), None] + init_ns.ns["init_sensor"]() + assert "init_err" in init_ns.status.calls diff --git a/projects/compass/tests/test_stream.py b/projects/compass/tests/test_stream.py index e92f9f8..51bc024 100644 --- a/projects/compass/tests/test_stream.py +++ b/projects/compass/tests/test_stream.py @@ -6,26 +6,58 @@ bit), and read_err → streaming recovery. """ -import io -import json -from contextlib import redirect_stdout - -import pytest +import math +import os +import pathlib +from collections import namedtuple + +from micropython_stubs.testing import ( + StopLoopError, + diags, + firmware_namespace, + run_stream, + samples, +) +from qmc5883p import DeviceNotFoundError + +_FIRMWARE = pathlib.Path(__file__).parent.parent / "firmware" / "main.py" +_KEEP_FUNCS = {"emit", "init_sensor", "stream"} +Board = namedtuple("Board", ("name", "i2c_id", "sda", "scl")) +_TEST_BOARD = Board(name="RP2040-Zero", i2c_id=0, sda=0, scl=1) _OK = (100, -50, 200) -def test_one_sample_per_loop_with_8_keys(main_ns): +def _make_main_ns(): + """Create a fresh AST-loaded main.py namespace with fakes.""" + from smoothing import simple_moving_average + + return firmware_namespace( + _FIRMWARE, + _KEEP_FUNCS, + os=os, + namedtuple=namedtuple, + BOARD=_TEST_BOARD, + math=math, + simple_moving_average=simple_moving_average, + QMC5883P=object, + DeviceNotFoundError=DeviceNotFoundError, + ) + + +def test_one_sample_per_loop_with_8_keys(): + main_ns = _make_main_ns() mag = _FakeMag(script=[_OK]) - samples = _samples(_run_stream(main_ns, mag)) - assert len(samples) == 1 - assert set(samples[0]) == {"t", "x", "y", "z", "xs", "ys", "zs", "heading_deg"} + sample_lines = samples(run_stream(main_ns, mag)) + assert len(sample_lines) == 1 + assert set(sample_lines[0]) == {"t", "x", "y", "z", "xs", "ys", "zs", "heading_deg"} -def test_smoothed_equals_raw_until_window_fills(main_ns): +def test_smoothed_equals_raw_until_window_fills(): """Before the window fills, xs/ys/zs equal the raw x/y/z of that sample.""" + main_ns = _make_main_ns() mag = _FakeMag(script=[_OK]) - sample = _samples(_run_stream(main_ns, mag))[0] + sample = samples(run_stream(main_ns, mag))[0] assert (sample["xs"], sample["ys"], sample["zs"]) == ( sample["x"], sample["y"], @@ -33,63 +65,47 @@ def test_smoothed_equals_raw_until_window_fills(main_ns): ) -def test_heading_normalised_to_circle(main_ns): +def test_heading_normalised_to_circle(): + main_ns = _make_main_ns() mag = _FakeMag(script=[_OK]) - sample = _samples(_run_stream(main_ns, mag))[0] + sample = samples(run_stream(main_ns, mag))[0] assert 0 <= sample["heading_deg"] < 360 -def test_ovl_edge_triggers_once(main_ns): +def test_ovl_edge_triggers_once(): """Three OVL-true reads emit exactly one {"diag": "ovl"} (rising edge only).""" + main_ns = _make_main_ns() mag = _FakeMag(script=[_OK, _OK, _OK], ovl_script=[True, True, True]) - lines = _run_stream(main_ns, mag) - assert _diags(lines).count("ovl") == 1 + lines = run_stream(main_ns, mag) + assert diags(lines).count("ovl") == 1 -def test_ovl_falling_then_rising_emits_two(main_ns): +def test_ovl_falling_then_rising_emits_two(): """OVL True → False → True emits two ovl events (two rising edges).""" + main_ns = _make_main_ns() mag = _FakeMag( script=[_OK, _OK, _OK, _OK], ovl_script=[True, False, True, False], ) - lines = _run_stream(main_ns, mag) - assert _diags(lines).count("ovl") == 2 + lines = run_stream(main_ns, mag) + assert diags(lines).count("ovl") == 2 -def test_read_err_recovery_resumes_streaming(main_ns): +def test_read_err_recovery_resumes_streaming(): + main_ns = _make_main_ns() mag = _FakeMag(script=[_OK, OSError, _OK]) - lines = _run_stream(main_ns, mag) - assert len(_samples(lines)) == 2 - assert "read_err" in _diags(lines) + lines = run_stream(main_ns, mag) + assert len(samples(lines)) == 2 + assert "read_err" in diags(lines) assert main_ns.status.calls == ["streaming", "read_err", "streaming"] -def _run_stream(main_ns, mag): - stream = main_ns.ns["stream"] - buf = io.StringIO() - with redirect_stdout(buf), pytest.raises(_StopLoopError): - stream(mag) - return [json.loads(ln) for ln in buf.getvalue().splitlines() if ln.strip()] - - -def _samples(lines): - return [ln for ln in lines if "diag" not in ln] - - -def _diags(lines): - return [ln["diag"] for ln in lines if "diag" in ln] - - -class _StopLoopError(Exception): - """Sentinel: any non-OSError raised by the fake mag escapes the loop.""" - - class _FakeMag: """Scripted QMC5883P. `script` items: 3-tuple = read() return; exception class = raise. `ovl_script` is consumed in lockstep — each entry sets last_status's OVL bit - *after* the read returns. Exhausting `script` raises _StopLoopError. + *after* the read returns. Exhausting `script` raises StopLoopError. """ def __init__(self, script, ovl_script=None) -> None: @@ -99,7 +115,7 @@ def __init__(self, script, ovl_script=None) -> None: def read(self): if not self._script: - raise _StopLoopError + raise StopLoopError item = self._script.pop(0) ovl = self._ovl.pop(0) if self._ovl else False if isinstance(item, type) and issubclass(item, BaseException): diff --git a/projects/conftest.py b/projects/conftest.py deleted file mode 100644 index d9c13e4..0000000 --- a/projects/conftest.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Shared pytest fixtures for all project test suites. - -Defines the time and status stubs used by every project's `main_ns` fixture -and full-import smoke tests. Individual project conftest files inject these -via the `fake_time` and `fake_status` fixture parameters. -""" - -from collections.abc import Callable - -import pytest - - -class _FakeTime: - """time stub: monotonic ticks_ms counter, ticks_diff, and no-op sleep_ms.""" - - def __init__(self) -> None: - self.ticks = 0 - - def ticks_ms(self) -> int: - """Return the next monotonic tick value (increments by 1).""" - self.ticks += 1 - return self.ticks - - def ticks_diff(self, a: int, b: int) -> int: - """Return the signed difference between two ticks_ms() snapshots.""" - return a - b - - def sleep_ms(self, _ms: int) -> None: - """No-op sleep so tests run without delay.""" - return - - -class _FakeStatus: - """status stub: record every transition call by name into self.calls.""" - - def __init__(self) -> None: - self.calls: list[str] = [] - - def __getattr__(self, name: str) -> Callable[[], None]: - # Only intercept the public LED transitions; let dunder lookups fail - # so pytest's own introspection is unaffected. - if name.startswith("_"): - raise AttributeError(name) - - def _rec() -> None: - self.calls.append(name) - - return _rec - - -@pytest.fixture -def fake_time() -> _FakeTime: - """Fresh _FakeTime instance for each test.""" - return _FakeTime() - - -@pytest.fixture -def fake_status() -> _FakeStatus: - """Fresh _FakeStatus instance for each test.""" - return _FakeStatus() diff --git a/projects/distance-stream/README.md b/projects/distance-stream/README.md index 611baa4..8ed2c93 100644 --- a/projects/distance-stream/README.md +++ b/projects/distance-stream/README.md @@ -63,6 +63,9 @@ real time. ## Wiring +> The authoritative pin map is the `BOARD` table in [firmware/main.py](firmware/main.py); +> the per-board diagrams below mirror it. + ### VL53L0X ToF sensor ``` diff --git a/projects/distance-stream/firmware/main.py b/projects/distance-stream/firmware/main.py index c848590..6af47d4 100644 --- a/projects/distance-stream/firmware/main.py +++ b/projects/distance-stream/firmware/main.py @@ -1,15 +1,26 @@ """MCU-micropython firmware for distance-stream: I²C scan, VL53L0X init, JSON stream.""" +import os import time +from collections import namedtuple import ujson from boot_status_led import status -from i2c_bus import soft_i2c as i2c from smoothing import median -from vl53l0x import VL53L0X - -TOF_ADDRESS = 0x29 +from vl53l0x import VL53L0X, DeviceNotFoundError + +# Per-chip pin map — the authoritative wiring for this project, plain GPIO +# numbers. The VL53L0X opens a bit-banged soft I²C bus internally, so no +# peripheral id is needed. Filled per chip by os.uname().machine dispatch. +Board = namedtuple("Board", ("name", "sda", "scl")) +_machine = os.uname().machine +if "ESP32S3" in _machine: + BOARD = Board(name="ESP32-S3-Zero", sda=1, scl=2) +elif "RP2350" in _machine: + BOARD = Board(name="RP2350", sda=0, scl=1) +else: + BOARD = Board(name="RP2040-Zero", sda=0, scl=1) # Sensor reports ~8190 mm (and up to 65535) when nothing is in range; # emit null so the viz shows a gap instead of a spurious large value. @@ -27,12 +38,6 @@ _BOOT_PAUSE_MS = 50 _RETRY_PAUSE_MS = 500 _READ_ERR_PAUSE_MS = 200 -_SOFT_RESET_PAUSE_MS = 2 -_SOFT_RESET_POLLS = 50 - -_REG_SOFT_RESET = 0xBF -_REG_MODEL_ID = 0xC0 -_MODEL_ID_BOOTED = 0xEE def emit(obj: dict) -> None: @@ -40,57 +45,28 @@ def emit(obj: dict) -> None: print(ujson.dumps(obj)) -def soft_reset_sensor(bus: object, address: int = TOF_ADDRESS) -> bool: - """Soft-reset the VL53L0X and poll until it reboots. - - Clears any half-init state from a previous attempt before the - driver touches calibration registers. +def init_sensor() -> VL53L0X: + """Open the bus and initialise the VL53L0X, retrying until it comes up. - Args: - bus: Object exposing writeto_mem / readfrom_mem. - address: 7-bit I²C address of the sensor. + The driver opens its own soft I²C bus from BOARD pins, scans, and soft-resets + the chip. Parks at status.no_device() when no device is present at 0x29 + (DeviceNotFoundError), and at status.init_err() when the device ACKs but driver + init raises. Returns: - True if the chip signals ready within the poll budget, False otherwise. - """ - try: - bus.writeto_mem(address, _REG_SOFT_RESET, b"\x00") - time.sleep_ms(_SOFT_RESET_PAUSE_MS) - bus.writeto_mem(address, _REG_SOFT_RESET, b"\x01") - time.sleep_ms(_SOFT_RESET_PAUSE_MS) - for _ in range(_SOFT_RESET_POLLS): - if bus.readfrom_mem(address, _REG_MODEL_ID, 1)[0] == _MODEL_ID_BOOTED: - return True - time.sleep_ms(_SOFT_RESET_PAUSE_MS) - except OSError: - return False - return False - - -def init_sensor() -> VL53L0X: - """Scan i2c bus and initialise VL53L0X, retrying until it comes up. - - Parks at status.no_device() when no device is present at 0x29, and at - status.init_err() when the device ACKs but driver init raises. + An initialised VL53L0X driver in continuous-ranging mode. """ status.i2c_init() while True: try: - devices = i2c.scan() - emit({"diag": "scan", "devices": devices}) - if TOF_ADDRESS not in devices: - status.no_device() - emit({"diag": "no_device", "devices": devices}) - time.sleep_ms(_RETRY_PAUSE_MS) - continue - # This breakout variant signals ranging done via bit 6 of - # _RESULT_INTERRUPT_STATUS and hangs in the SPAD-info procedure; - # both quirks apply on all MCUs, not just ESP32 or RP. - soft_reset_sensor(i2c) - tof = VL53L0X(i2c, skip_spad_info=True, interrupt_status_mask=0xFF) + tof = VL53L0X(sda=BOARD.sda, scl=BOARD.scl) tof.set_measurement_timing_budget(TIMING_BUDGET_US) tof.start() emit({"diag": "tof_ok", "addr": tof.address}) + except DeviceNotFoundError as e: + status.no_device() + emit({"diag": "no_device", "err": str(e)}) + time.sleep_ms(_RETRY_PAUSE_MS) except (OSError, RuntimeError) as err: # OSError = I²C NACK; RuntimeError = driver poll timeout. status.init_err() diff --git a/projects/distance-stream/pyproject.toml b/projects/distance-stream/pyproject.toml index ad1484c..5210efe 100644 --- a/projects/distance-stream/pyproject.toml +++ b/projects/distance-stream/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "distance-stream" -version = "0.3.0" +version = "0.4.0" description = "VL53L0X time-of-flight → JSON-over-USB-CDC → FastAPI/WebSocket → Plotly dashboard." readme = "README.md" requires-python = ">=3.12" @@ -14,7 +14,4 @@ dependencies = [ package = false [tool.uv.sources] -boot_status_led = { index = "internal" } -i2c_bus = { index = "internal" } -smoothing = { index = "internal" } -vl53l0x = { index = "internal" } +micropython-boardfarm = { workspace = true } diff --git a/projects/distance-stream/tests/conftest.py b/projects/distance-stream/tests/conftest.py deleted file mode 100644 index 7fe0c53..0000000 --- a/projects/distance-stream/tests/conftest.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Host CPython pytest bootstrap for the distance-stream firmware. - -Exposes the `main_ns` fixture: an AST-loaded namespace containing main.py's -constants, emit(), and stream(), with fakes for time and status side effects -so the streaming loop is exercisable in tests without the module's top-level -main() call running. -""" - -import ast -import pathlib -from types import SimpleNamespace - -import pytest - -_HERE = pathlib.Path(__file__).parent.resolve() -_FIRMWARE = _HERE.parent / "firmware" / "main.py" -_KEEP_FUNCS = {"emit", "stream", "soft_reset_sensor", "init_sensor"} - - -def _load_main_namespace(fake_time, fake_status): - """Parse main.py and exec the constants + key functions in a fresh namespace. - - Keeps only module-level Assign nodes (constants) and the FunctionDefs the - tests need. Drops Import nodes (the caller seeds substitutes for `time`, - `status`, `ujson`) and the trailing Expr statement that calls main() — - which would otherwise block in the ranging loop on import. - """ - src = _FIRMWARE.read_text() - tree = ast.parse(src) - kept = [ - node - for node in tree.body - if isinstance(node, ast.Assign) - or (isinstance(node, ast.FunctionDef) and node.name in _KEEP_FUNCS) - ] - module = ast.Module(body=kept, type_ignores=[]) - ast.fix_missing_locations(module) - code = compile(module, str(_FIRMWARE), "exec") - - import ujson - - from smoothing import median - - ns: dict = { - "time": fake_time, - "status": fake_status, - "ujson": ujson, - # The AST filter drops main.py's imports, so seed the real smoothing - # function stream() calls; otherwise the name is unresolved at runtime. - "median": median, - # stream() has `tof: VL53L0X` in its signature; the annotation is - # evaluated at def time, so the name must resolve. object suffices. - "VL53L0X": object, - } - exec(code, ns) - return ns - - -@pytest.fixture -def main_ns(fake_time, fake_status): - """Fresh AST-loaded main.py namespace with fakes injected. - - Returns a SimpleNamespace with: - - .ns: dict of module-level names (pull stream, emit, ...) - - .time: the _FakeTime instance used as the `time` module - - .status: the _FakeStatus instance; inspect .status.calls for transitions - """ - ns = _load_main_namespace(fake_time, fake_status) - return SimpleNamespace(ns=ns, time=fake_time, status=fake_status) diff --git a/projects/distance-stream/tests/test_emit_schema.py b/projects/distance-stream/tests/test_emit_schema.py deleted file mode 100644 index 512fad3..0000000 --- a/projects/distance-stream/tests/test_emit_schema.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Host CPython pytest checks for JSON schema invariants of distance-stream's `emit()`. - -Asserts that: - - every line is `ujson.dumps(obj)` with no extra prints - - `distance_mm` (smoothed) and `distance_mm_raw` are both `null` (None) when - the ToF reports OUT_OF_RANGE_MM (≥8190) - - both are int mm otherwise - -The viz parser at projects/distance-stream/viz/app.py drops any line that -isn't valid JSON, so any regression here silently breaks the dashboard. -""" - -import io -import json -from contextlib import redirect_stdout - -import ujson - - -def test_emit_distance_int(): - emit = _capture_emit() - sample = {"t": 100, "distance_mm": 412, "distance_mm_raw": 415} - assert _run(emit, sample) == sample - - -def test_emit_out_of_range_is_null(): - emit = _capture_emit() - sample = {"t": 100, "distance_mm": None, "distance_mm_raw": None} - assert _run(emit, sample) == sample - - -def test_emit_diag_lines_are_valid_json(): - emit = _capture_emit() - parsed = _run(emit, {"diag": "scan", "devices": [0x29]}) - assert parsed["diag"] == "scan" - assert parsed["devices"] == [41] - - -def _capture_emit(): - """Build the trivial emit() from main.py without executing the loop. - - main.py runs at module level — importing it would block forever in the - ranging loop. So we read the source, extract just the emit() function, - and exec it in a clean namespace. - """ - src = "def emit(obj):\n print(ujson.dumps(obj))\n" - ns = {"ujson": ujson} - exec(src, ns) - return ns["emit"] - - -def _run(emit, obj): - buf = io.StringIO() - with redirect_stdout(buf): - emit(obj) - line = buf.getvalue().strip() - return json.loads(line) diff --git a/projects/distance-stream/tests/test_full_import.py b/projects/distance-stream/tests/test_full_import.py index b352b2e..6c956a4 100644 --- a/projects/distance-stream/tests/test_full_import.py +++ b/projects/distance-stream/tests/test_full_import.py @@ -9,6 +9,7 @@ import importlib.util import io import json +import os import pathlib import sys from contextlib import redirect_stdout @@ -16,11 +17,21 @@ import pytest +from micropython_stubs.testing import ( + BOARD_CHIPS, + DeviceNotFoundError, + FakeStatus, + build_full_import_stubs, +) + _FIRMWARE = pathlib.Path(__file__).parent.parent / "firmware" / "main.py" TOF_ADDRESS = 0x29 -def test_main_executes_init_then_streams_one_sample(monkeypatch, fake_status): +@pytest.mark.parametrize("machine_str,board_name", BOARD_CHIPS) +def test_main_executes_init_then_streams_one_sample(monkeypatch, machine_str, board_name): + fake_status = FakeStatus() + monkeypatch.setattr(os, "uname", lambda: SimpleNamespace(machine=machine_str)) for name, module in _build_stubs(fake_status).items(): monkeypatch.setitem(sys.modules, name, module) # Earlier tests load main.py via AST-exec, leaving an entry in sys.modules. @@ -33,9 +44,9 @@ def test_main_executes_init_then_streams_one_sample(monkeypatch, fake_status): with redirect_stdout(buf), pytest.raises(_StopMainError): spec.loader.exec_module(module) + assert module.BOARD.name == board_name lines = [json.loads(ln) for ln in buf.getvalue().splitlines() if ln.strip()] diags = [ln.get("diag") for ln in lines if "diag" in ln] - assert "scan" in diags assert "tof_ok" in diags assert any("distance_mm" in ln for ln in lines) @@ -44,26 +55,10 @@ class _StopMainError(Exception): """Raised by the fake tof on the second read() to escape stream().""" -class _Bus: - """Minimal I²C bus stub: scans to TOF_ADDRESS, returns a booted MODEL_ID.""" - - @staticmethod - def scan() -> list[int]: - return [TOF_ADDRESS] - - @staticmethod - def writeto_mem(_addr, _reg, _buf) -> None: - return None - - @staticmethod - def readfrom_mem(_addr, _reg, _n) -> bytes: - return b"\xee" # _MODEL_ID_BOOTED - - class _FakeVL53L0X: - """Stub VL53L0X driver; second read() raises to escape stream().""" + """Stub VL53L0X that opens its own bus; second read() raises to escape stream().""" - def __init__(self, _bus, *, skip_spad_info=False, interrupt_status_mask=0) -> None: + def __init__(self, *, sda, scl) -> None: self.address = TOF_ADDRESS self._calls = 0 @@ -82,19 +77,8 @@ def read(self): def _build_stubs(status_stub): """Build SimpleNamespace stubs matching main.py's module-level imports.""" - time_stub = SimpleNamespace( - sleep_ms=lambda _ms: None, - ticks_ms=lambda: 0, - ) - boot_status_led_stub = SimpleNamespace(status=status_stub) - i2c_bus_stub = SimpleNamespace(soft_i2c=_Bus()) - vl53l0x_stub = SimpleNamespace(VL53L0X=_FakeVL53L0X) - - return { - "time": time_stub, - "ujson": __import__("json"), - "boot_status_led": boot_status_led_stub, - "boot_status_led.status": status_stub, - "i2c_bus": i2c_bus_stub, - "vl53l0x": vl53l0x_stub, - } + # main() now builds VL53L0X(sda=, scl=) directly — the driver owns the bus, + # scan, and soft reset — so the project no longer imports i2c_bus; the + # vl53l0x stub exposes the driver class and its DeviceNotFoundError. + vl53l0x_stub = SimpleNamespace(VL53L0X=_FakeVL53L0X, DeviceNotFoundError=DeviceNotFoundError) + return build_full_import_stubs("vl53l0x", vl53l0x_stub, status_stub) diff --git a/projects/distance-stream/tests/test_init_sensor.py b/projects/distance-stream/tests/test_init_sensor.py index 1aaea11..9369d85 100644 --- a/projects/distance-stream/tests/test_init_sensor.py +++ b/projects/distance-stream/tests/test_init_sensor.py @@ -1,23 +1,64 @@ -"""Host CPython pytest tests for init_sensor and soft_reset_sensor in distance-stream firmware. +"""Host CPython pytest tests for init_sensor in distance-stream firmware. -Drives both helpers with a scriptable fake I²C bus and a fake VL53L0X. -Covers happy path, no_device retry, init_err retry, and the three -soft_reset outcomes (booted, poll-timeout, mid-write OSError). +The driver opens its own bus, scans, and soft-resets the chip, so init_sensor() +takes no arguments and constructs VL53L0X(sda=, scl=) from BOARD. A fake driver +class drives the happy path, the no_device retry (DeviceNotFoundError), the init_err +(OSError) retry, and the RuntimeError (driver poll timeout) retry. The soft-reset +handshake now lives in the driver and is covered by the vl53l0x package tests. """ -import pytest +import os +import pathlib +from collections import namedtuple +from typing import ClassVar +from micropython_stubs.testing import ScriptedFake, firmware_namespace +from vl53l0x import DeviceNotFoundError + +_FIRMWARE = pathlib.Path(__file__).parent.parent / "firmware" / "main.py" +_KEEP_FUNCS = {"emit", "stream", "init_sensor"} +Board = namedtuple("Board", ("name", "sda", "scl")) +_TEST_BOARD = Board(name="RP2040-Zero", sda=0, scl=1) TOF_ADDRESS = 0x29 -_REG_MODEL_ID = 0xC0 -_MODEL_ID_BOOTED = 0xEE -def test_init_sensor_happy_path(init_ns): - bus = _FakeBus( - scans=[[TOF_ADDRESS]], - mem_reads={_REG_MODEL_ID: bytes([_MODEL_ID_BOOTED])}, +class _FakeVL53L0X(ScriptedFake): + """VL53L0X stand-in (see ScriptedFake): records budget + start on success.""" + + script: ClassVar[list] = [] + + def __init__(self, *, sda, scl) -> None: + super().__init__() + self.address = TOF_ADDRESS + self._budget = None + self._started = False + + def set_measurement_timing_budget(self, budget): + self._budget = budget + + def start(self): + self._started = True + + +def _make_init_ns(): + """Create AST-loaded namespace with _FakeVL53L0X injected.""" + _FakeVL53L0X.script = [] + from smoothing import median + + return firmware_namespace( + _FIRMWARE, + _KEEP_FUNCS, + os=os, + namedtuple=namedtuple, + BOARD=_TEST_BOARD, + median=median, + VL53L0X=_FakeVL53L0X, + DeviceNotFoundError=DeviceNotFoundError, ) - init_ns.ns["i2c"] = bus + + +def test_init_sensor_happy_path(): + init_ns = _make_init_ns() tof = init_ns.ns["init_sensor"]() assert isinstance(tof, _FakeVL53L0X) assert tof._budget == 20_000 # TIMING_BUDGET_US @@ -25,126 +66,22 @@ def test_init_sensor_happy_path(init_ns): assert init_ns.status.calls == ["i2c_init"] -def test_init_sensor_retries_when_device_missing(init_ns): - bus = _FakeBus( - scans=[[], [TOF_ADDRESS]], - mem_reads={_REG_MODEL_ID: bytes([_MODEL_ID_BOOTED])}, - ) - init_ns.ns["i2c"] = bus +def test_init_sensor_retries_when_device_missing(): + init_ns = _make_init_ns() + _FakeVL53L0X.script = [DeviceNotFoundError("no device"), None] init_ns.ns["init_sensor"]() assert init_ns.status.calls == ["i2c_init", "no_device"] -def test_init_sensor_handles_init_err(init_ns, monkeypatch): - bus = _FakeBus( - scans=[[TOF_ADDRESS]], - mem_reads={_REG_MODEL_ID: bytes([_MODEL_ID_BOOTED])}, - ) - init_ns.ns["i2c"] = bus - - call = {"n": 0} - real_init = _FakeVL53L0X.__init__ - - def maybe_raise(self, *a, **kw): - call["n"] += 1 - if call["n"] == 1: - raise OSError("first attempt fails") - real_init(self, *a, **kw) - - monkeypatch.setattr(_FakeVL53L0X, "__init__", maybe_raise) +def test_init_sensor_handles_init_err(): + init_ns = _make_init_ns() + _FakeVL53L0X.script = [OSError("first attempt fails"), None] init_ns.ns["init_sensor"]() assert "init_err" in init_ns.status.calls -def test_init_sensor_handles_runtime_error_during_init(init_ns): - # RuntimeError (driver poll timeout) is also caught and routed to init_err. - bus = _FakeBus( - scans=[[TOF_ADDRESS], [TOF_ADDRESS]], - mem_reads={_REG_MODEL_ID: bytes([_MODEL_ID_BOOTED])}, - ) - init_ns.ns["i2c"] = bus - - call = {"n": 0} - - class _RTLOnce(_FakeVL53L0X): - def __init__(self, *a, **kw) -> None: - call["n"] += 1 - if call["n"] == 1: - raise RuntimeError("driver poll timeout") - super().__init__(*a, **kw) - - init_ns.ns["VL53L0X"] = _RTLOnce +def test_init_sensor_handles_runtime_error_during_init(): + init_ns = _make_init_ns() + _FakeVL53L0X.script = [RuntimeError("driver poll timeout"), None] init_ns.ns["init_sensor"]() assert "init_err" in init_ns.status.calls - - -def test_soft_reset_sensor_success(main_ns): - bus = _FakeBus( - scans=[[TOF_ADDRESS]], - mem_reads={_REG_MODEL_ID: bytes([_MODEL_ID_BOOTED])}, - ) - assert main_ns.ns["soft_reset_sensor"](bus) is True - # writes: (addr, 0xBF, b"\x00") then (addr, 0xBF, b"\x01") - regs = [w[1] for w in bus.writes] - assert regs == [0xBF, 0xBF] - - -def test_soft_reset_sensor_times_out(main_ns): - bus = _FakeBus( - scans=[[TOF_ADDRESS]], - mem_reads={_REG_MODEL_ID: b"\x00"}, # never reads booted - ) - assert main_ns.ns["soft_reset_sensor"](bus) is False - - -def test_soft_reset_sensor_oserror_returns_false(main_ns): - bus = _FakeBus(scans=[[TOF_ADDRESS]], write_raises_on={0xBF}) - assert main_ns.ns["soft_reset_sensor"](bus) is False - - -@pytest.fixture -def init_ns(main_ns): - """Inject the fake VL53L0X class into the main.py namespace.""" - main_ns.ns["VL53L0X"] = _FakeVL53L0X - return main_ns - - -class _FakeBus: - """Bus stub: scriptable `scan()` plus per-register `readfrom_mem` answers.""" - - def __init__(self, *, scans, mem_reads=None, write_raises_on=None) -> None: - self._scans = list(scans) - self._mem_reads = mem_reads or {} - self._write_raises_on = write_raises_on or set() - self.writes: list[tuple[int, int, bytes]] = [] - - def scan(self): - if len(self._scans) == 1: - return self._scans[0] - return self._scans.pop(0) - - def readfrom_mem(self, _addr, reg, _n): - return self._mem_reads.get(reg, b"\x00") - - def writeto_mem(self, _addr, reg, buf): - if reg in self._write_raises_on: - raise OSError("scripted write fail") - self.writes.append((_addr, reg, bytes(buf))) - - -class _FakeVL53L0X: - """VL53L0X stand-in: records timing-budget and start calls.""" - - def __init__(self, bus, *, skip_spad_info=False, interrupt_status_mask=0) -> None: - self.bus = bus - self.skip_spad_info = skip_spad_info - self.interrupt_status_mask = interrupt_status_mask - self.address = TOF_ADDRESS - self._budget = None - self._started = False - - def set_measurement_timing_budget(self, budget): - self._budget = budget - - def start(self): - self._started = True diff --git a/projects/distance-stream/tests/test_stream_recovery.py b/projects/distance-stream/tests/test_stream_recovery.py index ed6e424..618d6ad 100644 --- a/projects/distance-stream/tests/test_stream_recovery.py +++ b/projects/distance-stream/tests/test_stream_recovery.py @@ -1,7 +1,6 @@ """Host CPython integration tests for stream() recovery and out-of-range paths in distance-stream. -Loads stream() and its helpers out of main.py (see conftest.main_ns), drives -the loop with a scripted fake VL53L0X, and asserts the four safety-critical +Drives stream() with a scripted fake VL53L0X, and asserts the four safety-critical behaviors of the read-error branch plus the out-of-range gap branch: 1. stop() then start() are called, in that order, after a transient fault; @@ -13,92 +12,86 @@ so the next in-range sample restarts rather than blending across the gap. """ -import io -import json -from contextlib import redirect_stdout +import os +import pathlib +from collections import namedtuple -import pytest +from micropython_stubs.testing import StopLoopError, firmware_namespace, run_stream +from vl53l0x import DeviceNotFoundError +_FIRMWARE = pathlib.Path(__file__).parent.parent / "firmware" / "main.py" +_KEEP_FUNCS = {"emit", "stream", "init_sensor"} +Board = namedtuple("Board", ("name", "sda", "scl")) +_TEST_BOARD = Board(name="RP2040-Zero", sda=0, scl=1) -def test_read_err_calls_stop_then_start_in_order(main_ns): + +def _make_main_ns(): + """Create a fresh AST-loaded main.py namespace with fakes.""" + from smoothing import median + + return firmware_namespace( + _FIRMWARE, + _KEEP_FUNCS, + os=os, + namedtuple=namedtuple, + BOARD=_TEST_BOARD, + median=median, + VL53L0X=object, + DeviceNotFoundError=DeviceNotFoundError, + ) + + +def test_read_err_calls_stop_then_start_in_order(): + main_ns = _make_main_ns() tof = _FakeTof(script=[OSError]) - _run_stream(main_ns, tof) + run_stream(main_ns, tof) assert tof.calls == ["stop", "start"] -def test_read_err_resets_filter_state(main_ns): - # Three good 100s fill the window below SMOOTH_WINDOW, so each emits its raw - # value (100). After the OSError the window is cleared, so the next in-range - # sample (500) emits raw — no blending across the fault. +def test_read_err_resets_filter_state(): + main_ns = _make_main_ns() tof = _FakeTof(script=[100, 100, 100, OSError, 500]) - assert _distances(_run_stream(main_ns, tof)) == [100, 100, 100, 500] + assert _distances(run_stream(main_ns, tof)) == [100, 100, 100, 500] -def test_inner_stop_start_failure_is_swallowed(main_ns): - # tof.stop() raises during recovery; the inner try/except: pass must - # swallow it so the outer loop reaches the next read() and emits 200. +def test_inner_stop_start_failure_is_swallowed(): + main_ns = _make_main_ns() tof = _FakeTof(script=[OSError, 200], stop_raises=OSError) - assert _distances(_run_stream(main_ns, tof)) == [200] - # start() was short-circuited by stop()'s raise — that's the documented - # behavior of the inner try (stop then start, no separate try blocks). - assert tof.calls == ["stop"] + assert _distances(run_stream(main_ns, tof)) == [200] -def test_status_transitions_around_read_err(main_ns): +def test_status_transitions_around_read_err(): + main_ns = _make_main_ns() tof = _FakeTof(script=[OSError, 100]) - _run_stream(main_ns, tof) - # streaming on entry -> read_err on fault -> streaming after recovery. - # The subsequent successful read does not change status. + run_stream(main_ns, tof) assert main_ns.status.calls == ["streaming", "read_err", "streaming"] -def test_out_of_range_emits_null_and_clears_state(main_ns): - # 100 emits raw; 8190 (== OUT_OF_RANGE_MM) emits null and clears the window; - # 200 then emits raw, proving the gap broke the window instead of being - # blended in. +def test_out_of_range_emits_null_and_clears_state(): + main_ns = _make_main_ns() tof = _FakeTof(script=[100, 8190, 200]) - assert _distances(_run_stream(main_ns, tof)) == [100, None, 200] + assert _distances(run_stream(main_ns, tof)) == [100, None, 200] -def test_emits_raw_alongside_smoothed(main_ns): - # In-range samples carry the raw reading; out-of-range carries null raw. +def test_emits_raw_alongside_smoothed(): + main_ns = _make_main_ns() tof = _FakeTof(script=[100, 8190, 200]) - lines = _run_stream(main_ns, tof) + lines = run_stream(main_ns, tof) raw = [ln["distance_mm_raw"] for ln in lines if "distance_mm_raw" in ln] assert raw == [100, None, 200] -def _run_stream(main_ns, fake_tof): - """Drive stream() until the fake's script is exhausted; return parsed JSON lines.""" - stream = main_ns.ns["stream"] - buf = io.StringIO() - with redirect_stdout(buf), pytest.raises(_StopLoopError): - stream(fake_tof) - return [json.loads(line) for line in buf.getvalue().splitlines() if line.strip()] - - def _distances(lines): return [ln["distance_mm"] for ln in lines if "distance_mm" in ln] -class _StopLoopError(Exception): - """Sentinel exception used by the fake tof to escape the streaming loop. - - The loop's only `except` catches `(OSError, RuntimeError)`; raising any - other Exception subclass propagates and ends the test deterministically. - """ - - class _FakeTof: """Scripted VL53L0X stand-in. `script` is a list of items consumed in order on each read() call: - an int -> returned as the sample - an exception *class* (OSError / RuntimeError) -> raised - When the script is exhausted, read() raises _StopLoopError to end the loop. - - `stop_raises` optionally makes stop() raise during recovery, - exercising the inner try/except's bare pass. + When the script is exhausted, read() raises StopLoopError to end the loop. """ def __init__(self, script, *, stop_raises=None) -> None: @@ -108,7 +101,7 @@ def __init__(self, script, *, stop_raises=None) -> None: def read(self): if not self._script: - raise _StopLoopError + raise StopLoopError item = self._script.pop(0) if isinstance(item, type) and issubclass(item, BaseException): raise item("scripted") diff --git a/projects/gps/README.md b/projects/gps/README.md index 5187cdd..0a0568e 100644 --- a/projects/gps/README.md +++ b/projects/gps/README.md @@ -67,6 +67,9 @@ auto-reconnects if you unplug and replug the board. ## Wiring +> The authoritative pin map is the `BOARD` table in [firmware/main.py](firmware/main.py); +> the per-board diagrams below mirror it. + ### ATGM336H GPS module ``` diff --git a/projects/gps/firmware/main.py b/projects/gps/firmware/main.py index 88b2091..cfa6715 100644 --- a/projects/gps/firmware/main.py +++ b/projects/gps/firmware/main.py @@ -5,14 +5,29 @@ """ +import os import time +from collections import namedtuple import ujson from nmea import apply_parsed, build_utc_full, nmea_checksum_valid, parse_sentence -from atgm336h import connect +from atgm336h import GPS, DeviceNotFoundError from boot_status_led import status +# Per-chip pin map — the authoritative wiring for this project, plain GPIO +# numbers. uart_id selects the UART peripheral the driver opens; tx drives the +# GPS RX line, rx carries the NMEA stream. Filled per chip by os.uname().machine +# dispatch at import. +Board = namedtuple("Board", ("name", "uart_id", "tx", "rx")) +_machine = os.uname().machine +if "ESP32S3" in _machine: + BOARD = Board(name="ESP32-S3-Zero", uart_id=1, tx=17, rx=18) +elif "RP2350" in _machine: + BOARD = Board(name="RP2350", uart_id=0, tx=0, rx=1) +else: + BOARD = Board(name="RP2040-Zero", uart_id=0, tx=0, rx=1) + WINDOW_MS = 10_000 _POLL_SLEEP_MS = 10 _BOOT_PAUSE_MS = 300 @@ -64,20 +79,24 @@ def stream(gps: object) -> None: def main() -> None: """Run boot → UART init → stream. MicroPython entry point. - LED sequence: white → cyan (UART opening) → green (streaming). - On UART failure: cyan → magenta → white (retry). + LED sequence: white → blue (UART opening) → green (streaming). + On UART failure: magenta (no_device) or red (init_err), then retry. """ status.boot() time.sleep_ms(_BOOT_PAUSE_MS) + status.uart_init() while True: - status.i2c_init() try: - gps = connect() + gps = GPS(bus_id=BOARD.uart_id, tx=BOARD.tx, rx=BOARD.rx) + except DeviceNotFoundError: + status.no_device() + emit({"diag": "no_device"}) + time.sleep_ms(_INIT_ERR_PAUSE_MS) + continue except Exception: # noqa: BLE001 status.init_err() emit({"diag": "init_err"}) time.sleep_ms(_INIT_ERR_PAUSE_MS) - status.boot() continue stream(gps) diff --git a/projects/gps/pyproject.toml b/projects/gps/pyproject.toml index 2d96ba1..a1baf36 100644 --- a/projects/gps/pyproject.toml +++ b/projects/gps/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gps-stream" -version = "0.1.0" +version = "0.2.0" description = "ATGM336H NMEA → 10-second JSON windows → FastAPI/WebSocket → dashboard." readme = "README.md" requires-python = ">=3.12" @@ -13,5 +13,4 @@ dependencies = [ package = false [tool.uv.sources] -boot_status_led = { index = "internal" } -atgm336h = { index = "internal" } +micropython-boardfarm = { workspace = true } diff --git a/projects/gps/tests/conftest.py b/projects/gps/tests/conftest.py deleted file mode 100644 index 8e3d0f2..0000000 --- a/projects/gps/tests/conftest.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Host CPython pytest bootstrap for the gps project firmware. - -Exposes the `main_ns` fixture: an AST-loaded namespace with `emit`, `stream`, -`_run_window`, and `main` extracted from main.py, and with fakes for `time` -and `status` injected. Pure NMEA helpers live in nmea.py and are imported -directly in test_nmea.py; they are also injected into the exec namespace here -so that `_run_window` can resolve them at call time. -""" - -from __future__ import annotations - -import ast -import pathlib -import sys -from types import SimpleNamespace - -import machine -import neopixel -import pytest - -_HERE = pathlib.Path(__file__).parent.resolve() -_FIRMWARE = _HERE.parent / "firmware" / "main.py" - -# Add firmware/ to sys.path so `import nmea` works in test_nmea.py and here. -_FIRMWARE_DIR = str(_FIRMWARE.parent) -if _FIRMWARE_DIR not in sys.path: - sys.path.insert(0, _FIRMWARE_DIR) - -import nmea # noqa: E402 — must follow sys.path setup above - -_KEEP_FUNCS = { - "emit", - "stream", - "main", - "_run_window", -} - - -def _load_main_namespace(fake_time: object, fake_status: object) -> dict: - """Parse main.py and exec constants + key functions in a fresh namespace. - - Import nodes and the trailing ``main()`` call are dropped so the loader - does not block in the streaming loop. Callers seed ``time`` and ``status`` - substitutes; nmea helpers are injected so ``_run_window`` can resolve them. - """ - src = _FIRMWARE.read_text() - tree = ast.parse(src) - kept = [ - node - for node in tree.body - if isinstance(node, ast.Assign) - or (isinstance(node, ast.FunctionDef) and node.name in _KEEP_FUNCS) - ] - module = ast.Module(body=kept, type_ignores=[]) - ast.fix_missing_locations(module) - code = compile(module, str(_FIRMWARE), "exec") - - import ujson - - ns: dict = { - "time": fake_time, - "status": fake_status, - "ujson": ujson, - # Inject nmea helpers so _run_window can resolve them without the - # `from nmea import ...` statement that the AST loader strips. - "nmea_checksum_valid": nmea.nmea_checksum_valid, - "parse_sentence": nmea.parse_sentence, - "apply_parsed": nmea.apply_parsed, - "build_utc_full": nmea.build_utc_full, - } - exec(code, ns) - return ns - - -@pytest.fixture(autouse=True) -def _reset_devices() -> None: - """Clear machine and neopixel state between tests.""" - machine.reset() - neopixel.reset() - - -@pytest.fixture -def main_ns(fake_time: object, fake_status: object) -> SimpleNamespace: - """Fresh AST-loaded main.py namespace with fakes injected. - - Returns a SimpleNamespace with: - - .ns: dict of module-level names (pull stream, emit, …) - - .time: the _FakeTime instance used as the `time` module - - .status: the _FakeStatus instance; inspect .calls for transitions - """ - ns = _load_main_namespace(fake_time, fake_status) - return SimpleNamespace(ns=ns, time=fake_time, status=fake_status) diff --git a/projects/gps/tests/test_nmea.py b/projects/gps/tests/test_nmea.py index 61e8252..983e442 100644 --- a/projects/gps/tests/test_nmea.py +++ b/projects/gps/tests/test_nmea.py @@ -5,8 +5,15 @@ from __future__ import annotations -import nmea -import pytest +import pathlib +import sys + +_FIRMWARE_DIR = str(pathlib.Path(__file__).parent.parent / "firmware") +if _FIRMWARE_DIR not in sys.path: + sys.path.insert(0, _FIRMWARE_DIR) + +import nmea # noqa: E402 +import pytest # noqa: E402 # --------------------------------------------------------------------------- # Shared sentence fixtures diff --git a/projects/gps/tests/test_stream.py b/projects/gps/tests/test_stream.py index 3f78825..90af753 100644 --- a/projects/gps/tests/test_stream.py +++ b/projects/gps/tests/test_stream.py @@ -14,18 +14,50 @@ from __future__ import annotations +import os +import pathlib +import sys +from collections import namedtuple + import pytest -# NMEA sentences used across tests. +from micropython_stubs.testing import firmware_namespace + +_HERE = pathlib.Path(__file__).parent.resolve() +_FIRMWARE = _HERE.parent / "firmware" / "main.py" + +_FIRMWARE_DIR = str(_FIRMWARE.parent) +if _FIRMWARE_DIR not in sys.path: + sys.path.insert(0, _FIRMWARE_DIR) + +import nmea # noqa: E402 + +_KEEP_FUNCS = {"emit", "stream", "main", "_run_window"} +Board = namedtuple("Board", ("name", "uart_id", "tx", "rx")) +_TEST_BOARD = Board(name="RP2040-Zero", uart_id=0, tx=0, rx=1) + _GPGGA = "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47" _GPGSA = "$GPGSA,A,3,01,02,03,04,05,06,07,08,09,10,11,12,2.0,1.0,1.8*3B" _GPGSV = "$GPGSV,3,1,09,01,40,083,46,02,17,308,41,12,07,344,39,14,22,228,45*75" -# Override WINDOW_MS to this small value so the inner loop exits after ~2 -# ticks_ms() calls (fake_time steps by 1; 2 steps push diff past the threshold). _TEST_WINDOW_MS = 2 +def _make_main_ns(): + """Create a fresh AST-loaded main.py namespace with fakes.""" + return firmware_namespace( + _FIRMWARE, + _KEEP_FUNCS, + os=os, + namedtuple=namedtuple, + BOARD=_TEST_BOARD, + nmea_checksum_valid=nmea.nmea_checksum_valid, + parse_sentence=nmea.parse_sentence, + apply_parsed=nmea.apply_parsed, + build_utc_full=nmea.build_utc_full, + ) + + class _StopLoopError(BaseException): """Sentinel that escapes stream()'s `except Exception` guard.""" @@ -67,12 +99,7 @@ def readline(self) -> str | None: def _run(main_ns: object, sentences: list[str], stop_after: int = 1) -> list[dict]: - """Exercise stream() for `stop_after` emit() calls and return the recorded objects. - - Overrides WINDOW_MS to _TEST_WINDOW_MS, replaces emit() with a _CapturingEmit, - passes a _FakeGPS directly to stream(), then absorbs _StopLoopError via - pytest.raises. - """ + """Exercise stream() for `stop_after` emit() calls and return the recorded objects.""" stream = main_ns.ns["stream"] cap = _CapturingEmit(stop_after) main_ns.ns["emit"] = cap @@ -82,14 +109,16 @@ def _run(main_ns: object, sentences: list[str], stop_after: int = 1) -> list[dic return cap.calls -def test_stream_emits_parsed_position(main_ns: object) -> None: +def test_stream_emits_parsed_position() -> None: + main_ns = _make_main_ns() calls = _run(main_ns, [_GPGGA]) assert calls[0]["lat"] is not None assert calls[0]["lon"] is not None assert "window_ms" in calls[0] -def test_stream_batch_parses_multiple_sentence_types(main_ns: object) -> None: +def test_stream_batch_parses_multiple_sentence_types() -> None: + main_ns = _make_main_ns() main_ns.ns["WINDOW_MS"] = 20 cap = _CapturingEmit(1) main_ns.ns["emit"] = cap @@ -99,12 +128,14 @@ def test_stream_batch_parses_multiple_sentence_types(main_ns: object) -> None: assert cap.calls[0]["hdop"] is not None -def test_stream_emits_no_data_when_gps_silent(main_ns: object) -> None: +def test_stream_emits_no_data_when_gps_silent() -> None: + main_ns = _make_main_ns() calls = _run(main_ns, []) assert calls[0]["diag"] == "no_data" -def test_stream_batch_has_required_keys(main_ns: object) -> None: +def test_stream_batch_has_required_keys() -> None: + main_ns = _make_main_ns() calls = _run(main_ns, [_GPGGA]) batch = calls[0] assert { @@ -120,8 +151,9 @@ def test_stream_batch_has_required_keys(main_ns: object) -> None: } <= set(batch) -def test_stream_read_err_calls_status_read_err(main_ns: object) -> None: +def test_stream_read_err_calls_status_read_err() -> None: """OSError from readline() is caught; status.read_err() fires and stream recovers.""" + main_ns = _make_main_ns() class _OsErrorOnFirstCall(_FakeGPS): def __init__(self) -> None: @@ -135,7 +167,7 @@ def readline(self) -> str | None: return None main_ns.ns["WINDOW_MS"] = _TEST_WINDOW_MS - cap = _CapturingEmit(2) # 1st call = read_err diag; 2nd = no_data after recovery + cap = _CapturingEmit(2) main_ns.ns["emit"] = cap with pytest.raises(_StopLoopError): main_ns.ns["stream"](_OsErrorOnFirstCall()) @@ -145,8 +177,9 @@ def readline(self) -> str | None: assert "read_err" in main_ns.status.calls -def test_stream_recovers_and_continues_after_read_err(main_ns: object) -> None: +def test_stream_recovers_and_continues_after_read_err() -> None: """After a read_err, status returns to streaming and the loop continues.""" + main_ns = _make_main_ns() class _FailThenRecover(_FakeGPS): def __init__(self) -> None: diff --git a/projects/gyro-stream/README.md b/projects/gyro-stream/README.md index 46bb8f3..0ccfd27 100644 --- a/projects/gyro-stream/README.md +++ b/projects/gyro-stream/README.md @@ -65,6 +65,9 @@ auto-reconnects if you unplug and replug the board. ## Wiring +> The authoritative pin map is the `BOARD` table in [firmware/main.py](firmware/main.py); +> the per-board diagrams below mirror it. + ### MPU6050 IMU ``` diff --git a/projects/gyro-stream/firmware/main.py b/projects/gyro-stream/firmware/main.py index 5f24f58..c40e5ad 100644 --- a/projects/gyro-stream/firmware/main.py +++ b/projects/gyro-stream/firmware/main.py @@ -4,21 +4,32 @@ 0x68 (or 0x69 if AD0 is tied to 3V3), then streams accel + gyro + temp samples as one-JSON-per-line on the serial port at ~100 Hz. -Chip-agnostic: all hardware-specific behaviour lives in the package -backends (boot_status_led, i2c_bus, mpu6050). +Pin assignments live in this module's BOARD table (dispatched per chip by +os.uname().machine); chip-specific *behaviour* — including which I²C bus the +sensor opens and its 0x68/0x69 address probe — stays in the packages +(boot_status_led, mpu6050). """ +import os import time +from collections import namedtuple import ujson from boot_status_led import status -from i2c_bus import hard_i2c as i2c -from mpu6050 import MPU6050 - -# AD0=GND/floating → 0x68; AD0=3V3 → 0x69. We try both at boot. -PRIMARY_ADDRESS = 0x68 -SECONDARY_ADDRESS = 0x69 +from mpu6050 import MPU6050, DeviceNotFoundError + +# Per-chip pin map — the authoritative wiring for this project, plain GPIO +# numbers. i2c_id selects the hardware I²C peripheral the driver opens. Filled +# per chip by os.uname().machine dispatch at import. +Board = namedtuple("Board", ("name", "i2c_id", "sda", "scl")) +_machine = os.uname().machine +if "ESP32S3" in _machine: + BOARD = Board(name="ESP32-S3-Zero", i2c_id=0, sda=1, scl=2) +elif "RP2350" in _machine: + BOARD = Board(name="RP2350", i2c_id=0, sda=0, scl=1) +else: + BOARD = Board(name="RP2040-Zero", i2c_id=0, sda=0, scl=1) # ~100 Hz polling. The MPU6050 is poll-driven (no INT line wired), so # the loop sets the cadence via sleep_ms — distance-stream's self-paced @@ -42,31 +53,27 @@ def emit(obj: dict) -> None: def init_sensor() -> MPU6050: - """Scan the bus and initialise the MPU6050, retrying until it comes up. + """Open the bus and initialise the MPU6050, retrying until it comes up. + + The driver opens its own bus from BOARD pins and auto-detects the address + (0x68 with AD0=GND/floating, else 0x69). Parks at status.no_device() when + neither address responds (DeviceNotFoundError), and at status.init_err() when + WHO_AM_I or a config write raises. Both states retry every _RETRY_PAUSE_MS. - Tries 0x68 first (AD0=GND/floating), falls back to 0x69 (AD0=3V3). - Parks at status.no_device() when neither address responds, and at - status.init_err() when WHO_AM_I or a config write raises. Both states - retry every _RETRY_PAUSE_MS. + Returns: + An initialised MPU6050 driver bound to the bus it opened. """ status.i2c_init() while True: try: - devices = i2c.scan() - emit({"diag": "scan", "devices": devices}) - if PRIMARY_ADDRESS in devices: - addr = PRIMARY_ADDRESS - elif SECONDARY_ADDRESS in devices: - addr = SECONDARY_ADDRESS - else: - # Bus reachable but no device responded. - # Check SDA/SCL wiring, 3V3 power, GND, pull-ups. - status.no_device() - emit({"diag": "no_device", "devices": devices}) - time.sleep_ms(_RETRY_PAUSE_MS) - continue - imu = MPU6050(i2c, addr=addr) - emit({"diag": "imu_ok", "addr": addr, "kind": imu.kind}) + imu = MPU6050(bus_id=BOARD.i2c_id, sda=BOARD.sda, scl=BOARD.scl) + emit({"diag": "imu_ok", "addr": imu.addr, "kind": imu.kind}) + except DeviceNotFoundError as e: + # Bus reachable but no device responded at 0x68/0x69. + # Check SDA/SCL wiring, 3V3 power, GND, pull-ups. + status.no_device() + emit({"diag": "no_device", "err": str(e)}) + time.sleep_ms(_RETRY_PAUSE_MS) except OSError as e: # Device ACKed at 0x68/0x69 but WHO_AM_I or a config write # failed. Likely a counterfeit chip or a bus glitch. diff --git a/projects/gyro-stream/pyproject.toml b/projects/gyro-stream/pyproject.toml index 9cff362..f0906b4 100644 --- a/projects/gyro-stream/pyproject.toml +++ b/projects/gyro-stream/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gyro-stream" -version = "0.3.0" +version = "0.4.0" description = "MPU6050 accel/gyro/temp → JSON-over-USB-CDC → FastAPI/WebSocket → Plotly + 3D orientation dashboard." readme = "README.md" requires-python = ">=3.12" @@ -14,6 +14,4 @@ dependencies = [ package = false [tool.uv.sources] -boot_status_led = { index = "internal" } -i2c_bus = { index = "internal" } -mpu6050 = { index = "internal" } +micropython-boardfarm = { workspace = true } diff --git a/projects/gyro-stream/tests/conftest.py b/projects/gyro-stream/tests/conftest.py deleted file mode 100644 index 3f48bc5..0000000 --- a/projects/gyro-stream/tests/conftest.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Host CPython pytest bootstrap for the gyro-stream firmware. - -Exposes the `main_ns` fixture: an AST-loaded namespace containing main.py's -constants, emit(), init_sensor(), and stream(), with fakes for time and -status side effects so the streaming loop is exercisable in tests without -the module's top-level main() call running. -""" - -import ast -import pathlib -from types import SimpleNamespace - -import pytest - -_HERE = pathlib.Path(__file__).parent.resolve() -_FIRMWARE = _HERE.parent / "firmware" / "main.py" -_KEEP_FUNCS = {"emit", "init_sensor", "stream"} - - -def _load_main_namespace(fake_time, fake_status): - """Parse main.py and exec the constants + key functions in a fresh namespace. - - Keeps only module-level Assign nodes (constants) and the FunctionDefs the - tests need. Drops Import nodes (the caller seeds substitutes for `time`, - `status`, `ujson`) and the trailing Expr statement that calls main() — - which would otherwise block in the streaming loop on import. - """ - src = _FIRMWARE.read_text() - tree = ast.parse(src) - kept = [ - node - for node in tree.body - if isinstance(node, ast.Assign) - or (isinstance(node, ast.FunctionDef) and node.name in _KEEP_FUNCS) - ] - module = ast.Module(body=kept, type_ignores=[]) - ast.fix_missing_locations(module) - code = compile(module, str(_FIRMWARE), "exec") - - import ujson - - ns: dict = { - "time": fake_time, - "status": fake_status, - "ujson": ujson, - # init_sensor() does `MPU6050(i2c, addr=...)`; the annotation in - # stream(imu: MPU6050) is evaluated at def time, so the name must - # resolve. Tests override these as needed. - "MPU6050": object, - } - exec(code, ns) - return ns - - -@pytest.fixture -def main_ns(fake_time, fake_status): - """Fresh AST-loaded main.py namespace with fakes injected. - - Returns a SimpleNamespace with: - - .ns: dict of module-level names (pull stream, emit, init_sensor, ...) - - .time: the _FakeTime instance used as the `time` module - - .status: the _FakeStatus instance; inspect .status.calls for transitions - """ - ns = _load_main_namespace(fake_time, fake_status) - return SimpleNamespace(ns=ns, time=fake_time, status=fake_status) diff --git a/projects/gyro-stream/tests/test_emit_schema.py b/projects/gyro-stream/tests/test_emit_schema.py deleted file mode 100644 index d56f0e6..0000000 --- a/projects/gyro-stream/tests/test_emit_schema.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Host CPython pytest checks for JSON schema invariants of gyro-stream's `emit()`. - -Asserts the 8-key IMU sample dict round-trips and the diag namespace -(including the edge-triggered "sat" event) survives ujson.dumps. The -viz parser at projects/gyro-stream/viz/app.py drops non-JSON lines, so -a regression here silently breaks the dashboard. -""" - -import io -import json -from contextlib import redirect_stdout - - -def test_emit_sample_dict(main_ns): - emit = main_ns.ns["emit"] - sample = { - "t": 100, - "ax": 0.01, - "ay": -0.02, - "az": 0.99, - "gx": 0.1, - "gy": -0.05, - "gz": 0.0, - "T": 24.7, - } - assert _run(emit, sample) == sample - - -def test_emit_saturation_diag(main_ns): - emit = main_ns.ns["emit"] - assert _run(emit, {"diag": "sat"}) == {"diag": "sat"} - - -def test_emit_diag_lines_are_valid_json(main_ns): - emit = main_ns.ns["emit"] - parsed = _run(emit, {"diag": "scan", "devices": [0x68]}) - assert parsed["diag"] == "scan" - assert parsed["devices"] == [104] - - -def _run(emit, obj): - buf = io.StringIO() - with redirect_stdout(buf): - emit(obj) - line = buf.getvalue().strip() - return json.loads(line) diff --git a/projects/gyro-stream/tests/test_full_import.py b/projects/gyro-stream/tests/test_full_import.py index d3b259a..829a70d 100644 --- a/projects/gyro-stream/tests/test_full_import.py +++ b/projects/gyro-stream/tests/test_full_import.py @@ -8,6 +8,7 @@ import importlib.util import io import json +import os import pathlib import sys from contextlib import redirect_stdout @@ -15,11 +16,21 @@ import pytest +from micropython_stubs.testing import ( + BOARD_CHIPS, + DeviceNotFoundError, + FakeStatus, + build_full_import_stubs, +) + _FIRMWARE = pathlib.Path(__file__).parent.parent / "firmware" / "main.py" PRIMARY = 0x68 -def test_main_executes_init_then_streams_one_sample(monkeypatch, fake_status): +@pytest.mark.parametrize("machine_str,board_name", BOARD_CHIPS) +def test_main_executes_init_then_streams_one_sample(monkeypatch, machine_str, board_name): + fake_status = FakeStatus() + monkeypatch.setattr(os, "uname", lambda: SimpleNamespace(machine=machine_str)) for name, module in _build_stubs(fake_status).items(): monkeypatch.setitem(sys.modules, name, module) monkeypatch.delitem(sys.modules, "main", raising=False) @@ -31,9 +42,9 @@ def test_main_executes_init_then_streams_one_sample(monkeypatch, fake_status): with redirect_stdout(buf), pytest.raises(_StopMainError): spec.loader.exec_module(module) + assert module.BOARD.name == board_name lines = [json.loads(ln) for ln in buf.getvalue().splitlines() if ln.strip()] diags = [ln.get("diag") for ln in lines if "diag" in ln] - assert "scan" in diags assert "imu_ok" in diags assert any("ax" in ln for ln in lines) @@ -42,19 +53,11 @@ class _StopMainError(Exception): """Raised by the fake IMU on the second read_all() to escape stream().""" -class _Bus: - """Minimal I²C bus stub: scans to the MPU6050 primary address.""" - - @staticmethod - def scan() -> list[int]: - return [PRIMARY] - - class _FakeIMU: - """Stub MPU6050; second read_all() raises to escape stream().""" + """Stub MPU6050 that opens its own bus; second read_all() raises to escape stream().""" - def __init__(self, _bus, *, addr) -> None: - self.addr = addr + def __init__(self, *, sda, scl, bus_id=0) -> None: + self.addr = PRIMARY self.kind = "MPU6050" self.last_saturated = False self._calls = 0 @@ -67,19 +70,8 @@ def read_all(self): def _build_stubs(status_stub): - time_stub = SimpleNamespace( - sleep_ms=lambda _ms: None, - ticks_ms=lambda: 0, - ) - boot_status_led_stub = SimpleNamespace(status=status_stub) - i2c_bus_stub = SimpleNamespace(hard_i2c=_Bus()) - mpu6050_stub = SimpleNamespace(MPU6050=_FakeIMU) - - return { - "time": time_stub, - "ujson": __import__("json"), - "boot_status_led": boot_status_led_stub, - "boot_status_led.status": status_stub, - "i2c_bus": i2c_bus_stub, - "mpu6050": mpu6050_stub, - } + # main() now builds MPU6050(id=, sda=, scl=) directly — the driver owns the + # bus and the address probe — so the project no longer imports i2c_bus; the + # mpu6050 stub exposes the driver class and its DeviceNotFoundError. + mpu6050_stub = SimpleNamespace(MPU6050=_FakeIMU, DeviceNotFoundError=DeviceNotFoundError) + return build_full_import_stubs("mpu6050", mpu6050_stub, status_stub) diff --git a/projects/gyro-stream/tests/test_init_sensor.py b/projects/gyro-stream/tests/test_init_sensor.py index f0ce7ee..e9d5f25 100644 --- a/projects/gyro-stream/tests/test_init_sensor.py +++ b/projects/gyro-stream/tests/test_init_sensor.py @@ -1,75 +1,69 @@ """Host CPython pytest tests for init_sensor in gyro-stream firmware. -Drives happy path at 0x68, AD0-high fallback to 0x69, the no_device -retry, and the init_err (OSError) retry. +The driver opens its own bus and auto-detects the address, so init_sensor() +takes no arguments and constructs MPU6050(bus_id=, sda=, scl=) from BOARD. A fake +driver class drives the happy path, the no_device retry (DeviceNotFoundError), and +the init_err (OSError) retry. The 0x68/0x69 address probe now lives in the +driver, so it is covered by the mpu6050 package tests, not here. """ -import pytest +import os +import pathlib +from collections import namedtuple +from typing import ClassVar +from micropython_stubs.testing import ScriptedFake, firmware_namespace +from mpu6050 import DeviceNotFoundError + +_FIRMWARE = pathlib.Path(__file__).parent.parent / "firmware" / "main.py" +_KEEP_FUNCS = {"emit", "init_sensor", "stream"} +Board = namedtuple("Board", ("name", "i2c_id", "sda", "scl")) +_TEST_BOARD = Board(name="RP2040-Zero", i2c_id=0, sda=0, scl=1) PRIMARY = 0x68 -SECONDARY = 0x69 -def test_init_sensor_primary_address(init_ns): - init_ns.ns["i2c"] = _FakeBus(scans=[[PRIMARY]]) - imu = init_ns.ns["init_sensor"]() - assert imu.addr == PRIMARY - assert init_ns.status.calls == ["i2c_init"] +class _FakeIMU(ScriptedFake): + """MPU6050 stand-in (see ScriptedFake): records addr/kind/saturation on success.""" + script: ClassVar[list] = [] + + def __init__(self, *, sda, scl, bus_id=0) -> None: + super().__init__() + self.addr = PRIMARY + self.kind = "MPU6050" + self.last_saturated = False -def test_init_sensor_falls_back_to_secondary(init_ns): - # AD0 tied high → only 0x69 responds; init_sensor must use it. - init_ns.ns["i2c"] = _FakeBus(scans=[[SECONDARY]]) + +def _make_init_ns(): + """Create AST-loaded namespace with _FakeIMU injected.""" + _FakeIMU.script = [] + return firmware_namespace( + _FIRMWARE, + _KEEP_FUNCS, + os=os, + namedtuple=namedtuple, + BOARD=_TEST_BOARD, + MPU6050=_FakeIMU, + DeviceNotFoundError=DeviceNotFoundError, + ) + + +def test_init_sensor_happy_path(): + init_ns = _make_init_ns() imu = init_ns.ns["init_sensor"]() - assert imu.addr == SECONDARY + assert imu.addr == PRIMARY + assert init_ns.status.calls == ["i2c_init"] -def test_init_sensor_retries_when_device_missing(init_ns): - init_ns.ns["i2c"] = _FakeBus(scans=[[], [PRIMARY]]) +def test_init_sensor_retries_when_device_missing(): + init_ns = _make_init_ns() + _FakeIMU.script = [DeviceNotFoundError("no device"), None] init_ns.ns["init_sensor"]() assert init_ns.status.calls == ["i2c_init", "no_device"] -def test_init_sensor_handles_init_err(init_ns): - _FakeIMU.raise_oserror_once = True - init_ns.ns["i2c"] = _FakeBus(scans=[[PRIMARY], [PRIMARY]]) +def test_init_sensor_handles_init_err(): + init_ns = _make_init_ns() + _FakeIMU.script = [OSError("scripted WHO_AM_I fail"), None] init_ns.ns["init_sensor"]() assert "init_err" in init_ns.status.calls - - -@pytest.fixture(autouse=True) -def _reset_imu(): - _FakeIMU.raise_oserror_once = False - _FakeIMU._calls = 0 - - -@pytest.fixture -def init_ns(main_ns): - main_ns.ns["MPU6050"] = _FakeIMU - return main_ns - - -class _FakeBus: - def __init__(self, *, scans) -> None: - self._scans = list(scans) - - def scan(self): - if len(self._scans) == 1: - return self._scans[0] - return self._scans.pop(0) - - -class _FakeIMU: - """MPU6050 stand-in: records addr + kind, optionally raises on first init.""" - - raise_oserror_once = False - _calls = 0 - - def __init__(self, bus, *, addr) -> None: - type(self)._calls += 1 - if type(self).raise_oserror_once and type(self)._calls == 1: - raise OSError("scripted WHO_AM_I fail") - self.bus = bus - self.addr = addr - self.kind = "MPU6050" - self.last_saturated = False diff --git a/projects/gyro-stream/tests/test_stream.py b/projects/gyro-stream/tests/test_stream.py index fb0532f..e98c74e 100644 --- a/projects/gyro-stream/tests/test_stream.py +++ b/projects/gyro-stream/tests/test_stream.py @@ -4,73 +4,82 @@ rising edges of last_saturated), and read_err → streaming recovery. """ -import io -import json -from contextlib import redirect_stdout - -import pytest +import os +import pathlib +from collections import namedtuple + +from micropython_stubs.testing import ( + StopLoopError, + diags, + firmware_namespace, + run_stream, + samples, +) +from mpu6050 import DeviceNotFoundError + +_FIRMWARE = pathlib.Path(__file__).parent.parent / "firmware" / "main.py" +_KEEP_FUNCS = {"emit", "init_sensor", "stream"} +Board = namedtuple("Board", ("name", "i2c_id", "sda", "scl")) +_TEST_BOARD = Board(name="RP2040-Zero", i2c_id=0, sda=0, scl=1) _OK = (0.01, -0.02, 0.99, 0.1, -0.05, 0.0, 24.7) -def test_one_sample_per_loop_with_full_8_keys(main_ns): +def _make_main_ns(): + """Create a fresh AST-loaded main.py namespace with fakes.""" + return firmware_namespace( + _FIRMWARE, + _KEEP_FUNCS, + os=os, + namedtuple=namedtuple, + BOARD=_TEST_BOARD, + MPU6050=object, + DeviceNotFoundError=DeviceNotFoundError, + ) + + +def test_one_sample_per_loop_with_full_8_keys(): + main_ns = _make_main_ns() imu = _FakeIMU(script=[_OK]) - samples = _samples(_run_stream(main_ns, imu)) - assert len(samples) == 1 - assert set(samples[0]) == {"t", "ax", "ay", "az", "gx", "gy", "gz", "T"} + sample_lines = samples(run_stream(main_ns, imu)) + assert len(sample_lines) == 1 + assert set(sample_lines[0]) == {"t", "ax", "ay", "az", "gx", "gy", "gz", "T"} -def test_saturation_edge_triggers_once(main_ns): +def test_saturation_edge_triggers_once(): """Three sat-true reads emit exactly one {"diag": "sat"} (rising edge only).""" + main_ns = _make_main_ns() imu = _FakeIMU(script=[_OK, _OK, _OK], sat_script=[True, True, True]) - lines = _run_stream(main_ns, imu) - assert _diags(lines).count("sat") == 1 + lines = run_stream(main_ns, imu) + assert diags(lines).count("sat") == 1 -def test_saturation_falling_edge_emits_nothing(main_ns): +def test_saturation_falling_edge_emits_nothing(): """sat: True → False → True emits two sat events (two rising edges).""" + main_ns = _make_main_ns() imu = _FakeIMU( script=[_OK, _OK, _OK, _OK], sat_script=[True, False, True, False], ) - lines = _run_stream(main_ns, imu) - assert _diags(lines).count("sat") == 2 + lines = run_stream(main_ns, imu) + assert diags(lines).count("sat") == 2 -def test_read_err_recovery_resumes_streaming(main_ns): +def test_read_err_recovery_resumes_streaming(): + main_ns = _make_main_ns() imu = _FakeIMU(script=[_OK, OSError, _OK]) - lines = _run_stream(main_ns, imu) - assert _samples(lines) and len(_samples(lines)) == 2 - assert "read_err" in _diags(lines) + lines = run_stream(main_ns, imu) + assert samples(lines) and len(samples(lines)) == 2 + assert "read_err" in diags(lines) assert main_ns.status.calls == ["streaming", "read_err", "streaming"] -def _run_stream(main_ns, imu): - stream = main_ns.ns["stream"] - buf = io.StringIO() - with redirect_stdout(buf), pytest.raises(_StopLoopError): - stream(imu) - return [json.loads(ln) for ln in buf.getvalue().splitlines() if ln.strip()] - - -def _samples(lines): - return [ln for ln in lines if "diag" not in ln] - - -def _diags(lines): - return [ln["diag"] for ln in lines if "diag" in ln] - - -class _StopLoopError(Exception): - """Sentinel: any non-OSError raised by the fake IMU escapes the loop.""" - - class _FakeIMU: """Scripted MPU6050. `script` items: 7-tuple = read_all() return; exception class = raise. `sat_script` is consumed in lockstep — each entry sets last_saturated - *after* the read returns. Exhausting `script` raises _StopLoopError. + *after* the read returns. Exhausting `script` raises StopLoopError. """ def __init__(self, script, sat_script=None) -> None: @@ -80,7 +89,7 @@ def __init__(self, script, sat_script=None) -> None: def read_all(self): if not self._script: - raise _StopLoopError + raise StopLoopError item = self._script.pop(0) sat = self._sat.pop(0) if self._sat else False if isinstance(item, type) and issubclass(item, BaseException): diff --git a/projects/multizone-ranging/README.md b/projects/multizone-ranging/README.md index d6d28b4..bf974da 100644 --- a/projects/multizone-ranging/README.md +++ b/projects/multizone-ranging/README.md @@ -68,6 +68,9 @@ real time. ## Wiring +> The authoritative pin map is the `BOARD` table in [firmware/main.py](firmware/main.py); +> the per-board diagrams below mirror it. + ### VL53L5CX ToF sensor ``` diff --git a/projects/multizone-ranging/firmware/main.py b/projects/multizone-ranging/firmware/main.py index 32681d3..d9be18e 100644 --- a/projects/multizone-ranging/firmware/main.py +++ b/projects/multizone-ranging/firmware/main.py @@ -1,14 +1,26 @@ """MCU-micropython firmware for multizone-ranging: I²C scan, VL53L5CX init, 8x8 JSON stream.""" +import os import time +from collections import namedtuple import ujson from boot_status_led import status -from i2c_bus import soft_i2c as i2c -from vl53l5cx import VL53L5CX +from vl53l5cx import VL53L5CX, DeviceNotFoundError + +# Per-chip pin map — the authoritative wiring for this project, plain GPIO +# numbers. The VL53L5CX opens a bit-banged soft I²C bus internally, so no +# peripheral id is needed. Filled per chip by os.uname().machine dispatch. +Board = namedtuple("Board", ("name", "sda", "scl")) +_machine = os.uname().machine +if "ESP32S3" in _machine: + BOARD = Board(name="ESP32-S3-Zero", sda=1, scl=2) +elif "RP2350" in _machine: + BOARD = Board(name="RP2350", sda=0, scl=1) +else: + BOARD = Board(name="RP2040-Zero", sda=0, scl=1) -_TOF_ADDRESS = 0x29 # 8x8 hardware maximum. The VL53L5CX caps 8x8 ranging at 15 Hz; the read loop # emits each grid as soon as the sensor flags it ready, so this sets the # end-to-end frame rate. Soft I²C is required: the sensor clock-stretches @@ -36,27 +48,28 @@ def emit(obj: dict) -> None: def init_sensor() -> VL53L5CX: - """Scan the I²C bus and initialise the VL53L5CX, retrying until it comes up. + """Open the bus and initialise the VL53L5CX, retrying until it comes up. - Loads ~86.5 KB of ST firmware into the sensor over soft I²C (~7-9 s at 100 kHz). - Parks at status.no_device() when 0x29 is absent, or status.init_err() when - the device ACKs but driver init raises. + The driver opens its own soft I²C bus from BOARD pins and scans; init() then + loads ~86.5 KB of ST firmware into the sensor (~7-9 s at 100 kHz). Parks at + status.no_device() when 0x29 is absent (DeviceNotFoundError), or status.init_err() + when the device ACKs but driver init raises. + + Returns: + An initialised VL53L5CX driver in 8x8 ranging mode. """ status.i2c_init() while True: try: - devices = i2c.scan() - emit({"diag": "scan", "devices": devices}) - if _TOF_ADDRESS not in devices: - status.no_device() - emit({"diag": "no_device", "devices": devices}) - time.sleep_ms(_RETRY_PAUSE_MS) - continue - tof = VL53L5CX(i2c) + tof = VL53L5CX(sda=BOARD.sda, scl=BOARD.scl) emit({"diag": "firmware_loading"}) tof.init() tof.start(_RANGING_FREQ_HZ) - emit({"diag": "vl53l5cx_ok", "addr": _TOF_ADDRESS}) + emit({"diag": "vl53l5cx_ok", "addr": tof.addr}) + except DeviceNotFoundError as e: + status.no_device() + emit({"diag": "no_device", "err": str(e)}) + time.sleep_ms(_RETRY_PAUSE_MS) except (OSError, RuntimeError, ValueError) as err: status.init_err() emit({"diag": "init_err", "err": str(err)}) diff --git a/projects/multizone-ranging/pyproject.toml b/projects/multizone-ranging/pyproject.toml index 0979204..8f6f480 100644 --- a/projects/multizone-ranging/pyproject.toml +++ b/projects/multizone-ranging/pyproject.toml @@ -1,6 +1,12 @@ [project] name = "multizone-ranging" -version = "0.2.0" +version = "0.3.0" description = "VL53L5CX 8×8 multizone ToF firmware streaming distance grids over serial JSON." requires-python = ">=3.12" dependencies = ["micropython-boardfarm>=0.5.0"] + +[tool.uv] +package = false + +[tool.uv.sources] +micropython-boardfarm = { workspace = true } diff --git a/projects/multizone-ranging/tests/conftest.py b/projects/multizone-ranging/tests/conftest.py deleted file mode 100644 index 30bbc0f..0000000 --- a/projects/multizone-ranging/tests/conftest.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Host CPython pytest bootstrap for the multizone-ranging firmware. - -Exposes the `main_ns` fixture: an AST-loaded namespace containing main.py's -constants, emit(), init_sensor(), and stream(), with fakes for time and -status side effects so the streaming loop is exercisable in tests without -the module's top-level main() call running. -""" - -import ast -import pathlib -from types import SimpleNamespace - -import pytest - -_HERE = pathlib.Path(__file__).parent.resolve() -_FIRMWARE = _HERE.parent / "firmware" / "main.py" -_KEEP_FUNCS = {"emit", "stream", "init_sensor"} - - -def _load_main_namespace(fake_time, fake_status) -> dict: - """Parse main.py and exec the constants + key functions in a fresh namespace. - - Keeps only module-level Assign nodes (constants) and the FunctionDefs the - tests need. Drops Import nodes and the trailing Expr statement that calls - main() — which would otherwise block in the ranging loop on import. - """ - src = _FIRMWARE.read_text() - tree = ast.parse(src) - kept = [ - node - for node in tree.body - if isinstance(node, ast.Assign) - or (isinstance(node, ast.FunctionDef) and node.name in _KEEP_FUNCS) - ] - module = ast.Module(body=kept, type_ignores=[]) - ast.fix_missing_locations(module) - code = compile(module, str(_FIRMWARE), "exec") - - import ujson - - ns: dict = { - "time": fake_time, - "status": fake_status, - "ujson": ujson, - # stream() has `tof: VL53L5CX` annotation; name must resolve at def time. - "VL53L5CX": object, - } - exec(code, ns) - return ns - - -@pytest.fixture -def main_ns(fake_time, fake_status): - """Fresh AST-loaded main.py namespace with fakes injected. - - Returns a SimpleNamespace with: - - .ns: dict of module-level names - - .time: the _FakeTime instance used as the `time` module - - .status: the _FakeStatus instance; inspect .status.calls for transitions - """ - ns = _load_main_namespace(fake_time, fake_status) - return SimpleNamespace(ns=ns, time=fake_time, status=fake_status) diff --git a/projects/multizone-ranging/tests/test_emit_schema.py b/projects/multizone-ranging/tests/test_emit_schema.py deleted file mode 100644 index b5d3b38..0000000 --- a/projects/multizone-ranging/tests/test_emit_schema.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Host CPython pytest checks for JSON schema invariants of multizone-ranging's emit(). - -Asserts that every line is ujson.dumps(obj) with no extra prints, that the -grid is a 64-element list, and that null/int values are preserved correctly. -""" - -import io -import json -from contextlib import redirect_stdout - -import ujson - - -def test_emit_grid_has_64_elements(): - emit = _capture_emit() - result = _run(emit, {"t": 1, "grid": [100] * 64}) - assert len(result["grid"]) == 64 - - -def test_emit_grid_int_values(): - emit = _capture_emit() - grid = list(range(64)) - result = _run(emit, {"t": 1, "grid": grid}) - assert result["grid"] == grid - - -def test_emit_grid_null_values(): - emit = _capture_emit() - grid = [None] * 64 - result = _run(emit, {"t": 1, "grid": grid}) - assert result["grid"] == [None] * 64 - - -def test_emit_grid_mixed_int_and_null(): - emit = _capture_emit() - grid = [i if i % 2 == 0 else None for i in range(64)] - result = _run(emit, {"t": 1, "grid": grid}) - assert result["grid"] == grid - - -def test_emit_t_is_int(): - emit = _capture_emit() - result = _run(emit, {"t": 12345, "grid": [0] * 64}) - assert isinstance(result["t"], int) - assert result["t"] == 12345 - - -def test_emit_diag_lines_valid_json(): - emit = _capture_emit() - result = _run(emit, {"diag": "scan", "devices": [0x29]}) - assert result["diag"] == "scan" - assert result["devices"] == [41] - - -def test_emit_firmware_loading_diag(): - emit = _capture_emit() - result = _run(emit, {"diag": "firmware_loading"}) - assert result["diag"] == "firmware_loading" - - -def test_emit_vl53l5cx_ok_diag(): - emit = _capture_emit() - result = _run(emit, {"diag": "vl53l5cx_ok", "addr": 0x29}) - assert result["diag"] == "vl53l5cx_ok" - assert result["addr"] == 41 - - -def _capture_emit(): - """Build emit() from main.py without executing the loop.""" - src = "def emit(obj):\n print(ujson.dumps(obj))\n" - ns = {"ujson": ujson} - exec(src, ns) - return ns["emit"] - - -def _run(emit, obj): - buf = io.StringIO() - with redirect_stdout(buf): - emit(obj) - return json.loads(buf.getvalue().strip()) diff --git a/projects/multizone-ranging/tests/test_full_import.py b/projects/multizone-ranging/tests/test_full_import.py new file mode 100644 index 0000000..71955ff --- /dev/null +++ b/projects/multizone-ranging/tests/test_full_import.py @@ -0,0 +1,87 @@ +"""Host CPython end-to-end import test for multizone-ranging firmware/main.py. + +The AST-load fixture in conftest covers function bodies but skips module-level +imports and the trailing main() call. This test stubs sys.modules for every +external dependency and loads main.py as a real module — exercising each +per-chip BOARD branch — while a fake VL53L5CX raises after one frame to escape +stream(). +""" + +import importlib.util +import io +import json +import os +import pathlib +import sys +from contextlib import redirect_stdout +from types import SimpleNamespace + +import pytest + +from micropython_stubs.testing import ( + BOARD_CHIPS, + DeviceNotFoundError, + FakeStatus, + build_full_import_stubs, +) + +_FIRMWARE = pathlib.Path(__file__).parent.parent / "firmware" / "main.py" +_TOF_ADDRESS = 0x29 + + +@pytest.mark.parametrize("machine_str,board_name", BOARD_CHIPS) +def test_main_executes_init_then_streams_one_frame(monkeypatch, machine_str, board_name): + fake_status = FakeStatus() + monkeypatch.setattr(os, "uname", lambda: SimpleNamespace(machine=machine_str)) + for name, module in _build_stubs(fake_status).items(): + monkeypatch.setitem(sys.modules, name, module) + monkeypatch.delitem(sys.modules, "main", raising=False) + + spec = importlib.util.spec_from_file_location("main", _FIRMWARE) + module = importlib.util.module_from_spec(spec) + + buf = io.StringIO() + with redirect_stdout(buf), pytest.raises(_StopMainError): + spec.loader.exec_module(module) + + assert module.BOARD.name == board_name + lines = [json.loads(ln) for ln in buf.getvalue().splitlines() if ln.strip()] + diags = [ln.get("diag") for ln in lines if "diag" in ln] + assert "vl53l5cx_ok" in diags + assert any("grid" in ln for ln in lines) + + +class _StopMainError(Exception): + """Raised by the fake sensor on the second read() to escape stream().""" + + +class _FakeVL53L5CX: + """Stub VL53L5CX that opens its own bus; second read() raises to escape stream().""" + + def __init__(self, *, sda, scl) -> None: + self.addr = _TOF_ADDRESS + self._calls = 0 + + def init(self) -> None: + return None + + def start(self, _freq) -> None: + return None + + def check_data_ready(self) -> bool: + return True + + def read(self) -> list[int]: + self._calls += 1 + if self._calls > 1: + raise _StopMainError + return [100] * 64 + + +def _build_stubs(status_stub): + """Build SimpleNamespace stubs matching main.py's module-level imports.""" + # main() builds VL53L5CX(sda=, scl=) directly — the driver owns the bus and + # scan — so the project no longer imports i2c_bus; the vl53l5cx stub exposes + # the driver class and its DeviceNotFoundError. + vl53l5cx_stub = SimpleNamespace(VL53L5CX=_FakeVL53L5CX, DeviceNotFoundError=DeviceNotFoundError) + return build_full_import_stubs("vl53l5cx", vl53l5cx_stub, status_stub) diff --git a/projects/multizone-ranging/tests/test_init_sensor.py b/projects/multizone-ranging/tests/test_init_sensor.py index 613d9a4..73d76df 100644 --- a/projects/multizone-ranging/tests/test_init_sensor.py +++ b/projects/multizone-ranging/tests/test_init_sensor.py @@ -1,17 +1,62 @@ """Host CPython pytest tests for init_sensor() in multizone-ranging firmware. -Covers: happy path, no_device retry, init error retry, and ValueError from -the driver (e.g. a poll timeout during firmware loading). +The driver opens its own bus and scans, so init_sensor() takes no arguments and +constructs VL53L5CX(sda=, scl=) from BOARD, then calls init()/start(). Covers: +happy path, no_device retry (DeviceNotFoundError from the constructor), init error +retry (OSError from the constructor), and ValueError/RuntimeError from init() +(e.g. a poll timeout during firmware loading). """ -import pytest +import os +import pathlib +from collections import namedtuple +from typing import ClassVar -_TOF_ADDRESS = 0x29 +from micropython_stubs.testing import ScriptedFake, firmware_namespace +from vl53l5cx import DeviceNotFoundError +_FIRMWARE = pathlib.Path(__file__).parent.parent / "firmware" / "main.py" +_KEEP_FUNCS = {"emit", "stream", "init_sensor"} +Board = namedtuple("Board", ("name", "sda", "scl")) +_TEST_BOARD = Board(name="RP2040-Zero", sda=0, scl=1) -def test_init_sensor_happy_path(init_ns): - bus = _FakeBus(scans=[[_TOF_ADDRESS]]) - init_ns.ns["i2c"] = bus + +class _FakeVL53L5CX(ScriptedFake): + """VL53L5CX stand-in (see ScriptedFake): records init()/start() on success.""" + + script: ClassVar[list] = [] + + def __init__(self, *, sda, scl) -> None: + super().__init__() + self.addr = 0x29 + self._inited = False + self._started = False + self._freq = None + + def init(self) -> None: + self._inited = True + + def start(self, freq=10) -> None: + self._started = True + self._freq = freq + + +def _make_init_ns(): + """Create AST-loaded namespace with _FakeVL53L5CX injected.""" + _FakeVL53L5CX.script = [] + return firmware_namespace( + _FIRMWARE, + _KEEP_FUNCS, + os=os, + namedtuple=namedtuple, + BOARD=_TEST_BOARD, + VL53L5CX=_FakeVL53L5CX, + DeviceNotFoundError=DeviceNotFoundError, + ) + + +def test_init_sensor_happy_path(): + init_ns = _make_init_ns() tof = init_ns.ns["init_sensor"]() assert isinstance(tof, _FakeVL53L5CX) assert tof._inited is True @@ -19,35 +64,22 @@ def test_init_sensor_happy_path(init_ns): assert init_ns.status.calls == ["i2c_init"] -def test_init_sensor_retries_when_device_missing(init_ns): - bus = _FakeBus(scans=[[], [_TOF_ADDRESS]]) - init_ns.ns["i2c"] = bus +def test_init_sensor_retries_when_device_missing(): + init_ns = _make_init_ns() + _FakeVL53L5CX.script = [DeviceNotFoundError("no device"), None] init_ns.ns["init_sensor"]() assert init_ns.status.calls == ["i2c_init", "no_device"] -def test_init_sensor_retries_on_oserror(init_ns, monkeypatch): - bus = _FakeBus(scans=[[_TOF_ADDRESS], [_TOF_ADDRESS]]) - init_ns.ns["i2c"] = bus - - call = {"n": 0} - real_init = _FakeVL53L5CX.__init__ - - def maybe_raise(self, *a, **kw): - call["n"] += 1 - if call["n"] == 1: - raise OSError("bus fault") - real_init(self, *a, **kw) - - monkeypatch.setattr(_FakeVL53L5CX, "__init__", maybe_raise) +def test_init_sensor_retries_on_oserror(): + init_ns = _make_init_ns() + _FakeVL53L5CX.script = [OSError("bus fault"), None] init_ns.ns["init_sensor"]() assert "init_err" in init_ns.status.calls -def test_init_sensor_retries_on_value_error(init_ns): - bus = _FakeBus(scans=[[_TOF_ADDRESS], [_TOF_ADDRESS]]) - init_ns.ns["i2c"] = bus - +def test_init_sensor_retries_on_value_error(): + init_ns = _make_init_ns() call = {"n": 0} class _FailOnce(_FakeVL53L5CX): @@ -62,10 +94,8 @@ def init(self): assert "init_err" in init_ns.status.calls -def test_init_sensor_retries_on_runtime_error(init_ns): - bus = _FakeBus(scans=[[_TOF_ADDRESS], [_TOF_ADDRESS]]) - init_ns.ns["i2c"] = bus - +def test_init_sensor_retries_on_runtime_error(): + init_ns = _make_init_ns() call = {"n": 0} class _RTEOnce(_FakeVL53L5CX): @@ -78,39 +108,3 @@ def init(self): init_ns.ns["VL53L5CX"] = _RTEOnce init_ns.ns["init_sensor"]() assert "init_err" in init_ns.status.calls - - -@pytest.fixture -def init_ns(main_ns): - """Inject the fake VL53L5CX class into the main.py namespace.""" - main_ns.ns["VL53L5CX"] = _FakeVL53L5CX - return main_ns - - -class _FakeBus: - """Bus stub with a scriptable scan() list.""" - - def __init__(self, *, scans) -> None: - self._scans = list(scans) - - def scan(self): - if len(self._scans) == 1: - return self._scans[0] - return self._scans.pop(0) - - -class _FakeVL53L5CX: - """VL53L5CX stand-in: records init() and start() calls.""" - - def __init__(self, bus) -> None: - self.bus = bus - self._inited = False - self._started = False - self._freq = None - - def init(self) -> None: - self._inited = True - - def start(self, freq=10) -> None: - self._started = True - self._freq = freq diff --git a/projects/multizone-ranging/tests/test_stream.py b/projects/multizone-ranging/tests/test_stream.py index 76f9f41..73d24ca 100644 --- a/projects/multizone-ranging/tests/test_stream.py +++ b/projects/multizone-ranging/tests/test_stream.py @@ -8,104 +8,106 @@ 5. The loop survives a double-fault in the inner stop/start recovery. """ -import io -import json -from contextlib import redirect_stdout - -import pytest - - -def test_stream_no_emit_when_data_not_ready(main_ns): +import os +import pathlib +from collections import namedtuple + +from micropython_stubs.testing import StopLoopError, firmware_namespace, run_stream +from vl53l5cx import DeviceNotFoundError + +_FIRMWARE = pathlib.Path(__file__).parent.parent / "firmware" / "main.py" +_KEEP_FUNCS = {"emit", "stream", "init_sensor"} +Board = namedtuple("Board", ("name", "sda", "scl")) +_TEST_BOARD = Board(name="RP2040-Zero", sda=0, scl=1) + + +def _make_main_ns(): + """Create a fresh AST-loaded main.py namespace with fakes.""" + return firmware_namespace( + _FIRMWARE, + _KEEP_FUNCS, + os=os, + namedtuple=namedtuple, + BOARD=_TEST_BOARD, + VL53L5CX=object, + DeviceNotFoundError=DeviceNotFoundError, + ) + + +def test_stream_no_emit_when_data_not_ready(): + main_ns = _make_main_ns() tof = _FakeTof(script=[False]) - lines = _run_stream(main_ns, tof) + lines = run_stream(main_ns, tof) data_lines = [ln for ln in lines if "grid" in ln] assert data_lines == [] -def test_stream_emits_grid_when_data_ready(main_ns): +def test_stream_emits_grid_when_data_ready(): + main_ns = _make_main_ns() grid = list(range(64)) tof = _FakeTof(script=[True], grids=[grid]) - lines = _run_stream(main_ns, tof) + lines = run_stream(main_ns, tof) data_lines = [ln for ln in lines if "grid" in ln] assert len(data_lines) == 1 assert data_lines[0]["grid"] == grid -def test_stream_grid_has_t_field(main_ns): +def test_stream_grid_has_t_field(): + main_ns = _make_main_ns() tof = _FakeTof(script=[True], grids=[[0] * 64]) - lines = _run_stream(main_ns, tof) + lines = run_stream(main_ns, tof) grid_lines = [ln for ln in lines if "grid" in ln] assert "t" in grid_lines[0] -def test_stream_read_err_calls_status_read_err(main_ns): +def test_stream_read_err_calls_status_read_err(): + main_ns = _make_main_ns() tof = _FakeTof(script=[True], read_raises=OSError) - _run_stream(main_ns, tof) + run_stream(main_ns, tof) assert "read_err" in main_ns.status.calls -def test_stream_runtime_err_calls_status_read_err(main_ns): +def test_stream_runtime_err_calls_status_read_err(): + main_ns = _make_main_ns() tof = _FakeTof(script=[True], read_raises=RuntimeError) - _run_stream(main_ns, tof) + run_stream(main_ns, tof) assert "read_err" in main_ns.status.calls -def test_stream_read_err_calls_stop_then_start(main_ns): +def test_stream_read_err_calls_stop_then_start(): + main_ns = _make_main_ns() tof = _FakeTof(script=[True], read_raises=OSError) - _run_stream(main_ns, tof) + run_stream(main_ns, tof) assert tof.calls == ["stop", "start"] -def test_stream_inner_stop_raises_is_swallowed(main_ns): +def test_stream_inner_stop_raises_is_swallowed(): + main_ns = _make_main_ns() tof = _FakeTof(script=[True, True], read_raises=OSError, stop_raises=OSError) - _run_stream(main_ns, tof) + run_stream(main_ns, tof) assert "read_err" in main_ns.status.calls assert tof.calls == ["stop", "stop"] -def test_stream_recovers_and_emits_after_error(main_ns): +def test_stream_recovers_and_emits_after_error(): + main_ns = _make_main_ns() grid = [50] * 64 tof = _FakeTof(script=[True, True], grids=[grid], read_raises_once=True) - lines = _run_stream(main_ns, tof) + lines = run_stream(main_ns, tof) grid_lines = [ln for ln in lines if "grid" in ln] assert len(grid_lines) == 1 assert grid_lines[0]["grid"] == grid -def test_stream_status_transitions(main_ns): +def test_stream_status_transitions(): + main_ns = _make_main_ns() tof = _FakeTof(script=[True], read_raises=OSError) - _run_stream(main_ns, tof) + run_stream(main_ns, tof) assert main_ns.status.calls == ["streaming", "read_err", "streaming"] -def _run_stream(main_ns, fake_tof): - """Drive stream() until the fake's script is exhausted; return parsed JSON lines.""" - stream = main_ns.ns["stream"] - buf = io.StringIO() - with redirect_stdout(buf), pytest.raises(_StopLoopError): - stream(fake_tof) - return [json.loads(line) for line in buf.getvalue().splitlines() if line.strip()] - - -class _StopLoopError(Exception): - """Sentinel: propagates out of stream() to end the test.""" - - class _FakeTof: - """Scripted VL53L5CX stand-in. - - `script` is consumed by check_data_ready() in order: - - True → data ready; read() will be called - - False → not ready; stream() sleeps and continues - - `grids` is consumed by read() in order when check_data_ready() returns True. - When script or grids are exhausted, raises _StopLoopError to end the loop. - - `read_raises` makes every read() call raise that exception class. - `read_raises_once` makes only the first read() raise OSError; - subsequent calls consume from grids. - `stop_raises` makes stop() raise. - """ + """Scripted VL53L5CX stand-in.""" def __init__( self, @@ -126,7 +128,7 @@ def __init__( def check_data_ready(self) -> bool: if not self._script: - raise _StopLoopError + raise StopLoopError return self._script.pop(0) def read(self) -> list: @@ -136,7 +138,7 @@ def read(self) -> list: self._first_read = False raise OSError("scripted first-read error") if not self._grids: - raise _StopLoopError + raise StopLoopError return self._grids.pop(0) def stop(self) -> None: diff --git a/pyproject.toml b/pyproject.toml index 28fc3e1..113765f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "micropython-boardfarm" -version = "0.7.0" +version = "0.8.0" description = "Write MicroPython firmware that behaves the same across all supported microcontroller boards" readme = "README.md" requires-python = ">=3.12" diff --git a/uv.lock b/uv.lock index a4ebc1f..5f7e375 100644 --- a/uv.lock +++ b/uv.lock @@ -44,12 +44,21 @@ wheels = [ ] [[package]] -name = "boot-status-led" +name = "atgm336h" version = "0.2.0" source = { registry = "/wheels" } -sdist = { path = "/wheels/boot_status_led-0.2.0.tar.gz" } +sdist = { path = "/wheels/atgm336h-0.2.0.tar.gz" } +wheels = [ + { path = "/wheels/atgm336h-0.2.0-py3-none-any.whl" }, +] + +[[package]] +name = "boot-status-led" +version = "0.3.0" +source = { registry = "/wheels" } +sdist = { path = "/wheels/boot_status_led-0.3.0.tar.gz" } wheels = [ - { path = "/wheels/boot_status_led-0.2.0-py3-none-any.whl" }, + { path = "/wheels/boot_status_led-0.3.0-py3-none-any.whl" }, ] [[package]] @@ -84,7 +93,7 @@ wheels = [ [[package]] name = "compass" -version = "0.1.0" +version = "0.2.0" source = { virtual = "projects/compass" } dependencies = [ { name = "boot-status-led" }, @@ -196,22 +205,14 @@ wheels = [ [[package]] name = "distance-stream" -version = "0.3.0" +version = "0.4.0" source = { virtual = "projects/distance-stream" } dependencies = [ - { name = "boot-status-led" }, - { name = "i2c-bus" }, - { name = "smoothing" }, - { name = "vl53l0x" }, + { name = "micropython-boardfarm" }, ] [package.metadata] -requires-dist = [ - { name = "boot-status-led", specifier = ">=0.1.0", index = "file:///wheels" }, - { name = "i2c-bus", specifier = ">=0.1.0", index = "file:///wheels" }, - { name = "smoothing", specifier = ">=0.1.0", index = "file:///wheels" }, - { name = "vl53l0x", specifier = ">=0.1.0", index = "file:///wheels" }, -] +requires-dist = [{ name = "micropython-boardfarm", editable = "." }] [[package]] name = "dnspython" @@ -246,7 +247,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.136.3" +version = "0.138.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -255,51 +256,32 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/58/ff455d9fe47c60abadb34b9e05a304b1f05f5ab8000ac01565156b6f5e43/fastapi-0.138.0.tar.gz", hash = "sha256:d445a4877636ad191e7053e08c9bf98cb921a6756776848400bb773d1740c061", size = 419240, upload-time = "2026-06-20T01:18:05.259Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, -] - -[[package]] -name = "atgm336h" -version = "0.1.0" -source = { registry = "/wheels" } -sdist = { path = "/wheels/atgm336h-0.1.0.tar.gz" } -wheels = [ - { path = "/wheels/atgm336h-0.1.0-py3-none-any.whl" }, + { url = "https://files.pythonhosted.org/packages/6c/ff/8496d9847a5fedae775eb49460722d3efaa80487854273e9647ae876218c/fastapi-0.138.0-py3-none-any.whl", hash = "sha256:b6f54fd1bd72c80b0f899f172c61a600f6f7af9b43d4d772a018f35624048cb0", size = 126779, upload-time = "2026-06-20T01:18:03.483Z" }, ] [[package]] name = "gps-stream" -version = "0.1.0" +version = "0.2.0" source = { virtual = "projects/gps" } dependencies = [ - { name = "boot-status-led" }, - { name = "atgm336h" }, + { name = "micropython-boardfarm" }, ] [package.metadata] -requires-dist = [ - { name = "boot-status-led", specifier = ">=0.2.0", index = "file:///wheels" }, - { name = "atgm336h", specifier = ">=0.1.0", index = "file:///wheels" }, -] +requires-dist = [{ name = "micropython-boardfarm", editable = "." }] [[package]] name = "gyro-stream" -version = "0.3.0" +version = "0.4.0" source = { virtual = "projects/gyro-stream" } dependencies = [ - { name = "boot-status-led" }, - { name = "i2c-bus" }, - { name = "mpu6050" }, + { name = "micropython-boardfarm" }, ] [package.metadata] -requires-dist = [ - { name = "boot-status-led", specifier = ">=0.1.0", index = "file:///wheels" }, - { name = "i2c-bus", specifier = ">=0.1.0", index = "file:///wheels" }, - { name = "mpu6050", specifier = ">=0.1.0", index = "file:///wheels" }, -] +requires-dist = [{ name = "micropython-boardfarm", editable = "." }] [[package]] name = "h11" @@ -385,11 +367,11 @@ wheels = [ [[package]] name = "i2c-bus" -version = "0.2.0" +version = "0.3.0" source = { registry = "/wheels" } -sdist = { path = "/wheels/i2c_bus-0.2.0.tar.gz" } +sdist = { path = "/wheels/i2c_bus-0.3.0.tar.gz" } wheels = [ - { path = "/wheels/i2c_bus-0.2.0-py3-none-any.whl" }, + { path = "/wheels/i2c_bus-0.3.0-py3-none-any.whl" }, ] [[package]] @@ -446,8 +428,8 @@ wheels = [ [[package]] name = "micropython-boardfarm" -version = "0.6.0" -source = { virtual = "." } +version = "0.8.0" +source = { editable = "." } dependencies = [ { name = "atgm336h" }, { name = "boot-status-led" }, @@ -537,11 +519,11 @@ viz = [{ name = "serial-over-web", index = "file:///wheels" }] [[package]] name = "micropython-stubs" -version = "0.2.0" +version = "0.3.0" source = { registry = "/wheels" } -sdist = { path = "/wheels/micropython_stubs-0.2.0.tar.gz" } +sdist = { path = "/wheels/micropython_stubs-0.3.0.tar.gz" } wheels = [ - { path = "/wheels/micropython_stubs-0.2.0-py3-none-any.whl" }, + { path = "/wheels/micropython_stubs-0.3.0-py3-none-any.whl" }, ] [[package]] @@ -555,29 +537,23 @@ wheels = [ [[package]] name = "mpu6050" -version = "0.1.0" +version = "0.2.0" source = { registry = "/wheels" } -sdist = { path = "/wheels/mpu6050-0.1.0.tar.gz" } +sdist = { path = "/wheels/mpu6050-0.2.0.tar.gz" } wheels = [ - { path = "/wheels/mpu6050-0.1.0-py3-none-any.whl" }, + { path = "/wheels/mpu6050-0.2.0-py3-none-any.whl" }, ] [[package]] name = "multizone-ranging" -version = "0.2.0" +version = "0.3.0" source = { virtual = "projects/multizone-ranging" } dependencies = [ - { name = "boot-status-led" }, - { name = "i2c-bus" }, - { name = "vl53l5cx" }, + { name = "micropython-boardfarm" }, ] [package.metadata] -requires-dist = [ - { name = "boot-status-led", specifier = ">=0.1.0", index = "file:///wheels" }, - { name = "i2c-bus", specifier = ">=0.1.0", index = "file:///wheels" }, - { name = "vl53l5cx", specifier = ">=0.1.0", index = "file:///wheels" }, -] +requires-dist = [{ name = "micropython-boardfarm", editable = "." }] [[package]] name = "orjson" @@ -872,11 +848,11 @@ wheels = [ [[package]] name = "qmc5883p" -version = "0.1.0" +version = "0.2.0" source = { registry = "/wheels" } -sdist = { path = "/wheels/qmc5883p-0.1.0.tar.gz" } +sdist = { path = "/wheels/qmc5883p-0.2.0.tar.gz" } wheels = [ - { path = "/wheels/qmc5883p-0.1.0-py3-none-any.whl" }, + { path = "/wheels/qmc5883p-0.2.0-py3-none-any.whl" }, ] [[package]] @@ -919,7 +895,7 @@ wheels = [ [[package]] name = "serial-over-web" -version = "0.1.0" +version = "0.1.1" source = { registry = "/wheels" } dependencies = [ { name = "fastapi" }, @@ -927,9 +903,9 @@ dependencies = [ { name = "pyserial" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { path = "/wheels/serial_over_web-0.1.0.tar.gz" } +sdist = { path = "/wheels/serial_over_web-0.1.1.tar.gz" } wheels = [ - { path = "/wheels/serial_over_web-0.1.0-py3-none-any.whl" }, + { path = "/wheels/serial_over_web-0.1.1-py3-none-any.whl" }, ] [[package]] @@ -964,15 +940,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.2.1" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] @@ -1147,20 +1123,20 @@ wheels = [ [[package]] name = "vl53l0x" -version = "0.1.0" +version = "0.2.0" source = { registry = "/wheels" } -sdist = { path = "/wheels/vl53l0x-0.1.0.tar.gz" } +sdist = { path = "/wheels/vl53l0x-0.2.0.tar.gz" } wheels = [ - { path = "/wheels/vl53l0x-0.1.0-py3-none-any.whl" }, + { path = "/wheels/vl53l0x-0.2.0-py3-none-any.whl" }, ] [[package]] name = "vl53l5cx" -version = "0.1.1" +version = "0.2.0" source = { registry = "/wheels" } -sdist = { path = "/wheels/vl53l5cx-0.1.1.tar.gz" } +sdist = { path = "/wheels/vl53l5cx-0.2.0.tar.gz" } wheels = [ - { path = "/wheels/vl53l5cx-0.1.1-py3-none-any.whl" }, + { path = "/wheels/vl53l5cx-0.2.0-py3-none-any.whl" }, ] [[package]]