Device protocol

This is the contract between a device agent (ESP-IDF component, Arduino library, simulator…) and the Otter server: JSON requests over HTTP(S), always initiated by the device, so devices never need an open port. Use HTTPS: see Security for what plain HTTP exposes.

All device endpoints live under /api/v1. A device built without a server URL can find it on the LAN over mDNS: service type _otter._tcp, whose TXT record url (when present) is the URL to use; otherwise http://<address>:<port> of the service.

Authentication#

A device first authenticates with the fleet key (OTTER_FLEET_KEY on the server; empty = no check, LAN only):

X-Otter-Key: <fleet key>

The check-in response to such a request carries a token for this device alone. The device stores it (NVS for the ESP-IDF agent) and sends it instead on every request, downloads included:

Authorization: Bearer otd_…

Once a device has used its token, the fleet key no longer works for it: someone who extracted the fleet key from any device's flash can't act as it. A token only acts for its own device (its check-ins, deployments and commands). Agents that ignore token keep working with the fleet key; they just get a new, unused token at each check-in. The server stores SHA-256 hashes only.

Answer Meaning What the device does
401 to a token unknown token: re-enrolled from the dashboard, or database reset forget it, check in with the fleet key
401 to the fleet key wrong key, or this device uses a token nothing useful: check the configuration
403 revoked, or awaiting approval (OTTER_DEVICE_APPROVAL) retry later

From the dashboard, Revoke blocks a device (token and fleet key) and Re-enroll lets its next fleet-key check-in get a new token. With OTTER_DEVICE_APPROVAL=1, new devices appear in the dashboard but are refused until approved.

1. Check-in#

The device calls this at boot and then every checkin_interval_s seconds.

POST /api/v1/checkin
Content-Type: application/json

{
  "mac": "a4:cf:12:34:56:78",     // required, identifies the device
  "hw": "esp32",                  // required, hardware family: esp32, esp32c3, esp8266…
  "chip": "ESP32-D0WD-V3",        // optional, exact chip, named as esptool does (shown in the UI)
  "chip_rev": "3.1",              // optional, chip revision: major.minor
  "flash_size": 4194304,          // optional, bytes of flash on the chip
  "psram_size": 8388608,          // optional, bytes of PSRAM, when there is some
  "radio": "Wi-Fi 4, Bluetooth 5 (LE)", // optional, its radios, shown in the UI
  "app": "weather-station",       // required, firmware application name
  "fw_version": "1.2.0",          // required, currently running version
  "ip": "192.168.1.42",           // optional, server falls back to the TCP peer address
  "rssi": -61,                    // optional, Wi-Fi signal in dBm
  "uptime_s": 3600,               // optional
  "ota_slot_size": 1966080,       // optional, bytes available for an update image (see below)
  "free_heap": 284104,            // optional, health: free heap in bytes
  "min_free_heap": 251360,        // optional, lowest free heap since boot
  "reset_reason": "power_on",     // optional, why the device last restarted (see below)
  "boot_count": 17,               // optional, incremented at each boot
  "config_version": "5dd2cdab1550ab80", // optional, remote configuration it has ("" = none)
  "next_checkin_s": 600,          // optional, a sleeping device: when it will be back (see below)
  "wait_s": 30                    // optional, long polling (see below)
}

Response:

{
  "checkin_interval_s": 30,
  "update": null,
  "commands": [],
  "token": null,                  // a new token when the request used the fleet key
  "config": null                  // the configuration, when config_version is outdated
}

or, when an update is scheduled for this device:

{
  "checkin_interval_s": 30,
  "update": {
    "deployment_id": 12,
    "version": "1.3.0",
    "url": "http://otter.local:8000/api/v1/firmwares/4/download",
    "size": 912384,
    "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
    "signature": "MEUCIQ…",        // base64, when the image was signed (see Security)
    "compressed": { … },           // optional, see "Firmware download"
    "delta": { … }                 // optional, see "Firmware download"
  }
}

OTA slot size#

ota_slot_size is the room for an update image: the size of the next OTA partition on ESP-IDF (esp_ota_get_next_update_partition(NULL)->size), ESP.getFreeSketchSpace() on Arduino. The server never sends a device an image bigger than that: deploying one to it is refused, and tag deployments, rollouts and release channels skip it. Without the field, the server can't check and trusts the device; a check-in without it keeps the last value.

Health#

