The platform is split into four layers. Only app.py changes between device types. Everything below it is identical across all devices and deployed as a unit.
On every boot, boot.py runs first and completes before main.py is ever imported. This means OTA updates are always applied atomically before application code runs.
# boot sequence boot.py → WiFi connect → POST /iot/boot (sends MD5 hashes of all .py files) → if update: write files → reboot → if no update: exit main.py # runs only after boot.py exits cleanly → asyncio.gather( platform.start(app), # register, web server, heartbeat app.run() # sensor loop )
The app never writes to the web server directly. It pushes telemetry through platform.report(), which forwards to both /status responses and heartbeat payloads.
# app.py platform.report( distance_mm = dist, sensor_status = "ok", uptime_s = uptime, ) # → web_server._telemetry updated # → appears in GET /status # → sent with next heartbeat
These are the only things app.py needs to know about the platform. Import nothing else from platform internals.
| Name | Type | Description |
|---|---|---|
| DEVICE_TYPE | str | Hardware platform identifier. Currently "pico" or "esp32". |
| FIRMWARE | str | Semantic version string. Reported to registry and visible in /status. |
| META | dict | Arbitrary metadata sent to the IoT registry on boot. Use for capabilities, sensor type, location. |
| run() | async def | Main application loop. Called by main.py via asyncio.gather. Must never block — use await. |
| Call | Description |
|---|---|
| platform.report(**kwargs) | Push telemetry. Keys appear in GET /status and are sent with each heartbeat. Call as often as needed — idempotent. |
| platform.register_routes(routes) | Add device-specific HTTP endpoints. Takes a list of (method, path, handler) tuples. Call once at module level. |
| platform.get_config() | Read /config.json from the filesystem. Returns a dict, empty dict if file missing. |
| platform.log(msg) | Write a timestamped line to /platform_log.txt and stdout. |
| platform.set_heartbeat_interval(ms) | Override the heartbeat interval. Default 300000 (5 min). Use for high-pulse mode. |
| platform.device_id | String. The device_id assigned by the API on boot. Available after platform.start() runs. |
# app.py — new device type skeleton import asyncio import platform DEVICE_TYPE = "pico" FIRMWARE = "1.0.0" META = { "capabilities": ["..."], "sensor": "...", } # Register device-specific routes (once, at module level) async def get_reading(request): return {"value": platform._telemetry.get("value", 0)} platform.register_routes([ ("GET", "/reading", get_reading), ]) # Main loop 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 your device has a NeoPixel strip, report num_leds and np_pin via platform.report() and the platform's POST /identify endpoint will work automatically with no additional code.
platform.report(num_leds=10, np_pin=0)
These endpoints are provided by the platform on every device. They cannot be removed or overridden by app.py. Device-specific endpoints added via platform.register_routes() appear alongside these in GET /endpoints.
platform.report(). App telemetry keys appear at the top level.
/, and current config.json contents.
/config.json. Returns the full updated config.
?lines=N returns the last N lines. Covers boot_log*.txt, main_log_*.txt, platform_log.txt.
platform.register_routes().
platform.report(num_leds=N, np_pin=P).
wifi.json, device_id.json, config.json, version.json, then reboots into AP mode.
OTA is handled entirely by boot.py on the device and the /iot/boot endpoint on api.rforssen.net. The server-side source of truth is the directory $OTA_BASE/boot/<app_type>/.
.py file on the filesystem. Sends MAC, app_type, device_type and the hash map to POST /iot/boot.boot/<app_type>/ and compares MD5s against what the device reported. Files missing on the device or with a different hash are included in the response..new temp path first, then renamed. main.py is always written last. boot.py is never sent — it is protected server-side.main.py runs with the new file set.Copy the file into the server-side directory. On the next device boot, it will be picked up automatically.
# server-side directory for this device type $OTA_BASE/boot/distance-meter/ app.py ← add new files here platform.py ← replace to update web_server.py main.py iot_registry.py microdot.py vl53l0x.py
get_ota_files() function reads whatever is in the directory — do not put boot.py there.
For a single device, drop files into $OTA_BASE/heartbeat/<device_id>/. They will be served on the next heartbeat call (default: within 5 minutes) and moved to a timestamped served/ archive after delivery.
mkdir $OTA_BASE/boot/my-new-device/platform.py, web_server.py, iot_registry.py, main.py, microdot.py, plus any device drivers.DEVICE_TYPE, FIRMWARE, META. Implement async def run(). Register routes. See app documentation./iot/boot with empty file hashes. API sends everything. Device writes all files and reboots into the full platform.