rforssen.net / iot

IoT Platform

boot.py · platform.py · web_server.py · iot_registry.py
scopefirmware + platform
devicespico_w · esp32
transportOTA via api.rforssen.net/iot/boot

Architecture

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.

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

Execution order

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
    )

Data flow

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

App Contract

These are the only things app.py needs to know about the platform. Import nothing else from platform internals.

What app.py must export

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.

What the platform gives app.py

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.

Minimal app.py skeleton

# 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)
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 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)

Web Server Endpoints

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.

GET /status Device health + app telemetry
Returns platform fields (device_id, firmware, version, uptime_ms, free_mem, rssi, ip, boot_epoch) merged with whatever the app has pushed via platform.report(). App telemetry keys appear at the top level.
GET /info Hardware info, config, file list
Returns device_id, MAC address, firmware, version, full file listing from /, and current config.json contents.
GET /config Read current config
POST /config Merge keys into config.json
JSON body is merged (not replaced) into /config.json. Returns the full updated config.
GET /logs List available log files
GET /logs/<filename> Read a log file
Optional query param ?lines=N returns the last N lines. Covers boot_log*.txt, main_log_*.txt, platform_log.txt.
GET /endpoints All registered endpoints
Returns platform routes plus any routes registered by the app via platform.register_routes().
POST /identify Blue LED flash for physical identification
Blinks all NeoPixels blue 5 times. Requires app to have called platform.report(num_leds=N, np_pin=P).
POST /restart Reboot the device
POST /factory-reset Clear credentials and reboot
Removes wifi.json, device_id.json, config.json, version.json, then reboots into AP mode.

OTA Deployment Flow

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

1
Device boots, boot.py runs
Connects to WiFi. Computes MD5 of every .py file on the filesystem. Sends MAC, app_type, device_type and the hash map to POST /iot/boot.
2
API compares hashes
Server reads all files in 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.
3
boot.py writes files atomically
Each file is written to a .new temp path first, then renamed. main.py is always written last. boot.py is never sent — it is protected server-side.
4
Device reboots
On the next boot, hashes match, no update is sent, and boot.py exits. main.py runs with the new file set.

Deploying a new file or update

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
boot.py is never in the OTA directory. Adding it would allow the device to overwrite its own bootloader mid-boot. The server-side get_ota_files() function reads whatever is in the directory — do not put boot.py there.

Hotfix / urgent update

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.

Adding a new device type

1
Create server-side directory
mkdir $OTA_BASE/boot/my-new-device/
2
Copy platform files in
platform.py, web_server.py, iot_registry.py, main.py, microdot.py, plus any device drivers.
3
Write app.py
Set DEVICE_TYPE, FIRMWARE, META. Implement async def run(). Register routes. See app documentation.
4
Flash boot.py to device and power on
First boot: device calls /iot/boot with empty file hashes. API sends everything. Device writes all files and reboots into the full platform.