rforssen.net / iot

IoT Platform

boot.py · platform.py · web_server.py · iot_registry.py · main.py
scopefirmware + platform
devicespico_w
otaapi.rforssen.net/iot/boot
boot.pynever OTA-updated

Architecture

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.

deployed per device type
Application
per device
Sensor logic, actuators, device-specific HTTP routes. Calls platform API only. Never imports web_server or iot_registry directly.
app.py
platform — identical on all devices · OTA-managed
Glue
entry point
7 lines. Gathers platform.start(app) and app.run() concurrently. Never edited per-device.
main.py
Platform SW
platform
Device registry, heartbeat loop, route registration, telemetry pipe, config access, HTTP server, logging.
platform.py
web_server.py
iot_registry.py
firmware — never OTA-updated · flash manually
Firmware
protected
WiFi bring-up, OTA file sync via MD5 comparison, device identity assignment, AP fallback for initial provisioning. Runs before main.py on every boot.
boot.py

Execution order

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
    )

Telemetry flow

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}

boot.py — Firmware & OTA Sync

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.

Critical constraint. boot.py must never block the serial REPL permanently. All dead-end paths raise SystemExit so Thonny and mpremote remain accessible for recovery.

Boot sequence

1
Rotate logs
Shifts boot_log.txtboot_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.
2
Load WiFi credentials
Reads /wifi.json{"ssid": "…", "password": "…"}. If the file is missing or invalid, credentials are null and AP mode is the fallback path.
3
Connect to WiFi
Checks 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.
4
AP fallback (no credentials)
If /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.
5
Collect file hashes
MD5 of every .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.
6
POST to OTA API (3 retries)
Sends 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.
7
Sync clock from server
API response includes epoch. Stored in /boot_time.json. All subsequent log lines in this boot session use real timestamps derived from this value.
8
Save device_id
The server assigns a stable 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.
9
Apply OTA update
If 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.
10
Exit → main.py
No update: boot.py reaches BOOT END and exits cleanly. MicroPython immediately runs main.py.

API request / response

FieldDirectionDescription
mac→ requestColon-separated MAC address of the WLAN interface.
device_type→ requestHardware platform, e.g. pico_w.
app_type→ requestApplication variant, e.g. distance-meter. Determines which OTA directory the server reads.
ip→ requestCurrent DHCP-assigned IP address.
files→ requestDict of filename → MD5 for every .py file on /.
epoch← responseUnix timestamp used for clock sync and log timestamps.
device_id← responseServer-assigned device identifier. Stable across reboots.
update← responseBoolean. True if any file hashes differ from server canonical.
files[]← responseList of {path, content}. Content is base64-encoded file bytes.
version← responseVersion string written to /version.json after OTA.

Files managed by boot.py

PathContents
/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.txtCurrent boot log. Rotated to boot_log_1.txtboot_log_5.txt.

platform.py — Runtime Contract

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.

What the platform gives app.py

CallDescription
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.

What app.py must export

NameTypeDescription
DEVICE_TYPEstrHardware platform identifier, e.g. "pico". Forwarded to the IoT registry.
FIRMWAREstrSemantic version string, e.g. "2.0.0". Visible in GET /status and the registry.
METAdictArbitrary metadata sent to the IoT registry on startup. Use for capabilities, sensor type, physical location.
run()async defMain application loop. Gathered by main.py concurrently with platform tasks. Must never block synchronously — always use await.

Minimal app.py skeleton

# 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)
Rule: app.py imports only platform from the platform layer. It never imports web_server, iot_registry, or reads /device_id.json directly.

NeoPixel / identify support

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

Internal platform tasks

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.

TaskDescription
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.

web_server.py — HTTP Layer

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.

GET /status Device health + app telemetry
Returns platform fields (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.
GET /info Hardware info, config, file list
Returns device_id, MAC address, firmware, version, the full uos.listdir("/") file listing, and the current contents of /config.json.
GET /config Read current config.json
POST /config Merge keys into config.json
JSON body is merged (not replaced) into /config.json. Returns the full updated config. Changes to hardware-level settings take effect on next reboot.
GET /logs List available log files
Returns count and filenames of all log files matching boot_log*.txt, main_log_*.txt, and platform_log.txt.
GET /logs/<filename> Read a log file
Returns the file as plain text. Optional query param ?lines=N returns the last N lines only.
GET /endpoints All registered endpoints
Returns platform routes plus any routes the app registered via platform.register_routes().
POST /identify Blue NeoPixel flash for physical identification
Blinks all NeoPixels blue 5 times. Reads 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.
POST /restart Reboot the device
Schedules machine.reset() after a 1-second delay, giving the HTTP response time to be returned to the caller.
POST /factory-reset Clear credentials and reboot into AP mode
Removes 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.

Internal setters (called by platform.py, not app.py)

FunctionDescription
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.

iot_registry.py — Registry & Heartbeat

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.

OTA can be triggered in two places: via boot.py on startup (file-hash diff), or via a heartbeat response mid-run. Both paths call apply_ota() and reboot.

Public functions

FunctionDescription
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.

API endpoints used

URLMethodCalled 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