rforssen.net / iot

IoT Platform

boot.py · platform_boot.py · platform.py · web_server.py · iot_registry.py · main.py
scopefirmware + platform
devicespico_w · esp32 (code-compatible)
apiapi.rforssen.net/iot
usbnew board only: boot.py · platform_boot.py · microdot.py
verified2026-10-26 against platform_boot 20261025c · platform 20261025b

Architecture

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.

deployed per app type · from boot/<app_type>/
Application
per app type
Sensor logic, actuators, device-specific HTTP routes. Calls the platform API only. Never imports web_server or iot_registry directly.
app.py
platform — identical on all devices · from boot/base/
Glue
entry point
Gathers platform.start(app) and app.run() concurrently. Never edited per device.
main.py
Platform SW
platform
Background WiFi, heartbeat, UTC clock, watchdog, route and pin registration, telemetry, config, identify, restart reasons, HTTP server, logging.
platform.py
web_server.py
iot_registry.py
Boot logic
OTA
WiFi at boot, boot policy, EEPROM app type, check-in with the server, OTA file sync by MD5, device name, AP mode for provisioning. Keeps the previous version as a rollback slot. The first copy is loaded via USB on a new board; every later version arrives by OTA.
platform_boot.py
loader — flashed with a new board · rarely changes
Loader
permanent
No platform logic and no networking. Runs the newest boot slot that works, then lets 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.
boot.py

Core rules

Execution order

# 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

Telemetry flow

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"

First-time Setup — What Goes on via USB

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.

Loaded via USB — once per new board

FileSourceWhy it can't come by OTA
MicroPython UF2provision/pico_w/MicroPython.uf2, copied in BOOTSEL modeThe firmware itself. Nothing runs without it. Use 1.23 or later so the server can send .mpy.
boot.pyiot-device-code/boot/base/boot.pyThe loader. MicroPython runs it at every start; it is what starts everything else.
platform_boot.pyiot-device-code/boot/base/platform_boot.pyThe only code that does WiFi and OTA. Without it the first OTA can never happen.
microdot.pyprovision/common/microdot.pyweb_server.py imports it, but it is not in boot/base/, so OTA never delivers it.
wifi.jsonEntered by the provisioning script (optional)WiFi credentials. Without it the board starts AP mode (PicoSetup) and you enter them there.
One source of truth. boot.py and platform_boot.py are taken from iot-device-code/boot/base/ — the same folder OTA serves — never from a separate copy.

Not needed via USB — arrives by OTA at the first boot

App type

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.

Copy order

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

What happens at the first power-on

1
Loader runs platform_boot.py
The only slot present.
2
WiFi and check-in
Connects with wifi.json (or starts AP mode), then POST /iot/boot. The server creates the device and names it.
3
Full OTA
Every platform and app file is missing, so the server sends the whole set. It is installed and the board restarts.
4
Normal start
The second boot finds matching hashes, main.py runs, and the first heartbeat confirms the board is up.

Checking that 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.

Provisioning scripts are behind this page. 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.

Boot — Loader, platform_boot & OTA

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.

The loader (boot.py)

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.

SlotWhat it is
platform_bootNewest version, delivered by OTA from boot/base/ (on a new board: the copy loaded via USB).
platform_boot_prevThe version before; kept automatically when an OTA replaces platform_boot.
platform_boot_last_workingA version promoted by hand as known-good.
boot.py stays .py. MicroPython only runs 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.
Recovery. With an app installed, no failure at boot restarts the board: it logs the reason and starts the app offline. Without an app, dead ends restart after 5 s. AP mode times out after AP_TIMEOUT_S (300 s) and restarts. Every one of these restarts leaves its reason (see Restart reasons).

platform_boot sequence

1
Rotate logs, log the reset cause
Shifts 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.
2
Apply the boot policy
From /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.
3
Connect to WiFi
/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.
4
AP mode (no networks, no app)
Starts a soft AP named 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.
5
Read app type from EEPROM
The AT24C16 EEPROM holds the app type. Its state is reported as eeprom: ok · empty · absent · error. Without a value the app type is undefined, and the server may supply one.
6
Hash code files
MD5 of every .py and .mpy file on /. Only code files are compared — any other file in the OTA folder would be re-sent on every boot.
7
POST /iot/boot
Sends identity, hardware, app type, hashes and the reset cause (see table below). Up to 3 attempts, 2 s apart. Unreachable: the app starts offline.
8
Store what the server says
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.
9
Apply OTA update
If 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.
10
Exit → main.py
No update: logs 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.

