The platform is split into three layers. Only app.py changes between device types. Everything below it is identical across all devices and OTA-deployed as a unit. boot.py is the sole exception — it is flashed once and never overwritten remotely.
platform.start(app) and app.run() concurrently. Never edited per-device.boot.py runs first and exits before main.py is ever imported. OTA updates are therefore always applied atomically before any application code runs.
# boot sequence (every power-on or reset) boot.py → rotate logs → connect WiFi (or start AP mode if no credentials) → POST /iot/boot (sends MD5 hashes of all .py files on /) → if update: write files atomically → reboot → if no update: exit → main.py runs main.py → asyncio.gather( platform.start(app), # register device, web server, heartbeat loop app.run() # device sensor / actuator loop )
The app never writes to the web server or heartbeat directly. It pushes key/value pairs through platform.report(), which forwards to both GET /status and each outgoing heartbeat payload.
# app.py (sensor loop) platform.report( distance_mm = dist, sensor_status = "ok", uptime_s = uptime, ) # → web_server._telemetry updated # → merged into GET /status response # → included in next POST /iot/heartbeat/{device_id}
Runs on every boot before main.py. Responsible for WiFi connectivity, OTA file synchronisation via hash comparison, device identity assignment, and clock sync from the server. This file is never included in the OTA directory and must be flashed manually.
SystemExit so Thonny and mpremote remain accessible for recovery.
boot_log.txt → boot_log_1.txt through boot_log_5.txt. The oldest file is deleted. Runs before any log write so the current session always starts in a clean file./wifi.json → {"ssid": "…", "password": "…"}. If the file is missing or invalid, credentials are null and AP mode is the fallback path.get_wifi_state() — returns one of READY / DHCP_PENDING / DISCONNECTED / INCONSISTENT. If already READY (soft-reset), skips reconnect. Otherwise polls for up to 15 seconds./wifi.json is absent, starts a soft AP named PicoSetup (password 12345678) and serves an HTML form at 192.168.4.1. On POST, writes /wifi.json and reboots. Blocks indefinitely — this is intentional..py file on the root filesystem. Sent to the API so the server can diff against canonical files and include only changed or missing files in the response.mac, device_type, app_type, ip, and the file hash map to POST https://api.rforssen.net/iot/boot. Retries up to 3 times with 2-second gaps. On permanent failure, raises SystemExit — the device continues with existing files.epoch. Stored in /boot_time.json. All subsequent log lines in this boot session use real timestamps derived from this value.device_id on first contact. Written to /device_id.json. The platform layer reads it from there — boot.py and the platform runtime never share memory directly.response.update == true: each file is written to a .new temp path then atomically renamed. main.py is always written last, with a .bak safety copy first. Version saved to /version.json. Device reboots — next boot finds matching hashes and exits without update.BOOT END and exits cleanly. MicroPython immediately runs main.py.| Field | Direction | Description |
|---|---|---|
| mac | → request | Colon-separated MAC address of the WLAN interface. |
| device_type | → request | Hardware platform, e.g. pico_w. |
| app_type | → request | Application variant, e.g. distance-meter. Determines which OTA directory the server reads. |
| ip | → request | Current DHCP-assigned IP address. |
| files | → request | Dict of filename → MD5 for every .py file on /. |
| epoch | ← response | Unix timestamp used for clock sync and log timestamps. |
| device_id | ← response | Server-assigned device identifier. Stable across reboots. |
| update | ← response | Boolean. True if any file hashes differ from server canonical. |
| files[] | ← response | List of {path, content}. Content is base64-encoded file bytes. |
| version | ← response | Version string written to /version.json after OTA. |
| Path | Contents |
|---|---|
| /wifi.json | {"ssid": "…", "password": "…"} — WiFi credentials. |
| /device_id.json | {"device_id": "…"} — written after first API contact. |
| /boot_time.json | {"epoch": N} — server time at most recent boot. |
| /version.json | {"version": "…", "updated_at": N} — written after OTA. |
| /boot_log.txt | Current boot log. Rotated to boot_log_1.txt … boot_log_5.txt. |
The public API that app.py is written against. Manages device registration, telemetry forwarding, the heartbeat loop, and HTTP route registration. Identical across all device types — never edited per-device.
| Call | Description |
|---|---|
| platform.register_routes(routes) | Register app HTTP endpoints. Takes a list of (method, path, async_handler) tuples. Call once at module level before run() is invoked. |
| platform.report(**kwargs) | Push telemetry. Keys are merged into GET /status and included in each heartbeat payload. Idempotent — call as often as needed from the sensor loop. |
| platform.get_config() | Read /config.json from the device filesystem. Returns a dict; returns empty dict if the file is missing. |
| platform.log(msg) | Write a timestamped line to /platform_log.txt and stdout. Auto-rotates the file at 8 000 bytes. |
| platform.set_heartbeat_interval(ms) | Override the heartbeat interval at runtime. Default is 300 000 ms (5 minutes). Use for high-pulse or diagnostic modes. |
| platform.device_id | String. The device_id read from /device_id.json and confirmed with the registry. Available after platform.start() completes. |
| Name | Type | Description |
|---|---|---|
| DEVICE_TYPE | str | Hardware platform identifier, e.g. "pico". Forwarded to the IoT registry. |
| FIRMWARE | str | Semantic version string, e.g. "2.0.0". Visible in GET /status and the registry. |
| META | dict | Arbitrary metadata sent to the IoT registry on startup. Use for capabilities, sensor type, physical location. |
| run() | async def | Main application loop. Gathered by main.py concurrently with platform tasks. Must never block synchronously — always use await. |
# app.py — skeleton for a new device type import asyncio import platform DEVICE_TYPE = "pico" FIRMWARE = "1.0.0" META = { "capabilities": ["sensor"], "sensor": "my_sensor", } async def get_reading(request): value = platform._telemetry.get("value", 0) return {"value": value} platform.register_routes([ ("GET", "/reading", get_reading), ]) async def run(): while True: value = read_sensor() platform.report(value=value, status="ok") await asyncio.sleep_ms(100)
app.py imports only platform from the platform layer. It never imports web_server, iot_registry, or reads /device_id.json directly.
If the device has a NeoPixel strip, report num_leds and np_pin via platform.report() once at module level. The platform's POST /identify endpoint will then work with no additional code in app.py.
platform.report(num_leds=10, np_pin=0) # call once at module level
These run concurrently in the asyncio event loop once platform.start(app) is called. The app's own run() coroutine is gathered alongside them by main.py.
| Task | Description |
|---|---|
| web_server.start() | Microdot HTTP server on port 80. Handles all platform and app routes. |
| _heartbeat_loop() | Calls send_heartbeat() on the configured interval. OTA can be triggered mid-run if the heartbeat response includes files. |
Microdot-based HTTP server. Owns all platform endpoints and exposes the Microdot app object so platform.register_routes() can attach device-specific handlers. These endpoints are present on every device and cannot be removed or overridden by app.py.
device_id, firmware, version, uptime_ms, uptime_s, free_mem, rssi, ip, boot_epoch) merged with whatever the app has pushed via platform.report(). App telemetry keys appear at the top level — no nesting.
device_id, MAC address, firmware, version, the full uos.listdir("/") file listing, and the current contents of /config.json.
/config.json. Returns the full updated config. Changes to hardware-level settings take effect on next reboot.
boot_log*.txt, main_log_*.txt, and platform_log.txt.
?lines=N returns the last N lines only.
platform.register_routes().
num_leds and np_pin from the telemetry dict — requires the app to have called platform.report(num_leds=N, np_pin=P) at startup.
machine.reset() after a 1-second delay, giving the HTTP response time to be returned to the caller.
wifi.json, device_id.json, config.json, and version.json, then reboots. On the next boot the device will find no WiFi credentials and enter AP mode for reprovisioning.
| Function | Description |
|---|---|
| set_platform_info(device_id, firmware) | Called once by platform.start(). Values appear in /status and /info. |
| set_telemetry(data) | Called by platform.report(). Merges the app's dict into the internal _telemetry store. |
Handles device registration with the server-side IoT registry after main.py starts, and sends periodic heartbeats. A heartbeat response may include OTA files, which are applied immediately without waiting for the next boot.
boot.py on startup (file-hash diff), or via a heartbeat response mid-run. Both paths call apply_ota() and reboot.
| Function | Description |
|---|---|
| register_device() | Called by platform.start(). Waits for WiFi (up to 20 s), then POSTs to /iot/register with hostname, IP, device_type, firmware, version, and meta. Also starts WebREPL on port 8266. Returns the confirmed device_id. |
| send_heartbeat() | Called periodically by the platform heartbeat loop. POSTs to /iot/heartbeat/{device_id} with the current version string. If the response contains update: true, calls apply_ota() immediately. |
| load_device_id() | Reads /device_id.json written by boot.py. Returns None if the file is absent. |
| apply_ota(files, version) | Writes base64-decoded files to the filesystem, saves the version string to /version.json, then calls machine.reset(). |
| load_version() / save_version() | Read and write /version.json. Used to include the current version string in heartbeat payloads. |
| URL | Method | Called by |
|---|---|---|
| https://api.rforssen.net/iot/register | POST | register_device() at startup |
| https://api.rforssen.net/iot/heartbeat/{device_id} | POST | send_heartbeat() on interval |