Isaac Tools
Account

Device API

The API keeps a record of every Isaac head: what it is (module variant, hardware revision, firmware), how it is doing (battery, charger, pack temperature), what needs attention (alerts), and which firmware it should run. The local agent relays a paired head to the API; your wallet reads it back. The full contract is services/api/openapi.yaml (tags devices and firmware).

No revision A boards exist yet. Everything here runs against simulated heads (the mock fleet below) until they do.

Who can do what

Caller Auth Can
the agent, for one paired head that head's device token (POST /devices/token, issued to the owner) write that head's HELLO, health and update state, ask for its update
the owner the wallet session (SIWE cookie or bearer) list and read their own heads, their health history and fleet summary
a firmware publisher x-admin-key: $ADMIN_API_KEY create and publish firmware releases

The admin key (ADMIN_API_KEY on the API) must be at least 32 characters, or the API does not start; make one with openssl rand -hex 32. Without it, publishing is refused, except on the local development chain (31337), where the API falls back to the development key dev-admin-only-change-me so the mock fleet works out of the box.

A device token carries the head's key. Writes only ever touch that head, and only while RobotIdentity still pairs that key with the token's robot: after an unpair, a re-pair, or a robot transfer the old token gets 401 device_unpaired. A head is visible only to the owner of the robot it is paired with; anyone else gets a 404.

Pair, then relay

Pair the head first (Pairing), then get its device token with your wallet session:

API=http://127.0.0.1:4000
curl -s -X POST $API/devices/token -H "authorization: Bearer $WALLET" \
  -H 'content-type: application/json' -d '{"robotId":"0x…"}'
# {"token":"eyJ…","robotId":"0x…","deviceKey":"0x…","expiresAt":"…"}

The agent relays the head's HELLO whenever the link comes up:

curl -s -X POST $API/devices/self/hello -H "authorization: Bearer $DEVICE" -H 'content-type: application/json' \
  -d '{"protoVer":1,"bodyHash":"0x…","boardId":"main","fwCrc":305441741,"fwVersion":"1.0.0","moduleVariant":"N4R2",
       "hwRev":"A","buildHwRev":"A","serial":"ISC-A-000123","identity":"factory"}'
# {"device":{…},"bodyMatches":true,"serialShared":false}

The board has no hardware ID of its own: a head's revision, module and serial live in a factory record written once (the fctry partition). The HELLO carries what the head found there:

Field Meaning
hwRev the board's revision letter from the factory record
buildHwRev the revision the firmware was built for
serial the factory serial, 1 to 32 letters, digits or hyphens
identity factory (record present and matching), unprovisioned (no record yet), mismatch (record says another revision)

A head on mismatch refuses to run its firmware and raises the hardware_mismatch alert until a later HELLO says otherwise. unprovisioned shows as a flag on the device (unprovisioned: true), not as an alert. Serials are not required to be unique; when two device keys report the same serial the API logs it and the HELLO answer says serialShared: true.

and health samples, once a minute and on every charger change, in batches of up to 500. at is the agent's own clock when it received the reading (the head has no clock that survives a power loss):

curl -s -X POST $API/devices/self/health -H "authorization: Bearer $DEVICE" -H 'content-type: application/json' -d '{
  "samples": [{ "at": "2026-10-23T10:00:00Z", "soc": 0.62, "vcell": 3.86, "chargeRate": 48.5, "packTempC": 31.2,
                "chargerState": "charging", "usbPresent": true, "restricted": false, "uptimeSec": 5400, "rssi": -58 }],
  "ota": { "state": "downloading", "version": "1.1.0", "progress": 0.4 } }'
# {"accepted":1,"duplicates":0,"alertEvents":0,"alerts":[]}
Field Meaning
soc fuel gauge state of charge of the full cell, 0..1
vcell fuel gauge cell voltage, V
chargeRate fuel gauge charge rate, %/h (negative on battery)
packTempC pack temperature, C; send it only while the reading is valid
chargerState not_charging, charging, charged, paused_hot, paused_cold, fault (older firmware: paused_temp)
usbPresent USB power present
restricted restricted (batteryless) mode: USB present without a confirmed cell
resetReason after a boot: poweron, sw, deepsleep, brownout, panic, ...

Re-sending a batch is safe: samples are unique per head and time. A batch can be refused with 400 (validation, too_many_samples, sample_out_of_window: older than 7 days or more than 5 minutes ahead) or 429 rate_limited (more than 120 posts per head per minute).

Read your fleet

curl -s $API/devices -H "authorization: Bearer $WALLET"            # your heads, latest health, alerts
curl -s $API/devices/summary -H "authorization: Bearer $WALLET"    # counts, alerts, attention list, firmware
curl -s $API/devices/0xDeviceKey -H "authorization: Bearer $WALLET"  # one head, with its last 20 alerts
curl -s "$API/devices/0xDeviceKey/health?from=2026-10-20T00:00:00Z" -H "authorization: Bearer $WALLET"

Health history returns raw samples for the last 48 hours and 15-minute summaries (minimum, average, maximum, and how long the head spent on USB, charging, paused or in fault) before that.

Presence

Presence When
connected heard from in the last 3 minutes
asleep quiet for less than 30 minutes, or quiet since it shut down at the low-battery cut-off
offline quiet for 30 minutes or more (raises the offline alert)

Deep sleep and resets drop the link; that is normal and is not an alert.

Alerts