reset_reason is one of power_on, software (restart, e.g. after an update), deep_sleep, external (reset pin), panic, int_watchdog, task_watchdog, watchdog, brownout, power_glitch, cpu_lockup, usb, jtag, sdio, efuse, unknown (lowercase letters, digits and _). The server flags a device that restarted (a new boot_count, or uptime_s going down) because of a panic, watchdog, brownout, power glitch or CPU lockup: highlighted in the UI, counted in otter_device_crashes_total and notified as device_crashed.

Configuration#

Key/value settings edited in the dashboard, per tag and per device (the device's own values win over its tags', and tags apply in alphabetical order). An agent that supports them always sends config_version ("" until it has one); when it differs from the current version, the answer carries the whole configuration:

"config": { "version": "5dd2cdab1550ab80", "values": { "interval_s": 60, "unit": "C", "debug": false } }

The device applies it, keeps it (the ESP-IDF agent in NVS, so it applies from boot even offline) and checks in again right away with the new config_version, which the dashboard shows as "in sync". Values are strings, numbers or booleans; keys [a-z][a-z0-9_]{0,31}; at most 32 keys and 2 KB per device or tag. Agents that don't send config_version never get any.

Sleeping devices#

A battery device that deep-sleeps between check-ins sends wait_s: 0 and next_checkin_s, the time until its next wake-up. It counts as online until then (plus a margin: 25 % and a minute), so it isn't reported offline while asleep. Deployments, commands and configuration changes wait for its next check-in. Without next_checkin_s, a device is expected every checkin_interval_s.

Long polling#

With wait_s > 0, if nothing is waiting for the device, the server holds the request open for up to wait_s seconds (capped at 60) and answers as soon as a deployment, a command or a new configuration concerns it, so they reach it within a second or two instead of at the next check-in. Set the HTTP timeout to wait_s plus a margin.

Agents should wait checkin_interval_s minus the time the request took before the next check-in: against a long-polling server they poll again immediately, against a server that answers at once they fall back to the plain interval. A device should send wait_s: 0 when it needs an immediate answer (e.g. a new firmware waiting to be marked valid).

Commands#

The check-in response may also carry remote commands queued from the dashboard (at most 5 per check-in: the rest follow at the next one):

{
  "checkin_interval_s": 30,
  "update": null,
  "commands": [
    { "id": 7, "name": "reboot", "args": null },
    { "id": 8, "name": "blink", "args": { "times": 3 } }
  ]
}

The device runs them in order and acknowledges each one:

POST /api/v1/commands/{id}/result
Content-Type: application/json

{ "ok": true, "message": "blinked 3 times" }    // message optional, at most 200 characters

ok: false with a message for a failure, e.g. "unknown command" for a name the firmware doesn't know. After commands, a device should check in again right away (more may be queued). reboot is acknowledged first, then the device restarts once every command is acknowledged. Commands reach a long-polling device within a second or two; one still queued after 10 min expires instead of running at an unexpected time.

Names are lowercase letters, digits and _ (at most 32); args is a JSON object (at most 512 bytes) or null. Built into the agents: reboot and identify (make the device show itself, e.g. blink an LED); applications register their own.

Crash reports#

After a crash, at its first check-in that reaches the server, an ESP-IDF device sends its core dump summary (then erases it):

POST /api/v1/crashes            (?mac=<mac> when authenticating with the fleet key)
Content-Type: application/json

{
  "elf_sha256": "2d8042e2",       // app_elf_sha256 of the crashed app (a prefix is fine)
  "task": "main",
  "reason": "abort() was called at PC 0x42000631 on core 0",
  "pc": 1082169736,
  "backtrace": [ … ],             // Xtensa: the backtrace
  "registers": { "ra": …, "sp": … }, "stack": [ … ]   // RISC-V: registers and stack words
}

The server finds the firmware whose image names that ELF hash, and decodes the addresses with its ELF file when it has it.

Live logs#

An agent that can send its logs says so in its check-ins ("logs": true). When someone watches the device's logs in the dashboard, the next check-in answer (at once, for a long poll) carries "logs_s": 600: send log lines for that many seconds, starting with the last ones kept, then check in again right away. "logs_s": 0 means stop. It only comes when it changes.

POST /api/v1/logs
Content-Type: application/json

{"mac": "a4:cf:12:34:56:78", "lines": ["I (1234) otter: …", "W (1240) wifi: …"]}   → 204

At most 200 lines of 512 characters per request; the ESP-IDF agent sends every 2 s. The server keeps the last 500 lines of each device, in memory only.

2. Progress reports#

While applying an update, the device reports its progress. Reports are best effort: a lost report must never abort the update. Sending one every ~5–10 % is plenty.

POST /api/v1/deployments/{deployment_id}/progress
Content-Type: application/json

