Every device is a "stem cell": the same firmware and platform code on every board, with only app.py differing per app type. The app type is read from the board's EEPROM at boot (the server can override it), and the server delivers the matching code. boot.py is only a small loader; all boot logic lives in platform_boot.py, which arrives by OTA like the rest of the platform. USB is needed only to set up a brand-new board — see First-time setup for exactly which files.
platform.start(app) and app.run() concurrently. Never edited per device.main.py run. Loaded via USB on a new board, together with platform_boot.py and microdot.py. Can be replaced by OTA, but should never need it.platform_boot installs it.machine.reset() reports as WDT, the same as a hang; the reason file tells them apart. Nothing calls machine.reset() without it..mpy; main.py, boot.py and app.py stay as .py. Nobody runs mpy-cross by hand.# every power-on or reset boot.py (loader) → run the first working slot: platform_boot → platform_boot_prev → platform_boot_last_working platform_boot → rotate logs, log the reset cause (+ requested reason, if any) → policy says skip network and an app exists → done, main.py runs → connect WiFi (strongest known network; AP mode if none configured) → read app_type from EEPROM → POST /iot/boot (MAC, hardware, app type, MD5 of code files, reset cause) → store name, time, boot policy, WiFi networks → if update: download + verify all files, then install → restart → any failure with an app installed → main.py runs offline main.py → asyncio.gather( platform.start(app), # web server, WiFi, heartbeat, watchdog app.run() # device sensor / actuator loop ) platform → first heartbeat as soon as WiFi is up (firmware + META + vitals) → then every 5 min while online: vitals + telemetry; reply sets the clock, may carry "app" (→ app.on_server) or "restart": true
The app pushes key/value pairs through platform.report(). They appear at the top level of GET /status on the device, and every heartbeat carries them as telemetry. An app that wants a change shown at once calls await platform.heartbeat_now().
# app.py (sensor loop) platform.report( distance_mm = dist, sensor_status = "ok", ) # → merged into GET /status # → sent with the next heartbeat as "telemetry"
A brand-new board needs a few files over USB before OTA can take over. The reason is that boot.py is only a loader: it has no WiFi and no OTA code. A board with boot.py alone finds no boot slot, logs no working boot slot, and never contacts the server. At least one boot slot must therefore be on the board from the start.
| File | Source | Why it can't come by OTA |
|---|---|---|
| MicroPython UF2 | provision/pico_w/MicroPython.uf2, copied in BOOTSEL mode | The firmware itself. Nothing runs without it. Use 1.23 or later so the server can send .mpy. |
| boot.py | iot-device-code/boot/base/boot.py | The loader. MicroPython runs it at every start; it is what starts everything else. |
| platform_boot.py | iot-device-code/boot/base/platform_boot.py | The only code that does WiFi and OTA. Without it the first OTA can never happen. |
| microdot.py | provision/common/microdot.py | web_server.py imports it, but it is not in boot/base/, so OTA never delivers it. |
| wifi.json | Entered by the provisioning script (optional) | WiFi credentials. Without it the board starts AP mode (PicoSetup) and you enter them there. |
boot.py and platform_boot.py are taken from iot-device-code/boot/base/ — the same folder OTA serves — never from a separate copy.main.py, platform.py, web_server.py, iot_registry.pyapp.py and any app drivers (e.g. vl53l0x.py), from boot/<app_type>/platform_boot_prev and platform_boot_last_workingplatform_boot.mpy, which replaces the USB-loaded platform_boot.py (the .py is removed when the .mpy is installed)The app type is not a file. Either write it to the board's AT24C16 EEPROM, or leave the EEPROM empty or absent and assign the type from the console (PUT /iot/devices/<id>/app-type, as override or fallback) after the board's first check-in. Without either, the board comes up as undefined-xxxx with no app.
boot.py goes last, so it cannot start running while the session is still copying:
mpremote cp microdot.py :microdot.py + \
cp wifi.json :wifi.json + \
cp platform_boot.py :platform_boot.py + \
cp boot.py :boot.py + \
reset
wifi.json (or starts AP mode), then POST /iot/boot. The server creates the device and names it.main.py runs, and the first heartbeat confirms the board is up.GET /iot/devices/by-mac/<mac> answers for this specific board, with no database credentials. heartbeat_after_boot tells you the platform is running, not just that the board checked in.
copy-files-pico.sh does not yet copy platform_boot.py (a board provisioned with it never reaches the server) and still takes boot.py from provision/pico_w/ instead of boot/base/. register-pico.sh queries the dropped last_seen column and hides the error, so it always reports "not seen". Both need updating to match this section.Two files run before main.py. boot.py is a permanent loader with no platform logic. platform_boot.py does the work: WiFi, boot policy, app type, check-in, OTA sync, name and clock. One set serves both Pico W (rp2) and ESP32 (esp32); the board is detected via sys.platform.
Tries three slots in order. A slot that raises goes to the next at once. A slot that hangs or resets the board 3 boots in a row (counted in /boot_try.txt) is skipped the next time. When every slot has failed repeatedly, only main.py runs. Problems are written to /loader_log.txt.
| Slot | What it is |
|---|---|
| platform_boot | Newest version, delivered by OTA from boot/base/ (on a new board: the copy loaded via USB). |
| platform_boot_prev | The version before; kept automatically when an OTA replaces platform_boot. |
| platform_boot_last_working | A version promoted by hand as known-good. |
boot.py, so it is never compiled; an OTA that lists boot.mpy is refused. An OTA may replace boot.py itself, installed after the other files and before main.py.AP_TIMEOUT_S (300 s) and restarts. Every one of these restarts leaves its reason (see Restart reasons).boot_log.txt → boot_log_1.txt … boot_log_5.txt. Logs the reset cause together with the reason a deliberate restart left in /restart_reason.txt, or flags a WDT reset that nobody requested./boot_policy.json (the server's last answer). With skip_network_at_boot and an app installed, boot ends here and the app starts at once — unless all maint_pins are held low at power-on, which forces a full online boot./wifi.json holds several networks. A scan picks the strongest known one; the time budget is boot_wifi_s (default 10 s), 30 s without an app or in maintenance. No networks configured: the app starts offline, or without an app, AP mode.PicoSetup and serves a form at 192.168.4.1. On submit, the network is added to /wifi.json and the board restarts. Times out after AP_TIMEOUT_S.eeprom: ok · empty · absent · error. Without a value the app type is undefined, and the server may supply one..py and .mpy file on /. Only code files are compared — any other file in the OTA folder would be re-sent on every boot.epoch → /boot_time.json; device_id → /device_id.json; boot_policy → /boot_policy.json (when changed); networks → /wifi.json (only when different from the board's list). The platform reads these files; boot and the platform never share memory.update == true: every listed file is first downloaded from GET /iot/file/<app_type>/<name> to a .new path and its MD5 checked; nothing live is touched until all have arrived. Then they are installed: other files first, boot.py next, main.py last (with a .bak copy). A new platform_boot moves the running one to platform_boot_prev. Version → /version.json, then a restart. A failed download keeps the current files.BOOT END and returns to the loader; MicroPython runs main.py. An unexpected error is logged and re-raised, so the loader falls back to the next slot.Sent by the server in the /iot/boot answer and cached in /boot_policy.json, so it also applies to boots that skip the network.
| Key | Default | Effect |
|---|---|---|
| skip_network_at_boot | false | true: a normal power-on goes straight to main.py (instant start); the platform connects in the background. |
| maint_pins | none | e.g. [14, 15]: holding all of them low at power-on forces a full online boot (updates, new config). |
| boot_wifi_s | 10 | WiFi time budget at boot, in seconds. |
| Field | Description |
|---|---|
| mac | MAC address of the WLAN interface. The device's identity. |
| device_type | Hardware, e.g. pico_w. Stored as hw_type. |
| app_type | Value read from the EEPROM, or undefined. |
| files | filename → MD5 for every code file on /. |
| ip | Current IP address (falls back to the request address). |
| proto | Boot protocol: 1 = inline file contents; 2 = manifest + per-file download, .mpy; 3 = also follows a server-chosen app type. platform_boot sends 3. |
| boot_version | Version of platform_boot, e.g. 20261025c. |
| device_id | Optional: the name the board currently has. Echoed back if the database is unreachable. |
| eeprom | proto 3: ok · empty · absent · error. |
| reset_cause | proto 3: why the board last reset (PWRON, WDT, …). |
| restart_reason | The reason a deliberate restart left, or null. WDT with null = a real watchdog reset. Not yet used by the server. |
| micropython | proto 3: MicroPython release. .mpy is served only for 1.23 and later. |
| maintenance | true when the boot was forced online with the maintenance pins. |
| Field | Description |
|---|---|
| device_id | The name, assigned and owned by the server (see naming). |
| app_type | Effective app type: override → EEPROM → fallback → undefined. |
| update | True if any code file differs from what the server would install. |
| files[] | Only when update. proto 1: {path, content} (base64). proto 2+: {path, name, md5} — download each file separately. |
| version | Only when update. 12-character hash over the file list; written to /version.json. |
| epoch | Server Unix time (UTC). |
| boot_policy | See Boot policy. Cached on the board. |
| networks | List of {ssid, password}; written to /wifi.json when it differs. |
| warning | e.g. DB connection failed — the board continues with its current files and name. |
The public API that app.py is written against. Identical across all devices — never edited per device.
| Call | Description |
|---|---|
| register_routes(routes) | Add HTTP endpoints: a list of (method, path, async_handler). Call once at module level. |
| register_pins(pins) | Declare the app's GPIOs, e.g. {"neopixel": 0, "i2c_sda": 4}. The /gpio endpoints refuse to touch reserved pins. |
| report(**kwargs) | Push telemetry. Keys are merged into GET /status and sent with every heartbeat. Call as often as needed. |
| await heartbeat_now() | Send a heartbeat right away with the current telemetry (e.g. a score change). True if it landed, False when offline or failed. Never raises. |
| online() | True while WiFi is connected and has an IP. |
| restart(reason) | Deliberate restart; the reason is logged and kept for the next boot. Apps never call machine.reset() directly. |
| get_config() | Contents of /config.json as a dict; empty dict if missing. |
| log(msg) | Timestamped (UTC) line to /platform_log.txt and stdout. Rotates at 8 000 bytes. |
| set_heartbeat_interval(ms) | Change the heartbeat interval. Default 300 000 ms (5 min). |
| utc_epoch() | Unix time (UTC) in seconds, or None until the clock is synced. |
| device_id | The server-assigned name, read from /device_id.json. Set when start() runs. |
| Name | Description |
|---|---|
| FIRMWARE | App version string, e.g. "2.0.0". Sent in the first heartbeat; shown in /status and the console. |
| META | Dict sent in the first heartbeat: capabilities, sensor type, location. |
| async run() | Main loop. Must never return and never block — always await. Must work without WiFi. |
| async identify(seconds) | Optional. The app's own identify signal (e.g. a strip pattern), run alongside the platform's LED blink. |
| on_server(cmd) | Optional, may be async. Receives the app dict of a heartbeat reply — the only way to reach a board behind NAT (e.g. on a phone hotspot). |
DEVICE_TYPE is no longer used — platform_boot reports the hardware.
# app.py — skeleton for a new app type import asyncio import platform FIRMWARE = "1.0.0" META = {"capabilities": ["sensor"], "sensor": "my_sensor"} platform.register_pins({"i2c_sda": 4, "i2c_scl": 5}) _last = {"value": None} async def get_reading(request): return _last platform.register_routes([ ("GET", "/reading", get_reading), ]) async def run(): while True: _last["value"] = read_sensor() platform.report(value=_last["value"], status="ok") await asyncio.sleep_ms(100)
app.py imports only platform from the platform layer. It never imports web_server or iot_registry, never reads /device_id.json, never touches platform._telemetry, and never calls machine.reset().POST /identify blinks every indicator in groups of three fast blinks for ?seconds=N (default 10). Indicators are the onboard LED and an external status LED for boards inside an enclosure:
| Board | Onboard LED | Status LED |
|---|---|---|
| rp2 (Pico W) | "LED" | GP15 (physical pin 20, next to GND on pin 18) |
| esp32 | GPIO 2 | GPIO 25 |
Override with "onboard_led" and "status_led_pin" in /config.json (null disables). Both pins are reserved automatically. If the app defines identify(), it runs as well. Apps that only report np_pin/num_leds get the old blue strip blink as a legacy fallback.
platform.start(app) runs these concurrently; the app's run() is gathered alongside by main.py.
| Task | Description |
|---|---|
| web_server.start() | Microdot HTTP server on port 80. |
| _wifi_loop() | Keeps WiFi up in the background: the networks in /wifi.json in turn, 15 s each, then a pause of wifi_retry_s. Never blocks. Logs one line when WiFi comes or goes. |
| _heartbeat_loop() | First heartbeat as soon as WiFi is up (≥ 5 s after start, retried every 30 s until it lands), then every interval while online. Quiet while offline. Heartbeats are serialised by a lock, so never two TLS sessions at once. |
| _watchdog_loop() | machine.WDT, 8 s timeout, fed every second. If anything blocks the loop for longer, the board resets. |
main.py with mpremote or Thonny ends in a reset after 8 s. Break in during boot instead, or set "watchdog": false in /config.json.On the Pico, machine.reset() reports as reset cause WDT — exactly like a hang. So every deliberate restart first writes /restart_reason.txt: UTC time, source and reason, e.g. 2026-10-25 11:39:23Z boot: OTA installed version a98f7213509a.
| Source | Restarts with a reason |
|---|---|
| boot: | OTA installed; AP mode timed out or WiFi set up; WiFi, API or OTA failed with no app installed. |
| platform: | Server asked in a heartbeat reply; memory safety net; /restart and /factory-reset (via platform.restart); an app's platform.restart(reason). |
At the next boot, platform_boot logs one of:
[boot] reset cause: WDT (requested: 2026-10-25 11:39:23Z boot: OTA installed version a98f7213509a) [boot] reset cause: WDT — no restart was requested: the watchdog fired (something blocked or hung), or machine.reset() from a REPL
It then moves the file to /last_restart.txt, so an old reason can never explain a later hang, and sends it as restart_reason. The platform logs it once in platform_log.txt as previous restart: …, or previous reset: WATCHDOG … when none was requested.
An HTTPS heartbeat needs a large contiguous block of RAM for the TLS handshake. Two measures keep it working on small boards:
"tls_reserve_kb", 0 = off) and releases it for the first heartbeat after boot. It is not taken back afterwards.MemoryError / ENOMEM) is retried once after gc.collect(). After 3 such failures in a row the board restarts with that reason. A loop guard in /restart_count.txt allows at most 2 such restarts without a successful heartbeat; after that the board keeps running and retrying. Network and server errors never cause a restart, and a server-requested restart does not count.| Key | Default | Effect |
|---|---|---|
| watchdog | true | false disables the hardware watchdog. |
| tls_reserve_kb | 40 | Size of the TLS reserve; 0 turns it off. |
| wifi_retry_s | 60 | Pause after a full round of WiFi attempts without luck. |
| onboard_led | per board | Onboard LED for identify; null disables. |
| status_led_pin | per board | External status LED for identify; null disables. |
Read at start-up — changes take effect after a restart. Boot settings live in the boot policy, set by the server.
Microdot server on port 80. Owns the platform endpoints below, present on every device, and exposes the Microdot app so platform.register_routes() can attach app routes. Every request (except /logs/api) is logged to a 10-entry ring buffer in /api_log.json. microdot.py itself is loaded via USB on a new board (see First-time setup).
device_id, firmware, version, boot_epoch, uptime_ms, uptime_s, free_mem, rssi, ip, merged with everything the app has reported (top level, no nesting).device_id, firmware, mac, version, the uos.listdir("/") listing and the contents of /config.json.{"status": "saved", "config": …}. Most settings take effect after a restart.boot_log*.txt, main_log_*.txt and platform_log*.txt.?lines=N returns the last N lines.wifi.json, webrepl_cfg.py, *.key, secret*) return 403.?seconds=N, 1–120, default 10. Returns 503 if identify is unavailable. See Identify.HTTP /restart — plus ?reason=… if given (max 60 characters, e.g. reason=console). platform_boot then installs any pending update.wifi.json, device_id.json, config.json, version.json and api_log.json, then restarts with the reason HTTP /factory-reset.Talks to the IoT API on behalf of the platform. Registration and heartbeat OTA are gone: platform_boot reports what the board is, the first heartbeat reports what the app says, and code only ever arrives at boot.
The heartbeat uses asyncio streams with TLS, so the web server, app and watchdog keep running during it. If this MicroPython build lacks async TLS, it falls back to blocking urequests. Timeout 15 s; replies are capped at 8 KB.
| Function | Description |
|---|---|
| send_heartbeat(device_id, payload) | POST to /iot/heartbeat/<device_id>. Returns the reply dict, or None on any failure (never raises). The reason is left in last_error; last_error_memory is true for RAM failures. |
| load_device_id() | Name from /device_id.json, or None. |
| load_version() | OTA version from /version.json, or "unknown". |
| get_rssi() / get_ip() | WiFi signal strength and address. |
| Field | Description |
|---|---|
| version | OTA version the board runs (/version.json). The server compares it with what boot would install. |
| uptime_s | Seconds since the platform started. |
| free_mem | Free heap after gc.collect(). |
| rssi | WiFi signal strength. |
| telemetry | Everything the app has reported with platform.report(), when there is any. |
| first · firmware · meta | First heartbeat after boot only: first: true, the app's FIRMWARE and META. |
| Field | Effect on the device |
|---|---|
| epoch | Sets the RTC to UTC. Every reply carries it. |
| app | A dict handed to the app's on_server(cmd). |
| restart | true → the board restarts (reason: server asked) so platform_boot installs pending updates. |
| warning | Logged, e.g. unknown device. |
| update | Always false — kept for compatibility. |
api/iot/devices.py serves OTA files from iot-device-code/boot/: base/ for the platform (including boot.py and platform_boot.py), overlaid by <app_type>/. An app-type folder must exist for a type to be valid.
<app_type>-<last 4 MAC hex>, widened to 6 hex digits on a clash.rename event); heartbeats under the old name still reach it.undefined. Assignments take effect at the next boot. Proto 2 boards can't follow a server-chosen type; they need proto 3.old_mac, the name becomes <name>~<registered_at>. If the board shows up again it is a new device, linked to its previous incarnation. ?purge=true removes a row for good.| Status | Condition |
|---|---|
| online | Heartbeat within 10 min. |
| stale | Last heartbeat 10–30 min ago. |
| booting | Booted less than 2 min ago, no heartbeat since. |
| offline | Anything older, or booted without a heartbeat after 2 min. |
| archived | Deleted. |
| Endpoint | Used by · purpose |
|---|---|
| POST /iot/boot | platform_boot — identity, name, OTA diff, boot policy, WiFi networks. |
| GET /iot/file/<app_type>/<name> | platform_boot (proto 2+) — one OTA file. |
| POST /iot/heartbeat/<device_id> | platform — vitals, telemetry, clock, restart flag. |
| GET /iot/devices | console — all devices with status and update info (?include_deleted=true). |
| GET /iot/devices/<id> | console — one device. |
| GET /iot/devices/<id>/events | console — event history (boot, heartbeat, ota, rename, assign, restart, rollout, …). |
| POST /iot/devices/<id>/restart | console — restart directly if reachable, else via the next heartbeat. |
| PUT /iot/devices/<id>/app-type | console — assign an app type (override or fallback) or clear it. |
| DELETE /iot/devices/<id> | console — archive (or ?purge=true). |
| POST /iot/rollouts | console — staged restart: canary first, the rest once it is back. |
| GET /iot/rollouts/latest | console — rollout progress. |
| POST /iot/rollouts/<id>/cancel | console — stop a rollout. |
| GET /iot/app-types | console — valid app types, mpy-cross version and compile errors. |
| GET /iot/device-log | MCP server — read-only: a board's log files, crash.txt and restart_reason.txt (?device_id=…&file=…&lines=N, max 500 lines / 64 KB). |
| GET /iot/devices/by-mac/<mac> | provisioning — is this board up? (see First-time setup) |
/iot/register no longer exists.
| Path | Written by · contents |
|---|---|
| /boot.py | USB on a new board (or OTA) — the loader. |
| /platform_boot.mpy | OTA — boot logic. A new board starts with platform_boot.py from USB; the first OTA replaces it with the .mpy and removes the .py. platform_boot_prev = the version before; platform_boot_last_working = promoted known-good. |
| /microdot.py | USB on a new board — the HTTP framework web_server.py uses. Not delivered by OTA. |
| /boot_try.txt | loader — boot attempts of the current slot; cleared when it finishes. |
| /loader_log.txt | loader — failed slots; reset at 4 KB. |
| /wifi.json | USB, AP form or the server — {"networks": [{ssid, password}, …]} (old single format still read). Never served over HTTP. |
| /boot_policy.json | platform_boot — the server's boot policy. |
| /device_id.json | platform_boot — server-assigned name. |
| /boot_time.json | platform_boot — server epoch at boot. |
| /version.json | platform_boot after OTA — {"version", "updated_at"}. |
| /config.json | POST /config — device configuration. |
| /boot_log.txt, _1 … _5 | platform_boot — boot logs, rotated each boot. |
| /platform_log.txt | platform — runtime log, rotated at 8 000 bytes. |
| /api_log.json | web_server — last 10 inbound API calls. |
| /restart_reason.txt | anything that restarts on purpose — the reason, just before the reset; moved away at the next boot. |
| /last_restart.txt | platform_boot — the reason that explained this boot; logged once by the platform, then removed. |
| /restart_count.txt | platform — safety-net restarts since the last good heartbeat. |
| Date | Change |
|---|---|
| 2026-10-26 | Documentation: new First-time setup section listing the files loaded via USB (boot.py, platform_boot.py, microdot.py, optional wifi.json). Corrected the earlier claim that boot.py is the only USB file. |
| 2026-10-25 | Restart reasons: every deliberate restart leaves /restart_reason.txt; boot and platform logs tell it from a watchdog reset; restart_reason in /iot/boot; platform.restart(reason); /restart?reason=. wifi.json written only on change. |
| 2026-10-25 | Permanent loader boot.py + OTA-delivered platform_boot with fallback slots. Offline-first: boot never restarts a board with an app; background WiFi in the platform. Boot policy (skip_network_at_boot, maint_pins, boot_wifi_s). Several WiFi networks, sent by the server. Telemetry in heartbeats, heartbeat_now(), online(), on_server(). |
| 2026-10-21 | TLS reserve used only for the first heartbeat after boot. |
| 2026-10-20 | Memory safety net: GC retry, self-restart after 3 memory failures, TLS reserve, loop guard. Read-only /iot/device-log for the MCP server. |
| 2026-10-18 | WebREPL removed. Heartbeat restart flag. Failed heartbeats log their reason. /iot/register removed server-side. Server-side .mpy compilation. |
| 2026-10-17 | First heartbeat replaces registration; heartbeats carry vitals and are async; heartbeat OTA removed; watchdog; UTC clock; credential files blocked on /files. |
| 2026-05-19 | API request logging, /logs/api. |