A lightweight IoT observability and firmware management platform for ESP32 (and ESP8266 via Arduino) fleets. It ingests crash dumps, streams device telemetry over CBOR, and delivers OTA updates via cohort-based rollouts.
Built with Django 5, Celery, PostgreSQL, Redis, and S3-compatible storage (MinIO locally). A React dashboard visualizes fleet health, crashes, and firmware releases.
| Capability | Agent endpoint | Description |
|---|---|---|
| Crash reports | POST /api/v1/agent/crash-report/ |
Binary core dump upload → S3 → async xtensa-esp32-elf-gdb symbolication |
| Heartbeats | POST /api/v1/agent/heartbeat/ |
CBOR metrics (heap, RSSI, battery) buffered in Redis, flushed to Postgres every 60s; response may include a one-shot remote command (e.g. reboot) |
| OTA pull | GET /api/v1/agent/ota-check/ |
302 redirect to a signed firmware URL when a newer build exists for the device cohort |
| Remote reboot | (via heartbeat response) | Dashboard queues reboot; device applies on next heartbeat (~60s) with command_id dedup |
| Device registration | Dashboard UI + API | Register by MAC; per-device X-Device-Token required on all agent calls |
Devices are identified by their 6-byte factory MAC as a 12-character lowercase hex string (e.g. 240ac4a1b2c3). They must be registered in the dashboard before the agent API accepts traffic.
Fleet Manager splits traffic into two planes: an agent plane (ESP32 devices, low bandwidth, CBOR/binary) and an operator plane (browser dashboard and Django admin).
flowchart TB
subgraph devices [ESP32 / ESP8266 Fleet]
ESP[Device SDK]
end
subgraph edge [Operator]
UI[React Dashboard]
ADM[Django Admin]
end
subgraph docker [Docker Compose Stack]
NGX[frontend nginx :61294]
WEB[web Gunicorn :52841]
CW[celery-worker]
CB[celery-beat]
PG[(PostgreSQL)]
RD[(Redis)]
S3[(MinIO S3)]
end
ESP -->|CBOR heartbeat crash OTA| WEB
UI -->|/api proxy| NGX
NGX -->|REST| WEB
ADM --> WEB
WEB --> PG
WEB --> RD
WEB --> S3
CW --> PG
CW --> RD
CW --> S3
CB -->|schedule flush| CW
| Layer | Technology | Role |
|---|---|---|
| Device SDK | C / ESP-IDF | Heartbeats (CBOR), crash dump upload, OTA poll via esp_http_client |
| Agent API | Django (agents/) |
Device-facing endpoints; returns 503 on DB/storage failure so devices back off |
| Dashboard API | DRF (dashboard/) |
JSON for the React UI — devices, metrics, crashes, firmware, cohorts |
| Domain | Django (fleet/) |
Models, business logic in fleet/services/, Celery tasks |
| Web server | Gunicorn + WhiteNoise | WSGI app; serves collected static files (admin CSS) when DEBUG=false |
| Task queue | Celery + Redis | Async symbolication; periodic heartbeat flush from Redis stream → Postgres |
| Metadata DB | PostgreSQL | Devices, cohorts, firmware releases, heartbeat rows, crash report metadata |
| Buffer | Redis Stream | High-frequency heartbeats land here before bulk insert (default: 60s flush) |
| Object storage | MinIO (S3 API) | Core dumps, .elf symbols, firmware .bin files; presigned URLs for OTA |
| Frontend | React + Vite build → nginx | SPA on port 61294; proxies /api and /admin to Gunicorn |
sequenceDiagram
participant D as ESP32
participant W as Agent API
participant R as Redis Stream
participant C as Celery Beat
participant P as PostgreSQL
D->>W: POST /agent/heartbeat/ (CBOR)
W->>R: XADD fleet:heartbeats:stream
alt pending remote command
W-->>D: 200 {"status":"ok","command":"reboot","command_id":"42"}
else no command
W-->>D: 200 {"status":"ok"}
end
C->>C: flush every 60s
C->>R: XRANGE + XDEL batch
C->>P: bulk_create HeartbeatMetric
Views stay thin: decode CBOR, validate fields, enqueue. No per-packet Postgres write.
sequenceDiagram
participant D as ESP32
participant W as Agent API
participant S as MinIO
participant Q as Celery Worker
participant G as xtensa-esp32-elf-gdb
participant P as PostgreSQL
D->>W: POST /agent/crash-report/ (binary)
W->>S: PUT core dump
W->>P: CrashReport pending
W->>Q: symbolicate_crash_report.delay
W-->>D: 202 Accepted
Q->>S: GET dump + optional ELF
Q->>G: batch backtrace
Q->>P: symbolicated_trace
Panic-path firmware must not use malloc() or printf() — capture registers/stack in RTC/SPIFFS, upload after reboot.
sequenceDiagram
participant D as ESP32
participant W as Agent API
participant P as PostgreSQL
participant S as MinIO
D->>W: GET /agent/ota-check/?hw_version&fw_version
W->>P: Device + cohort + FirmwareRelease
alt newer firmware for cohort + hw
W->>S: presigned GET URL
W-->>D: 302 Location + X-Firmware-Version
else up to date
W-->>D: 204 No Content
end
OTA matching uses semantic versioning per hw_version and cohort. Devices without a cohort never receive an update.
| Model | Purpose |
|---|---|
Cohort |
Named rollout group (e.g. stable, canary) |
Device |
Primary key = 12-char MAC hex; token_hash for agent auth; tracks hw_version, fw_version, last_seen_at |
HeartbeatMetric |
Time-series rows: heap, min heap, RSSI, battery (indexed columns, not JSON) |
CrashReport |
S3 keys, panic reason, symbolication status/trace |
FirmwareRelease |
Version + hw_version + cohort + s3_key for the binary in object storage |
TelemetryThresholdConfig |
Per-HW version alert thresholds (heap, RSSI, battery, CPU temp) for charts and breach events |
DeviceCommand |
Queued remote actions (reboot) delivered once per heartbeat |
FleetEvent |
Connectivity, threshold breaches, OTA, crash, and reboot events for the Events tab |
| Container | Image / build | Notes |
|---|---|---|
postgres |
postgres:16-alpine |
Persistent volume postgres_data |
redis |
redis:7-alpine |
Celery broker + heartbeat stream |
minio |
minio/minio |
API + console ports exposed on host |
minio-init |
minio/mc |
One-shot: creates fleet-manager bucket, then exits |
web |
Dockerfile → Gunicorn |
migrate + collectstatic on start; WhiteNoise for /static/ |
celery-worker |
same image | Runs symbolicate_crash_report and flush tasks |
celery-beat |
same image | Schedules heartbeat stream flush (60s default) |
frontend |
frontend/Dockerfile |
Multi-stage: npm run build → nginx serves SPA + API proxy |
Internal DNS names (postgres, redis, minio, web) are used inside the compose network. Host ports are configurable via .env (WEB_PORT, FRONTEND_PORT, etc.).
- Thin views, fat services — request parsing in views; OTA rules, storage, and symbolication in
fleet/services/. - Structured telemetry — explicit columns on
HeartbeatMetricfor PostgreSQL indexing and partitioning-friendly schemas. - Write buffering — Redis absorbs heartbeat spikes; Celery bulk-writes to Postgres.
- Fail-safe agents — storage or DB errors surface as 503, not opaque 500s, to protect device batteries and flash wear.
fleet_manager/
├── core/ # Django project (settings, Celery, URLs)
├── fleet/ # Models, services, Celery tasks, migrations
├── agents/ # Device-facing HTTP API
├── dashboard/ # REST API for the web UI
├── frontend/ # React dashboard (Vite dev / Docker nginx prod)
├── firmware/
│ ├── sdk/ # Shared C headers (reference)
│ └── examples/ # Arduino + ESP-IDF ready-to-flash agents
├── images/ # README screenshots (hardware setup, dashboard, OTA)
├── scripts/ # stop-local.sh, simulate_heartbeat.py
├── docker/ # Gunicorn entrypoint, nginx config for frontend image
├── docker-compose.yml # Full production stack (local Docker)
├── manage.py
├── requirements.txt
└── README.md
Requires Docker Desktop running.
# Stop any local runserver / Vite on the same ports
./scripts/stop-local.sh
cp -n .env.example .env # skip if .env exists
# Ensure .env has DEV_USE_SQLITE=false for Docker
docker compose up --build -d
docker compose exec web python manage.py seed_demo
# If seed_demo issues a device token, copy it for simulate_heartbeat.py / firmware secrets.hOn first stack start, ensure_dashboard_admin creates the dashboard user from DASHBOARD_ADMIN_USERNAME / DASHBOARD_ADMIN_PASSWORD in .env.
| Service | URL (default host ports) |
|---|---|
| Dashboard (nginx + React) | http://localhost:61294 |
| API (Gunicorn) | http://localhost:52841 |
| Django admin | http://localhost:52841/admin/ |
| MinIO API (S3) | http://localhost:38472 |
| MinIO console | http://localhost:41908 |
| Postgres | localhost:47291 |
| Redis | localhost:58163 |
MinIO login (console at port 41908 — not admin):
| Field | Value |
|---|---|
| Username | minioadmin |
| Password | minioadmin |
Django and Celery use the same keys via .env (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY). The minio-init container creates the fleet-manager bucket on first stack start.
Stack: Gunicorn (not runserver), Postgres, Redis, MinIO, Celery worker + beat, built frontend behind nginx. Host ports are configurable in .env to avoid local clashes.
docker compose logs -f web
docker compose exec web python manage.py createsuperuser
docker compose downYou do not need the full docker compose up stack, but Django expects backing services unless you use the SQLite shortcut below.
Run Postgres, Redis, and MinIO in Docker; run Django locally:
cp .env.example .env
docker compose up postgres redis minio minio-init -d
source .venv/bin/activate # or: python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python manage.py migrate
python manage.py seed_demo
python manage.py runserver 52841Connection refused on port 47291 means Postgres is not running — start it with the docker compose up postgres ... line above.
Good for admin, dashboard API, and migrations. Agent heartbeats still need Redis; crash uploads need MinIO.
cp .env.example .env
echo 'DEV_USE_SQLITE=true' >> .env
python manage.py migrate
python manage.py runserverDefault dev server URL: http://127.0.0.1:8000/ (not 52841 unless you pass that port).
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env
# Requires Option A infra, or your own Postgres on the port in DATABASE_URL
python manage.py migrate
python manage.py seed_demo
python manage.py runserver 52841Celery (separate terminals):
celery -A core worker --loglevel=info
celery -A core beat --loglevel=infocd frontend
npm install
npm run devOpen http://localhost:61294 — Vite proxies /api to Django (WEB_PORT, default 52841).
pytestFull guides: firmware/examples/README.md
cp firmware/examples/arduino/FleetManagerAgent/secrets.example.h \
firmware/examples/arduino/FleetManagerAgent/secrets.h
# Edit secrets.h: Wi-Fi, FLEET_API_HOST=<your LAN IP>, FLEET_DEVICE_TOKEN=<from dashboard>-
Connect the ESP32 to your computer with USB (if there is a power switch, make sure it is set to OFF). A WROVER module is shown below; any ESP32 dev board works.
-
In Arduino IDE 2.x, open
firmware/examples/arduino/FleetManagerAgent/FleetManagerAgent.ino, select ESP32 Wrover Module (or your board), choose the serial port, and click Upload. -
Open Serial Monitor @ 115200 baud and note Device ID (Wi‑Fi MAC). Register it in the dashboard (+ Register), copy the one-time token into
FLEET_DEVICE_TOKENinsecrets.h, and upload again. -
On success you should see heartbeats returning HTTP 200 (not 401).
Example output:
=== Fleet Manager Arduino Agent ===
Chip: ESP32-D0WD
Device ID: 30aea4c2cdc4
Device Name: esp32-device
HW 1.0 FW 1.1.3
API: http://192.168.68.108:52841
OTA poll every 300 s
WiFi connecting to Argo_IoT....
WiFi OK
IP=192.168.68.110 RSSI=-51
[crash-report] accepted
[heartbeat] device_id=30aea4c2cdc4 sending...
[heartbeat] OK device_id=30aea4c2cdc4 heap=226920 min_heap=222712 rssi=-51 batt=21 mV cpu=41C
[ota] checking for update (fw 1.1.3)...
[ota] no update
More detail: firmware/examples/arduino/FleetManagerAgent/README.md
Same agent API as ESP32, but a separate sketch and .bin. Use HW version 8266 in the dashboard so OTA never mixes chip families.
cp firmware/examples/arduino/FleetManagerAgent8266/secrets.example.h \
firmware/examples/arduino/FleetManagerAgent8266/secrets.h
# Edit secrets.h: Wi-Fi, FLEET_API_HOST, FLEET_DEVICE_TOKEN, keep FLEET_HW_VERSION "8266"- Install board package esp8266 by ESP8266 Community (Boards Manager).
- Open
firmware/examples/arduino/FleetManagerAgent8266/FleetManagerAgent8266.ino. - Select your board (e.g. LOLIN(WEMOS) D1 R2 & mini) and an OTA-capable flash size.
- Upload; note Device ID in Serial Monitor, register in the dashboard, set
FLEET_DEVICE_TOKENinsecrets.h, and upload again. - Expect
HW 8266and heartbeats with HTTP 200. - For OTA: export
FleetManagerAgent8266.ino.bin, deploy with HW version8266in the Firmware tab.
Example output:
=== Fleet Manager Arduino Agent (ESP8266) ===
Chip ID: 0x755901
Device ID: 600194755901
Device Name: esp8266-device
HW 8266 FW 1.0.1
API: http://192.168.68.108:52841
OTA poll every 300 s
WiFi connecting to Argo_IoT........
WiFi OK
IP=192.168.68.123 RSSI=-60
[crash-report] accepted
[heartbeat] device_id=600194755901 sending...
[heartbeat] OK device_id=600194755901 heap=49248 min_heap=49248 rssi=-59 batt=0 mV
[ota] checking for update (fw 1.0.1)...
[ota] no update
Full guide: firmware/examples/arduino/FleetManagerAgent8266/README.md
Use this when you want to push a new build from the dashboard Firmware tab (Deploy OTA Update) instead of flashing over USB.
Before exporting, set Tools → Partition Scheme to an OTA-capable layout (for example Default 4MB with spiffs or Minimal SPIFFS). OTA needs enough flash to hold the running app and the incoming image at the same time.
Bump FLEET_FW_VERSION in secrets.h so the platform treats the build as newer than what devices report today.
- Open
firmware/examples/arduino/FleetManagerAgent/FleetManagerAgent.ino. - Tools → Board — select your ESP32 board (e.g. ESP32 Wrover Module).
- Sketch → Export Compiled Binary (compiles without uploading).
- Sketch → Show Sketch Folder (Ctrl+K / Cmd+K).
- Open the new
build/folder, then the board subfolder (e.g.build/esp32.esp32.esp32wrover/). - Use
FleetManagerAgent.ino.binfor OTA upload in the dashboard.
- Open the sketch and select the board under Tools → Board.
- Sketch → Export Compiled Binary (Ctrl+Alt+S / Cmd+Option+S).
- When compilation finishes, Sketch → Show Sketch Folder.
- On 1.8.x the
.binis often next to the.inofile; on newer toolchains it is underbuild/...as above.
Arduino exports several binaries. For Fleet Manager OTA, upload only:
| File | Use for dashboard OTA? |
|---|---|
FleetManagerAgent.ino.bin |
Yes — application firmware |
FleetManagerAgent.ino.partitions.bin |
No — partition table (only when changing flash layout) |
FleetManagerAgent.ino.bootloader.bin |
No — USB/full flash workflows |
boot_app0.bin |
No |
FleetManagerAgent.ino.merged.bin |
No — full-chip image, not for OTA pull |
Upload one file per deployment: FleetManagerAgent.ino.bin.
- OTA code must be in the binary you ship. The sketch must include OTA check/apply logic (this repo’s agent does). If you flash a build without OTA support, the next update requires USB again.
- Version numbers: set Version in the dashboard to match
FLEET_FW_VERSIONinsecrets.h(e.g.1.1.0). Set HW version to matchFLEET_HW_VERSION(e.g.1.0for ESP32,8266for ESP8266). - First install vs OTA: use USB Upload once to get OTA-capable firmware on the device; after that, use the dashboard for subsequent updates.
- Polling interval: the Arduino agent checks OTA about every 5 minutes (
FLEET_OTA_MS) unless overridden insecrets.h. After Send OTA, wait for the next poll or reboot the device to trigger sooner. - Presigned URLs: if OTA download fails from the device, set
AWS_S3_PUBLIC_ENDPOINT_URLin.envto your LAN MinIO API URL (see.env.example). - Deployment stuck at
pending/offered: this usually means the device can reach/api/v1/agent/ota-check/but cannot fetch the binary URL fromLocation. A common misconfiguration is signed URLs usinghttp://minio:9000/...(Docker-internal host). ConfigureAWS_S3_PUBLIC_ENDPOINT_URL=http://<your-lan-ip>:<MINIO_API_HOST_PORT>(for examplehttp://192.168.68.108:38472) and restartweb.
The flow below was verified on a real ESP32 over Wi‑Fi: export the app binary, deploy from the Firmware tab, device downloads from MinIO, reboots on the new version, and the dashboard marks the target updated.
-
Firmware tab — open Deploy OTA Update, set version/HW, and pick targets (or use Sketch → Export Compiled Binary first).
-
Choose the app binary — select
FleetManagerAgent.ino.binfrom the sketchbuild/esp32.esp32.*/folder (notmerged.binor bootloader). -
Queue deployment — enter the new version (e.g.
1.1.2), matching HW (e.g.1.0), select device(s), click Send OTA. -
Device applies update — Serial Monitor shows presigned URL download,
update applied, reboot, and the newFWline after boot.Example (abbreviated):
[ota] checking for update (fw 1.0.0)... [ota] update available: http://192.168.68.108:38472/fleet-manager/firmware/manual/1.1.2/... [ota] version: 1.1.2 [ota] downloading: http://192.168.68.108:38472/... [ota] update applied, rebooting to 1.1.2 [ota-report] updated sent ... HW 1.0 FW 1.1.2 -
Dashboard confirms — Recent Deployments shows
completed; the device badge showsupdated.
cd firmware/examples/esp-idf/fleet_agent
idf.py set-target esp32
idf.py menuconfig # Fleet Manager Agent → Wi-Fi + API URL
idf.py -p PORT flash monitordocker compose up -d
# Register 240ac4dead01 in the dashboard (or use seed_demo token), then:
FLEET_DEVICE_TOKEN=<token> python3 scripts/simulate_heartbeat.py --device-id 240ac4dead01Open http://localhost:61294 — log in with DASHBOARD_ADMIN_* credentials. Allow ~60s for Redis flush, or check curl http://localhost:52841/api/v1/dashboard/devices/ (requires session auth).
Important: ESP32 must use your PC's LAN IP (e.g. 192.168.1.42:52841), not 127.0.0.1.
Dashboard views from a local run:
| View | Screenshot |
|---|---|
| Devices overview | ![]() |
| Events and thresholds | ![]() |
| Firmware / OTA tab | ![]() |
For a full USB → export → deploy → device reboot → dashboard completed walkthrough with Serial Monitor proof, see OTA through the dashboard (end-to-end) above.
The SPA at http://localhost:61294 (or FRONTEND_PORT) requires login (session cookie). Credentials come from DASHBOARD_ADMIN_USERNAME / DASHBOARD_ADMIN_PASSWORD in .env, created on stack start via ensure_dashboard_admin.
| Tab | What you can do |
|---|---|
| Devices | + Register new devices (one-time agent token), edit label, rotate token, delete device, per-device telemetry charts, zoom in/out (up to ~1 week of history), Older / Newer / Latest navigation, Restart device (online devices only) |
| Events | Paginated event log (50 per page) with filters: device, time range, severity, metric (threshold breaches) |
| Firmware | Upload .bin, deploy OTA to selected devices (match HW version — 1.0 for ESP32, 8266 for ESP8266) |
| Settings | Per device-type thresholds (ESP32 vs ESP8266) that drive chart red lines and threshold_breach alerts |
Device registration: Click + Register, enter the 12-char MAC (from Serial or board), optional label and HW version. Copy the one-time token into FLEET_DEVICE_TOKEN in firmware secrets.h and reflash via USB. Use Rotate agent token in the edit (✎) modal if the token is lost. Delete device removes the fleet record and telemetry; the same MAC can be registered again later.
Remote restart: On the Devices tab, select an online device and click Restart device. The platform queues a reboot; the agent receives it on the next heartbeat (~60s), stores the command_id in EEPROM/NVS so the same command cannot reboot the device twice, waits 1s, then calls ESP.restart() / esp_restart() outside the HTTP handler.
Telemetry charts: Default window is ~1 hour of points; use Zoom out to see up to ~7 days (requires that much history in Postgres). Use ← Older to pan backward through retained data.
Events filters: Metric applies to threshold_breach events (heap_free_bytes, wifi_rssi_dbm, battery_voltage_mv, cpu_temperature_c). Combine with severity to narrow warnings vs critical issues.
If Serial shows [heartbeat] HTTP 400 or [crash-report] HTTP 400 while Wi‑Fi is connected, Django is usually rejecting the Host header. The device calls http://<your-lan-ip>:52841, so that IP must be listed in ALLOWED_HOSTS.
-
Find your machine's LAN IP (macOS: System Settings → Network, or
ipconfig getifaddr en0). -
Add it to
.env(comma-separated, no spaces):ALLOWED_HOSTS=localhost,127.0.0.1,web,192.168.68.108
.env.exampleincludes a placeholder IP for this reason. -
Recreate the web container so the env is picked up:
docker compose up -d web
-
Reset the ESP32. You should see
[heartbeat] HTTP 200and, if test crash is enabled,[crash-report] HTTP 202.
A 400 with a short HTML body is typical for DisallowedHost. A 400 with JSON like Missing X-Device-Id means the host is allowed but headers or CBOR are wrong — check X-Device-Id and that fleet_cbor.h uses extern "C" for Arduino builds.
401 / 403 on heartbeat usually means the device is not registered or X-Device-Token is missing or wrong — register in the dashboard and update secrets.h, then reflash via USB (OTA cannot fix a missing token header).
Devices must be registered in the dashboard before they can call the agent API. Registration issues a per-device token (shown once). Send it on every agent request:
X-Device-Token: <token from dashboard Register device>| Code | Meaning |
|---|---|
| 401 | Missing or invalid X-Device-Token |
| 403 | Device ID not registered (or no token provisioned — rotate token in dashboard) |
POST /api/v1/agent/heartbeat/
Content-Type: application/cbor
X-Device-Id: 240ac4a1b2c3
X-Device-Token: <device token>
X-Hw-Version: 1.0
X-Fw-Version: 1.0.0CBOR map fields:
heap_free(uint) — free heap bytesheap_min_free(uint) — minimum ever free heapwifi_rssi(int) — RSSI in dBmbattery_mv(uint, optional) — battery voltage in millivoltscpu_temp_c(int, optional) — CPU temperature in Celsius
Response (JSON):
{"status": "ok"}When a remote command is pending (e.g. dashboard Restart device):
{
"status": "ok",
"command": "reboot",
"command_id": "42"
}The agent must persist command_id (EEPROM on ESP8266, NVS on ESP32 / ESP-IDF) and ignore duplicates. Reboot is deferred to the main loop with a ~1s delay after the HTTP transaction completes.
POST /api/v1/agent/crash-report/
Content-Type: application/octet-stream
X-Device-Id: 240ac4a1b2c3
X-Device-Token: <device token>
X-Panic-Reason: Guru Meditation Error: LoadProhibited
X-Elf-S3-Key: firmware/1.0.0/build.elf # optional, for symbolicationBody: raw binary dump. Response 202 with { "id", "status": "accepted" }.
GET /api/v1/agent/ota-check/?device_id=240ac4a1b2c3&hw_version=1.0&fw_version=1.0.0
X-Device-Token: <device token>- 204 — no update
- 302 —
Locationheader is a presigned firmware URL;X-Firmware-Versionheader set
Database or storage failures return 503 so devices back off instead of retrying indefinitely.
Base path: /api/v1/dashboard/ — all endpoints except GET /health/ and auth routes require a logged-in session.
| Endpoint | Description |
|---|---|
POST /auth/login/ |
Dashboard login (username, password) |
POST /auth/logout/ |
End session |
GET /auth/session/ |
Current user |
GET /stats/ |
Fleet summary and default threshold snapshot |
GET /devices/ |
Device list |
POST /devices/register/ |
Register device by MAC; returns one-time token |
DELETE /devices/{device_id}/ |
Remove device and cascade telemetry/events |
POST /devices/{device_id}/token/ |
Rotate agent token; returns new one-time token |
GET /devices/{device_id}/metrics/ |
Heartbeat history (limit, optional end cursor for paging backward in time) |
POST /devices/{device_id}/commands/ |
Queue remote command, e.g. {"command":"reboot"} |
PATCH /devices/{device_id}/label/ |
Update device label |
GET /events/ |
Paginated events (page, device_id, hours, severity, metric) |
GET /crashes/ |
Crash reports |
GET /firmware/ |
Firmware releases |
GET /cohorts/ |
Rollout cohorts |
GET /ota/deployments/ |
OTA deployment history |
POST /ota/deployments/ |
Upload .bin, version, hw version, and target device_ids (multipart form) |
GET /thresholds/ |
List per-HW threshold profiles (?hw_version=8266 for one) |
POST /thresholds/ |
Save thresholds for a given hw_version |
Events query parameters:
| Parameter | Example | Purpose |
|---|---|---|
page |
2 |
Page number (50 events per page) |
device_id |
240ac4a1b2c3 |
Filter to one device |
hours |
24 |
Last N hours (max 30 days) |
severity |
warning |
info, warning, or critical |
metric |
heap_free_bytes |
Threshold breach field in details.metric |
Metrics query parameters:
| Parameter | Example | Purpose |
|---|---|---|
limit |
48 |
Number of heartbeat points (up to 10080 ≈ 1 week at 1/min) |
end |
ISO timestamp | Return points strictly before this time (chart Older navigation) |
From the dashboard Firmware tab you can upload FleetManagerAgent.ino.bin or FleetManagerAgent8266.ino.bin, select devices, and queue an update without using Django admin. Cohort-based rollouts via admin still work for devices assigned to a cohort.
Reference library: firmware/sdk/README.md.
Flashable examples: firmware/examples/.
See .env.example for DATABASE_URL, REDIS_URL, S3/MinIO credentials, heartbeat flush interval, and online/offline tuning:
HEARTBEAT_EXPECTED_INTERVAL_SECONDS(default60)HEARTBEAT_MISSED_ITERATIONS(default3)
Devices are considered offline when no heartbeat arrives for EXPECTED_INTERVAL * MISSED_ITERATIONS seconds.
Telemetry charts and threshold_breach events use per-HW profiles (editable in Settings). Env defaults when no DB row exists:
| Variable | Default | Notes |
|---|---|---|
THRESHOLD_HEAP_FREE_BYTES_MIN |
50000 |
ESP32-class devices (hw_version 1.0) |
THRESHOLD_HEAP_FREE_BYTES_MIN_8266 |
35000 |
ESP8266 — much less RAM than ESP32 |
THRESHOLD_WIFI_RSSI_DBM_MIN |
-75 |
Both families |
THRESHOLD_BATTERY_VOLTAGE_MV_MIN |
3600 |
Both families |
THRESHOLD_CPU_TEMPERATURE_C_MAX |
75 |
Both families |
Use Settings → Device type to tune ESP32 (1.0) and ESP8266 (8266) separately so 8266 devices are not flagged for low heap at ESP32 limits.
Free for personal, educational, and internal evaluation use.
Commercial use is not permitted without explicit permission from the project owner.
Recommended license text for this policy: PolyForm Noncommercial 1.0.0
(https://polyformproject.org/licenses/noncommercial/1.0.0/)
Add a LICENSE file in the repository root with the full license text before distribution.