Alert Opens Closes
low_battery on battery below 20 % (warning); below 15 % (critical, "very low", back to the warning only at 18 %); within 50 mV of the 3.30 V cut-off (critical) at 25 %, or on USB
charge_paused_temp charging paused because the pack is too hot (above 43 C) or too cold (below 5 C) charging resumes (pack back at 8 to 40 C)
charger_fault the charger's safety timer expired again after the firmware's one retry; the cell may be damaged the fault clears (USB re-plugged)
hardware_mismatch the firmware was built for another board revision than the head's factory record (critical) a later HELLO says factory or unprovisioned
temp_out_of_range pack outside 3.5 to 47.2 C, or charging above 43 C; critical above 51.5 C 3 C back inside the window
restricted_mode USB present without a confirmed cell the cell is confirmed
offline quiet for 30 minutes (see Presence) the head is heard from again

A charge pause in a warm room while Isaac is talking is expected behaviour, not a fault: charging while talking warms the inside of the head by 19 to 27 C. charged covers more than a finished charge: top-up cycles and a firmware hold count as charged too.

Firmware updates

A release is built for one module variant (N4R2 or N8), a channel (stable or beta) and a lowest hardware revision. Its image must stay within the variant's limit: 1,769,472 bytes on N4R2 (4 MB flash), 2,818,048 bytes on N8 (8 MB flash). That is the update slot (1,835,008 or 2,883,584 bytes) less the padding and signature Secure Boot v2 adds, applied now so every release also fits a production head; a larger image gets 400 image_too_large with the limit in details.limitBytes.

Every release is signed. The heads have no secure boot, so they install an image only when its release carries an Ed25519 signature by the Isaac release key over this exact text:

isaac-fw-v1|<targetVariant>|<minHwRev, or empty>|<version>|<sha256, lowercase hex>|<sizeBytes>

for example isaac-fw-v1|N4R2|A|1.1.0|e4df…6226|1450000. The API checks the signature against its FIRMWARE_RELEASE_PUBKEY (the release key's 32-byte public key, in hex) when the release is created and again when it is published, and serves it in the manifest; the head and the agent check it once more before installing. Without FIRMWARE_RELEASE_PUBKEY the API refuses to publish, except on the local development chain (31337), where it uses the committed development key (public key bd24085067da07db8272c5c44377286c471bb8ccd1284f3a27ea9e34fe1b8578, seed sha256 of isaac-fw-dev-release-key-v1) that signs the mock fleet's releases. Keep the real release key's private half offline; only the public key goes on the API.

Publishing is two calls with the admin key:

ADMIN="x-admin-key: $ADMIN_API_KEY"
SHA=$(sha256sum app.bin | cut -c1-64); SIZE=$(stat -c%s app.bin)
SIG=…   # Ed25519 by the release key over "isaac-fw-v1|N4R2|A|1.1.0|$SHA|$SIZE", hex
curl -s -X POST $API/firmware/releases -H "$ADMIN" -H 'content-type: application/json' \
  -d "{\"version\":\"1.1.0\",\"channel\":\"stable\",\"targetVariant\":\"N4R2\",\"minHwRev\":\"A\",\"sha256\":\"$SHA\",\"sizeBytes\":$SIZE,\"signature\":\"$SIG\"}"
# {"release":{"id":"…",…},"uploadUrl":"https://…"}
curl -s -X PUT --data-binary @app.bin -H 'content-type: application/octet-stream' "$UPLOAD_URL"
curl -s -X POST $API/firmware/releases/$ID/publish -H "$ADMIN"

Publish re-reads the uploaded image and refuses it unless its size and SHA-256 match, it is an ESP-IDF app image, its embedded version equals the release version, it fits the slot, and the signature still verifies.

The agent asks for an update with the head's device token:

curl -s "$API/firmware/manifest?variant=N4R2&hwRev=A&current=1.0.0&channel=stable" -H "authorization: Bearer $DEVICE"
# 200 {"version":"1.1.0","sha256":"…","sizeBytes":1450000,"signature":"…","url":"https://…","urlExpiresAt":"…",…}
# 204 when the head is up to date

Updates only ever go forward. The manifest offers a release only when its version is strictly newer than what the head runs (the current the agent sends, or the version the head reported in its HELLO if that is higher), by semantic-version order: 1.10.0 is above 1.9.0, a pre-release is below its release (1.2.0-rc.1 < 1.2.0), and build metadata does not count (1.2.0+b7 is 1.2.0). Publishing follows the same rule: a version at or below the highest one already published for the same variant and channel is refused with 409 version_not_newer, so a fix for a bad release ships as a new, higher version.

The download URL is valid for 15 minutes. The head's update state (offered, downloading, applied, failed) follows from the manifest call, the ota field of the health post, and the HELLO it sends after rebooting into the new version.

Mock fleet

Nine simulated heads, each telling one story from the hardware handoff: a discharge through the 20 % and 15 % warnings to the cut-off and a full charge, a too-hot pause and resume, a too-cold pause and resume, restricted mode, a Wi-Fi loss, an update, a persistent charger fault, an unprovisioned head, and a hardware mismatch. They pair on the local chain and go through exactly the calls above.

anvil --host 127.0.0.1 --port 8545 --chain-id 31337 &
pnpm --filter @isaac/contracts deploy:local
DEVICE_OFFLINE_MINUTES=1 pnpm --filter @isaac/api dev
pnpm --filter @isaac/api mock:devices --wait-offline

--speed 600 (the default) runs a simulated hour in six seconds; --devices 18 repeats the stories; set API_URL when the API is not on port 4000. Each run creates a fresh fleet; --run <tag> reuses one.