Boot policy

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.

KeyDefaultEffect
skip_network_at_bootfalsetrue: a normal power-on goes straight to main.py (instant start); the platform connects in the background.
maint_pinsnonee.g. [14, 15]: holding all of them low at power-on forces a full online boot (updates, new config).
boot_wifi_s10WiFi time budget at boot, in seconds.

POST /iot/boot — request

FieldDescription
macMAC address of the WLAN interface. The device's identity.
device_typeHardware, e.g. pico_w. Stored as hw_type.
app_typeValue read from the EEPROM, or undefined.
filesfilename → MD5 for every code file on /.
ipCurrent IP address (falls back to the request address).
protoBoot protocol: 1 = inline file contents; 2 = manifest + per-file download, .mpy; 3 = also follows a server-chosen app type. platform_boot sends 3.
boot_versionVersion of platform_boot, e.g. 20261025c.
device_idOptional: the name the board currently has. Echoed back if the database is unreachable.
eepromproto 3: ok · empty · absent · error.
reset_causeproto 3: why the board last reset (PWRON, WDT, …).
restart_reasonThe reason a deliberate restart left, or null. WDT with null = a real watchdog reset. Not yet used by the server.
micropythonproto 3: MicroPython release. .mpy is served only for 1.23 and later.
maintenancetrue when the boot was forced online with the maintenance pins.

POST /iot/boot — response

FieldDescription
device_idThe name, assigned and owned by the server (see naming).
app_typeEffective app type: override → EEPROM → fallback → undefined.
updateTrue 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.
versionOnly when update. 12-character hash over the file list; written to /version.json.
epochServer Unix time (UTC).
boot_policySee Boot policy. Cached on the board.
networksList of {ssid, password}; written to /wifi.json when it differs.
warninge.g. DB connection failed — the board continues with its current files and name.

platform.py — Runtime Contract

The public API that app.py is written against. Identical across all devices — never edited per device.

What the platform gives app.py

CallDescription
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_idThe server-assigned name, read from /device_id.json. Set when start() runs.

What app.py exports

NameDescription
FIRMWAREApp version string, e.g. "2.0.0". Sent in the first heartbeat; shown in /status and the console.
METADict 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.

Minimal app.py skeleton

# 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)
Rule: 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().

Identify

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:

BoardOnboard LEDStatus LED
rp2 (Pico W)"LED"GP15 (physical pin 20, next to GND on pin 18)
esp32GPIO 2GPIO 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 tasks

platform.start(app) runs these concurrently; the app's run() is gathered alongside by main.py.

TaskDescription
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.
Watchdog and the REPL. Once started the watchdog cannot be stopped, so interrupting 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.

Restart reasons

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.

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

Memory safety net

An HTTPS heartbeat needs a large contiguous block of RAM for the TLS handshake. Two measures keep it working on small boards:

Configuration keys used by the platform

KeyDefaultEffect
watchdogtruefalse disables the hardware watchdog.
tls_reserve_kb40Size of the TLS reserve; 0 turns it off.
wifi_retry_s60Pause after a full round of WiFi attempts without luck.
onboard_ledper boardOnboard LED for identify; null disables.
status_led_pinper boardExternal 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.

web_server.py — HTTP Layer

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

No authentication. These endpoints are open to anyone on the LAN, including restart, factory reset and GPIO control. Hardening is planned.
GET/statusHealth + app telemetry
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).
GET/infoHardware info, config, file list
device_id, firmware, mac, version, the uos.listdir("/") listing and the contents of /config.json.
GET/configRead config.json
POST/configMerge keys into config.json
JSON body is merged, not replaced. Returns {"status": "saved", "config": …}. Most settings take effect after a restart.
GET/logsList log files
All files matching boot_log*.txt, main_log_*.txt and platform_log*.txt.
GET/logs/<filename>Read a log file
Plain text (UTF-8). ?lines=N returns the last N lines.
GET/logs/apiLast 10 inbound API calls
Request and response of each call, with UTC timestamp and duration.
GET/files/<filename>Read a file on the device
Plain text. Credential files (wifi.json, webrepl_cfg.py, *.key, secret*) return 403.
GET/gpioList reserved pins
GET/gpio/<pin>/readRead a pin
POST/gpio/<pin>/high · /low · /toggleDrive a pin
Reserved pins (app pins and identify LEDs) return 403.
GET/endpointsAll registered endpoints
Platform routes plus the app's routes.
POST/identifyBlink indicators
?seconds=N, 1–120, default 10. Returns 503 if identify is unavailable. See Identify.
POST/restartReboot the device
Restarts after 1 s, so the response gets out first, leaving the reason HTTP /restart — plus ?reason=… if given (max 60 characters, e.g. reason=console). platform_boot then installs any pending update.
POST/factory-resetClear credentials, reboot into AP mode
Removes wifi.json, device_id.json, config.json, version.json and api_log.json, then restarts with the reason HTTP /factory-reset.

