Firmware
Embedded software, device logic, peripheral drivers, and the firmware update mechanism.
Stack
The firmware is written in MicroPython and runs on the Pico 2 W. It is a single main.py entry point plus a lib/ directory of modules. Firmware is pushed to the device with mpremote fs cp - no compile step in the current phase.
Boot paths
The firmware has two distinct boot paths. Which one runs is decided by whether /config.json on flash contains Wi-Fi credentials.
Unprovisioned boot
main.py → provisioning.enter_provisioning_mode() immediately
→ no OLED / I2C init until after provisioning
→ reboot after successful provisioning
Hardware init is deliberately skipped on first boot. I²C initialisation delayed the serial listener, which caused the PWA to time out waiting for the ready beacon. Unprovisioned devices enter the provisioning loop immediately, before any peripheral is touched.
Provisioned boot
main.py → KnownHardware() → Wi-Fi → DNS :53 → HTTP :8080 → main loop
Modules
| Module | Purpose |
|---|---|
provisioning.py | USB WebSerial JSON loop: ready beacon, identify, challenge, scan, router_info, provision |
ecdsa.py | Pure-MicroPython ECDSA P-256 signing with DER encoding. ~150 lines, ~3s per signature on RP2350 |
otp_keys.py | Device key storage. OTP primary via custom C module, flash file fallback. Atomic burn with read-back verify |
dns_monitor.py | UDP 53 server. Forwards to 1.1.1.1, logs queries, classifies flagged-domain / new-connection / normal |
http_server.py | HTTP API on port 8080. CORS *. Routes: health, stats, devices, audit, debug, token, allowlist, device rename |
devices.py | DeviceTracker. Tracks devices by source IP |
names_store.py | Persistent device names at /names.json. Max 32 characters per name |
dns_diag.py | Network diagnostic tool. T1–T4 tests for DNS issues |
ups.py | Pico UPS B + INA219 battery monitor. Voltage, current, power, charge percentage |
ssd1306.py | OLED display driver |
DNS monitoring
Heron listens on UDP 53 and forwards every query transparently to 1.1.1.1 / 8.8.8.8. Queries are logged with source IP, domain, and timestamp. The classifier in dns_monitor.py tags each query as one of:
- flagged-domain - the queried domain matches a known suffix on the flagged list
- new-connection - a device making its first observed connection
- normal - neither of the above
The flagged field is true for both flagged-domain and new-connection classes. The log is cached to /dnslog.json on flash and is clearable from the dashboard.
Key storage
Device keys live in the RP2350's one-time-programmable memory. The 254-byte payload uses a fixed layout:
| Field | Offset | Length | Notes |
|---|---|---|---|
| Private key | 0 | 32 | ECDSA P-256, raw big-endian |
| Public key | 32 | 65 | Uncompressed point (0x04 ‖ X ‖ Y) |
| Serial | 97 | 8 | 8-byte hex identifier |
| Certificate | 105 | 148 | DER-encoded, null-padded, trimmed on read |
| Magic byte | 253 | 1 | 0x01 = keys committed |
If these offsets ever move, every burned device is bricked. The test suite locks them.
Burn is atomic. Data fields are written first, read back and verified, then the magic byte is written last. If any byte does not match, the magic is never set and the device can be re-attempted - OTP bits only go 0→1, so retrying a partial write sets the same bits again.
ECDSA signing
The signer is pure Python - no C extension. It uses the Pico's true random number generator for per-signature k, does point multiplication via double-and-add on the Cortex-M33, and applies low-S normalisation for canonical signatures. Signing takes roughly three seconds on the RP2350. This is a one-time operation during provisioning, so the latency is acceptable and avoids a custom MicroPython build with a C crypto module.
Firmware updates
Updates are local by design. There is no cloud, no server, no auto-download. A signed .heronpkg bundle - a single file the user can copy by email, USB stick, or any channel - is applied to a plugged-in device.
The bundle carries a monotonic version number, a minimum-source-version gate, and per-file SHA-256 hashes. The signature is verified against a dedicated update public key burned into the device at manufacturing - independent from the attestation root key, so each can rotate without dragging the other. Files are staged to .new paths, verified on device, then renamed atomically. A power cut during the apply leaves the old firmware intact.
Phase 2 secure boot - signed UF2s, frozen modules, SECURE_BOOT_ENABLE on production devices - is deferred until volume production. The current updater verifies integrity at apply time, not boot time.
Hard rules
- Never use
boot.py.os.dupterm(None, 1)can permanently disable USB serial. Recovery requiresflash_nuke.uf2. All init logic lives inmain.py. - Serial-first unprovisioned boot. I²C and OLED init are skipped until after provisioning. Serial priority is non-negotiable.
- Root private key never leaves the manufacturing machine. 600 permissions. Signs device certificates only.