Heartbeat, telemetry and commands
The fleet plane your server implements, identical in all three modes: a 30-second heartbeat, health telemetry, self-reported incidents, and the acknowledge-then-act remote command lifecycle.
10 min read
On this page
Everything on this page applies to every device regardless of mode. These are calls the device makes to your server on its own timers — you implement the endpoints, and the payloads below are what will arrive. All routes take the MAC in the URL path, and the device sends it uppercase colon-separated.
Heartbeat
curl -X POST https://api.yourcompany.com/api/devices/heartbeat \
-H "Content-Type: application/json" \
-d '{
"mac_address": "AA:BB:CC:DD:EE:FF",
"ip_address": "192.168.1.88",
"firmware_version": "2.1.4",
"ota_version": "2.1.4",
"hardware_version": "rev-C",
"cpu_usage_percent": 34.2,
"memory_usage_percent": 61.0,
"temperature_celsius": 44.5,
"uptime_seconds": 84213,
"wifi_rssi": -58,
"free_storage_bytes": 2097152,
"battery_percent": 87.5
}'
# → { "status": "ok", "last_seen_at": "2026-09-10T10:22:31Z", "pending_commands": 2 }- Only mac_address is required. Every telemetry field is optional, so older firmware that sends none still works.
- pending_commands in the response is your cue to poll for commands. Do not poll the command endpoint on a blind timer when this field tells you whether it is worth a round trip.
- Any heartbeat, bare or telemetry-bearing, auto-resolves an open OFFLINE incident for that device.
- A BLOCKED device gets 403. Treat that as terminal: stop, show a fault indicator, do not retry in a loop.
Health is computed from what you send
| Condition | health_status |
|---|---|
| temperature_celsius > 70 | CRITICAL |
| memory_usage_percent > 90 | DEGRADED |
| cpu_usage_percent > 85 | DEGRADED |
| free_storage_bytes < 524288 | DEGRADED |
| otherwise | HEALTHY |
Incident reporting
curl -X POST https://api.yourcompany.com/api/devices/AA:BB:CC:DD:EE:FF/incident \
-H "Content-Type: application/json" \
-d '{
"incident_type": "WS_CONNECT_FAIL",
"severity": "HIGH",
"description": "TLS handshake failed after 6 attempts",
"metadata_json": { "host": "api.yourcompany.com", "errno": -9984 }
}'
# → { "incident_id": 442, "status": "recorded",
# "firmware_version_captured": "2.1.4", "ota_version_captured": "2.1.4" }The firmware version at the moment of the fault is frozen into the incident record by the server. That is why an incident from a device that has since been updated still tells you which build broke.
incident_type is a free string, uppercased on write. The recognised set is CRASH, HARDWARE_ERROR, NETWORK_ERROR, LOW_BATTERY, LOW_STORAGE, HIGH_TEMP, OTA_FAIL, UNEXPECTED_REBOOT, OFFLINE and UNKNOWN, plus WS_CONNECT_FAIL, AUDIO_UNDERRUN and TLS_FAIL in use by existing firmware. Adding your own is fine — it will group and report correctly — but keep them stable, because dashboards aggregate by this string.
severity is one of CRITICAL, HIGH, MEDIUM or LOW. CRITICAL and HIGH immediately push the device's health_status to CRITICAL and DEGRADED respectively.
Remote commands
GET /api/devices/{mac}/commands/pending
PATCH /api/devices/{mac}/commands/{command_id}/ack
PATCH /api/devices/{mac}/commands/{command_id}/result{ "commands": [
{ "id": 88,
"command_type": "REBOOT",
"payload_json": null,
"issued_at": "2026-09-10T10:00:00Z",
"expires_at": "2026-09-11T10:00:00Z" }
]}Command types are REBOOT, TRIGGER_OTA, CHANGE_MODE, FACTORY_RESET, MUTE, UNMUTE, COLLECT_LOGS and PING. payload_json is free-form, so a CHANGE_MODE carrying a communication_mode is how a unit is repurposed in the field without touching it.
{ "command_type": "CHANGE_MODE",
"payload_json": { "communication_mode": "websocket" } }The lifecycle is PENDING → SENT → ACKNOWLEDGED → COMPLETED or FAILED, with EXPIRED for anything past expires_at, which defaults to 24 hours.
curl -X PATCH https://api.yourcompany.com/api/devices/AA:BB:CC:DD:EE:FF/commands/88/ack
curl -X PATCH https://api.yourcompany.com/api/devices/AA:BB:CC:DD:EE:FF/commands/88/result \
-H "Content-Type: application/json" \
-d '{"status":"COMPLETED","response_json":{"uptime_before":84213}}'Anything other than COMPLETED is recorded as FAILED.
Recommended timers
| Activity | Interval | Why |
|---|---|---|
| Heartbeat | 30 s | The offline threshold is 60 s |
| Command poll | When pending_commands > 0, else 60 s | The heartbeat response tells you when it matters |
| OTA check | Boot, then hourly | Every check that finds an update creates a tracking row |
| Print-job poll (mode C) | 3 s | Kitchen latency; the claim timeout assumes it |
| Notification poll (mode B) | 2–5 s | Perceived responsiveness |
| WebSocket ping/pong (mode C) | 20 s server ping, 10 s pong deadline | Proxy idle timeouts |
| Reconnect backoff | 1→2→4→8→16→60 s, ±25 % jitter | Reconnect storms are real |
A reference fleet-plane loop
This is the whole fleet plane in one worked example. It is written in Python for readability; the same shape is what a device implements natively, and the same shape is what you implement server-side if you are running your own backend.
import random, time, requests
HOST = "https://api.yourcompany.com"
MAC = "AA:BB:CC:DD:EE:FF"
def heartbeat(telemetry):
r = requests.post(f"{HOST}/api/devices/heartbeat",
json={"mac_address": MAC, **telemetry}, timeout=10)
if r.status_code == 403:
raise SystemExit("device BLOCKED — terminal, do not retry")
r.raise_for_status()
return r.json()
def drain_commands():
r = requests.get(f"{HOST}/api/devices/{MAC}/commands/pending", timeout=10)
for cmd in r.json().get("commands", []):
cid = cmd["id"]
# Acknowledge first. A crash after this point loses the command either
# way, and acknowledging late is how destructive commands get replayed.
requests.patch(f"{HOST}/api/devices/{MAC}/commands/{cid}/ack", timeout=10)
try:
result = execute(cmd)
status = "COMPLETED"
except Exception as exc:
result, status = {"error": str(exc)}, "FAILED"
requests.patch(f"{HOST}/api/devices/{MAC}/commands/{cid}/result",
json={"status": status, "response_json": result}, timeout=10)
def report(incident_type, severity, description, **metadata):
requests.post(f"{HOST}/api/devices/{MAC}/incident",
json={"incident_type": incident_type, "severity": severity,
"description": description, "metadata_json": metadata},
timeout=10)
backoff, last_ota = 1, 0
while True:
try:
reply = heartbeat(read_telemetry())
backoff = 1
if reply.get("pending_commands", 0) > 0:
drain_commands()
if time.time() - last_ota > 3600: # hourly, never per-minute
check_ota(); last_ota = time.time()
time.sleep(30) # not 60 — 60 races the sweep
except requests.RequestException as exc:
report("NETWORK_ERROR", "MEDIUM", str(exc))
time.sleep(backoff * (0.75 + random.random() * 0.5)) # ±25 % jitter
backoff = min(backoff * 2, 60)Something wrong or missing on this page? Tell us.