iot_registry.py — Heartbeat Client

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.

Functions

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

Heartbeat payload

FieldDescription
versionOTA version the board runs (/version.json). The server compares it with what boot would install.
uptime_sSeconds since the platform started.
free_memFree heap after gc.collect().
rssiWiFi signal strength.
telemetryEverything the app has reported with platform.report(), when there is any.
first · firmware · metaFirst heartbeat after boot only: first: true, the app's FIRMWARE and META.

Heartbeat reply

FieldEffect on the device
epochSets the RTC to UTC. Every reply carries it.
appA dict handed to the app's on_server(cmd).
restarttrue → the board restarts (reason: server asked) so platform_boot installs pending updates.
warningLogged, e.g. unknown device.
updateAlways false — kept for compatibility.

Server Side

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.

Naming and identity

Status (computed, never stored)

StatusCondition
onlineHeartbeat within 10 min.
staleLast heartbeat 10–30 min ago.
bootingBooted less than 2 min ago, no heartbeat since.
offlineAnything older, or booted without a heartbeat after 2 min.
archivedDeleted.

Endpoints

EndpointUsed by · purpose
POST /iot/bootplatform_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/devicesconsole — all devices with status and update info (?include_deleted=true).
GET /iot/devices/<id>console — one device.
GET /iot/devices/<id>/eventsconsole — event history (boot, heartbeat, ota, rename, assign, restart, rollout, …).
POST /iot/devices/<id>/restartconsole — restart directly if reachable, else via the next heartbeat.
PUT /iot/devices/<id>/app-typeconsole — assign an app type (override or fallback) or clear it.
DELETE /iot/devices/<id>console — archive (or ?purge=true).
POST /iot/rolloutsconsole — staged restart: canary first, the rest once it is back.
GET /iot/rollouts/latestconsole — rollout progress.
POST /iot/rollouts/<id>/cancelconsole — stop a rollout.
GET /iot/app-typesconsole — valid app types, mpy-cross version and compile errors.
GET /iot/device-logMCP 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.

Files on the device

PathWritten by · contents
/boot.pyUSB on a new board (or OTA) — the loader.
/platform_boot.mpyOTA — 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.pyUSB on a new board — the HTTP framework web_server.py uses. Not delivered by OTA.
/boot_try.txtloader — boot attempts of the current slot; cleared when it finishes.
/loader_log.txtloader — failed slots; reset at 4 KB.
/wifi.jsonUSB, AP form or the server — {"networks": [{ssid, password}, …]} (old single format still read). Never served over HTTP.
/boot_policy.jsonplatform_boot — the server's boot policy.
/device_id.jsonplatform_boot — server-assigned name.
/boot_time.jsonplatform_boot — server epoch at boot.
/version.jsonplatform_boot after OTA — {"version", "updated_at"}.
/config.jsonPOST /config — device configuration.
/boot_log.txt, _1 … _5platform_boot — boot logs, rotated each boot.
/platform_log.txtplatform — runtime log, rotated at 8 000 bytes.
/api_log.jsonweb_server — last 10 inbound API calls.
/restart_reason.txtanything that restarts on purpose — the reason, just before the reset; moved away at the next boot.
/last_restart.txtplatform_boot — the reason that explained this boot; logged once by the platform, then removed.
/restart_count.txtplatform — safety-net restarts since the last good heartbeat.

Changes

DateChange
2026-10-26Documentation: 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-25Restart 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-25Permanent 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-21TLS reserve used only for the first heartbeat after boot.
2026-10-20Memory 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-18WebREPL removed. Heartbeat restart flag. Failed heartbeats log their reason. /iot/register removed server-side. Server-side .mpy compilation.
2026-10-17First heartbeat replaces registration; heartbeats carry vitals and are async; heartbeat OTA removed; watchdog; UTC clock; credential files blocked on /files.
2026-05-19API request logging, /logs/api.