{ "state": "downloading", "progress": 42 }
state meaning
downloading image is being streamed to the inactive OTA partition
rebooting image written and verified, device is about to restart
failed update aborted; include "error": "<short reason>"

With failed, a device may add "retryable": true|false to say whether a new attempt could succeed. Without it, the server guesses from error: network problems (connection lost, download timeout, cannot reach…) are retried, anything else (sha256 mismatch, image validation, not enough space…) is final. A retried deployment goes back to pending and is handed out again at a later check-in, after 10 s, 20 s… (OTTER_RETRY_DELAY), up to OTTER_DEPLOY_ATTEMPTS attempts (default 3).

progress is 0–100. A 409 Conflict answer means the deployment was cancelled (or superseded) from the UI: the device should abort the update and keep running its current image. There is no success state sent by the device: the update is confirmed when the device checks in again reporting the target fw_version. If it checks in after rebooting with any other version, the server marks the deployment as failed (typically a bootloader rollback).

3. Firmware download#

GET /api/v1/firmwares/{id}/download

Returns the raw .bin image. The device must verify the SHA-256 of what it wrote against update.sha256 before switching boot partitions.

When compressing saves at least 5 %, the update order also offers the image zlib-compressed: "compressed": {"url": "…/download?format=zlib", "size": 750629, "format": "zlib"} (ESP32 images shrink by about 40 %). An agent that can inflate downloads that instead and inflates it while writing (the ESP-IDF agent uses the ROM's tinfl, with a 32 KB window); size and sha256 still describe the image itself. Ranges count compressed bytes, so a resumed download continues the stream where it stopped.

When the device runs an image Otter has (same app, hardware and version as it reports), the order may also offer a patch from it, made with detools (heatshrink compression), when it is less than half the compressed image, at the first attempt only: "delta": {"url": "…/delta?base=12", "size": 19693, "base_hash": "…", "format": "detools-heatshrink"}. base_hash is the SHA-256 the base image carries at its end, which is what esp_partition_get_sha256() returns for the running partition: the agent uses the patch only if they match (an image flashed by other means differs), reading the running image as the patch source and writing the result like a downloaded image, which it then verifies with sha256 and the signature as usual. A patch that fails reports delta patch failed, which is retried with the whole image.

A device can resume an interrupted download with Range: bytes=<offset>-: the server answers 206 Partial Content with the rest of the image. On a weak Wi-Fi link, a connection that stops receiving data for a few seconds rarely recovers (the server's TCP waits twice as long after each loss), so the ESP-IDF agent opens a new one after 8 s of silence and resumes where it stopped, up to 10 times per attempt, instead of starting over.

Agent loop (reference)#

boot:
    mark running app valid (ESP-IDF rollback)   # once the app is confirmed healthy
loop:
    t0 = now()
    resp = checkin(wait_s = checkin_interval_s)
    for command in resp.commands:
        run it, then POST its result
    if resp.commands:
        reboot if asked, else check in again right away
    if resp.update:
        report(downloading, 0)
        stream url -> OTA partition, report every ~10 %
        verify size + sha256, otherwise report(failed, error) and continue
        report(rebooting, 100)
        restart
    sleep(max(0, resp.checkin_interval_s - (now() - t0)))

Security#

What protects what, and what doesn't yet:

Threat Plain HTTP HTTPS (server) Status
Someone on the network reads the fleet key, device data, or dashboard passwords and cookies exposed protected HTTPS: ESP-IDF done, Arduino to do (#17)
Someone on the network (e.g. ARP spoofing) serves a malicious firmware exposed: sha256 comes through the same channel, it only detects corruption protected, if the device verifies the certificate HTTPS: ESP-IDF done, Arduino to do (#17)
The server itself, or its storage, is compromised and serves a malicious firmware exposed exposed signed firmware (ESP-IDF agent): devices built with the public key refuse images not signed with the private key, which never reaches the server (#18)
A device's key is extracted from its flash the shared fleet key opens every device's API same per-device tokens (ESP-IDF agent): the fleet key alone can't act as an enrolled device, and a device can be revoked alone (#15); an extracted fleet key can still enroll new devices, unless OTTER_DEVICE_APPROVAL is on

Deployment options:

Devices must verify the server's certificate: TLS without verification still lets anyone on the network impersonate the server.

Signatures: signature in an update order is the base64 of openssl dgst -sha256 -sign over the whole image (DER ECDSA, or RSA PKCS#1 v1.5). An agent configured with a public key refuses an order without one (report failed, unsigned firmware refused…) and checks the SHA-256 of what it wrote against it before switching partitions (invalid signature…); both are final, not retried.

Rendered from docs/protocol.md.