Field manual · dustyphone
One radio at a time.
A DustyCam field camera is asleep almost all the time. dustyphone is the owner's Android app for the few minutes it is awake: provision a new camera in the field, read and edit its tuning, and look at what it has recorded — with no Wi-Fi and no cell coverage. It does that over Bluetooth Low Energy, hands the camera to the phone's own Wi-Fi hotspot when there is real data to move, and brings it back. The camera never runs both radios at once.
Status as of 2026-09-16. Everything on this page is taken from the plan, the status log and the source linked at the bottom; the numbers are bench measurements on a XIAO ESP32S3 Sense (xiaocam1) and a Pixel 6, not estimates.
The system
Three parties, and the phone is the network.
The camera has one BLE stack and one Wi-Fi stack and only ever holds one of them up. The app talks to the camera; the camera talks to the server; the app never talks to the server.
Why sequential radios. On the ESP32-S3 the Bluetooth controller, the Wi-Fi driver and the camera sensor all compete for the same internal SRAM. Running BLE and Wi-Fi together would turn that budget into a measurement instead of a guarantee. The design rule (decided 2026-09-15) is the ordinary IoT split: BLE for onboarding and control, Wi-Fi for data, never both. Switching is a full stop and de-initialisation of one stack before the other is started — nimble_port_stop/nimble_port_deinit down to an idle BT controller, and a full esp_wifi stop and deinit on the way back — so the live sets are only ever BLE + camera or Wi-Fi + camera + TLS. Timer wakes never touch a radio at all; they judge and spool to the SD card.
Where the radios come from. A button press or a cold boot opens a window: the camera advertises BLE as dc-<device> for ble_adv_s (120 s; 600 s when unprovisioned). A linked phone extends the window and can ask for a handoff to Wi-Fi at any time. If no phone links before the window ends, the camera hands itself to Wi-Fi and runs the ordinary contact — so the app is optional and the button still works on its own. Every window ends with both radios de-initialised before deep sleep.
The phone is the network. The camera's Wi-Fi credentials are the owner's phone hotspot (identity, tier 1). Whether the app asked for the handoff or the camera did it alone, the camera joins that hotspot; the phone routes it over cell to the sensorhub's Funnel endpoint (TLS, X-Token) and the blob gate forwards to ingest. The camera is the config sync agent: an edit made over BLE is pushed at the next contact, and the server wins a real conflict.
Which cameras. The BLE path is the XIAO ESP32S3 (xiaocam1). The OpenMV N6's STM32N657 has no BLE radio, so it uses only the server path on the right of the map (its LoRa reporting is a separate channel not shown here).
Ports and endpoints from docs/camera_standard.md §4; radio states from radio.c; app states from Session.java.
The BLE session
One service, framed JSON, an owner key.
A custom GATT service with one write characteristic (cmd) and three notify streams (rsp, data, evt). Requests and replies are JSON in the same shapes the HTTP control plane uses; images travel as framed binary.
connectGatt to the camera's fixed static address; status-133 retry in GattQueue.7d1e0001-… and its five characteristics.rsp, data and evt.requestMtu(517), then high connection priority.info (no auth): device, version, cfg, provisioned flag, nonce, proto — unknown proto is refused.hello exchanges nonces; auth sends HMAC-SHA256(ble_key, camera nonce ‖ phone nonce).request(op, args) correlated by a rolling id 1…255; id 0 is reserved for events.What the framing buys. BLE notifications have no message boundaries and no request correlation. Every write and every notify carries a 4-byte header — [id u8][flags u8][idx u16 LE] — so a reply can be matched to its request, a long message can be split across fragments in order, and a stream of thumbnails cannot be confused with a status reply. The bench found that 514-byte notifies at MTU 517 never reached the Pixel 6 even though NimBLE reported success, so both sides cap frames at MTU 247, i.e. 244 B of value; at that size fragments arrive 100 % of the time at 65–85 KB/s. Binary transfers prefix the first fragment with [total u32][crc32 u32] and there is no per-fragment ACK: the link layer is ordered and reliable, so the receiver checks size and CRC once at the end and re-requests on a mismatch. Limits: request ≤ 4 KB, response ≤ 8 KB, data ≤ 512 KB. The same code exists twice — ble_frame.c in the firmware and Framer.java in the app — and both are host-tested without hardware.
What the auth buys. The camera has no display, so LE pairing could only be “Just Works”: unauthenticated, plus pairing dialogs and bond-loss failures. Instead there is one 32-byte owner key ([ble] key in ~/.dusty/secrets.toml, imported into the phone once from dusty_phone.json) and an HMAC-SHA256 challenge; NimBLE's security manager is compiled out. Every op except hello and auth answers err:auth until the challenge passes. The one unauthenticated write is prov.set on an unprovisioned camera (advertising dc-new-…), accepted once, in plaintext, inside its physical-presence window; on a provisioned camera the same op must arrive in an AES-256-GCM envelope under a session key derived from the two nonces.
What runs over it. Status, the tuning schema and config (cfg.schema, cfg.get, cfg.set), provisioning, time sync, a Wi-Fi scan, the spool listing, 200×150 thumbnails decoded from the SD card without waking the sensor, a one-shot preview and a shoot-to-spool, events (led, stage, cfg, bye) — and the two handoff ops, wifi.up and contact. Full-resolution frames over BLE exist only as a slow fallback; they belong to the Wi-Fi phase.
From docs/phone_app_plan.md §2 and the matching ble_frame.h / Framer.java. The 244 B cap is a bench finding, not a design choice.
The handoff loop
Bluetooth out, Wi-Fi in, and back by address.
Thumbnails fit over BLE; full frames, an MJPEG stream and a drain do not. The app asks the camera to switch, follows it onto the hotspot, and asks for it back.
Going out. Tapping View over Wi-Fi (or Contact) sends wifi.up (or contact). The camera replies with {"handoff":"wifi", "expect_ip", "back_in_s"}, sends the event bye reason:handoff, terminates the link, and only then tears NimBLE down; radio.c asserts the BT controller is idle in the log at every transition. The app moves to HANDOFF and starts listening on UDP :8267.
Finding it. Once the camera has joined the hotspot and its control plane is up it broadcasts a beacon once a second — {"dc":1,"device","ip","port"} — so the owner never types an IP. The first GET /status that answers puts the app in WIFI; on the bench that is about 2 s after the tap. In WIFI the app polls /status every 2 s for radio, ble_back_in_s and the drain counters, and can fetch /spool, /thumb and /stream. HTTP goes through CamHttp, which binds to a network only when that network's subnet actually contains the camera — a Pixel can be a Wi-Fi client on one subnet and a hotspot on another at the same time.
Coming back. Bring back to Bluetooth sends POST /ble; the camera answers {"ok":true,"radio":"ble","in_s":2}, stops the HTTP server, stops and de-initialises Wi-Fi, re-initialises NimBLE and advertises on the same fixed address — measured 12 ms after Wi-Fi off. The app, now RETURNING, does a direct connectGatt to that address (3–5 s) rather than scanning: Android silently returns nothing after more than five scans in 30 s, so an address-filtered scan is only the fallback. hello/auth run again and the app is back in BLE. The camera guarantees at least 30 s of BLE after a POST /ble even if the original window has expired.
Losing it. If the camera ends the window itself — bye reason:window or live — or a rescan times out, the app shows LOST: “press the camera's button”. The camera never depends on the app; an unexpected disconnect inside the window is a 30 s address-filtered rescan.
Bench: 10 cycles without a reboot, twice, on the bench firmware; 21 board cycles with internal heap drift ≤ 300 B. On the field firmware the full cycle has passed once.
Without a phone app
The button still works on its own.
Most of the camera's life has no phone in it. Wakes judge and spool; a button press or a cold boot opens a window; if nobody links, the camera contacts the server by itself.
- Wake, judge, spool. Timer and motion wakes capture, run the on-device gate, and record kept frames and their sidecar JSON to the SD spool. No radio is started.
- Window. A button press (a GPIO0 wake from deep sleep), a cold boot, a first boot after provisioning, or a pending-verify firmware image runs
radio_window_run(). Provisioned: BLE advertises for 120 s. If a phone links it can extend the window and hand off; a linked phone that goes quiet forcontact_idle_sends it. - Self-contact. No phone by the deadline: BLE is stopped and
contact_run(contact)joins the hotspot named in the camera's identity for up tohotspot_join_s. - Clock, announce, firmware.
GET /config/<device>is the first request; the server'sDatesets the RTC andclock_skew_sis recorded. One announce telemetry goes toPOST /telemetry/<device>.GET /firmware/<device>/versionis checked before any config is applied — firmware first, then config. - Config push or pull. If tuning was edited over BLE (
cfg_src = ble) the camera doesPOST /config/<device>with{base, config, schema}; the server accepts only if itscfgequalsbase, otherwise answers 409 with the current config and the camera applies that. Otherwise the pulled config is applied and stored. - Drain. Ranked spool frames up to
upload_cap, then debug frames, each asPOST /blob/<device>/framewith the meta inX-Meta, through the blob gate toingest; telemetry everytelemetry_smeanwhile. Accepted frames are deleted from the card. - Serve, then leave. The
:8266control plane stays up (and keeps beaconing) untilcontact_idle_spasses with no request, orPOST /ble, orlive. Wi-Fi is stopped and de-initialised, the camera de-initialised, and the board deep-sleeps.
The camera is the config sync agent. The app edits tier-2 tuning over BLE into NVS and the camera carries the change to the server at its next contact — so an edit made in airplane mode shows up on the device page the next time the owner walks into coverage and taps Contact. The setup page on the board still edits nothing.
Provisioning without USB is designed the same way: a blank fleet image advertises dc-new-<mac4>, the app writes identity into NVS with prov.set, and the camera restarts as its new id and contacts. See the status table — this path has not been run on hardware yet.
What is proven
On the bench, on the hardware, and not yet.
From docs/phone_app_status.md (updated 2026-09-16). “Pass” means measured on the XIAO and the Pixel 6; “once” means it worked but was not repeated; “open” means not run.
| Claim | Result | Evidence |
|---|---|---|
| Sequential radios on ESP-IDF 5.5 (P0.1b) | Pass | BT controller idle every time after dusty_ble_stop; re-advertising 12 ms after Wi-Fi off; 21 board cycles with internal-heap drift ≤ 300 B; Cycle ×10 from the app 10/10, twice (bench firmware). |
| BLE transfers (P0.1a) | Pass | Thumb/preview 65–85 KB/s at 244 B frames, 40+/40+ CRC-ok, heap byte-identical across 20 camera init/deinit cycles; 144 KB internal free with NimBLE up and the camera initialised (gate asked for ≥ 80 KB). |
| Internal RAM per radio state | Measured | Boot 210 KB, BLE 166 KB (156 KB after any Wi-Fi phase, a one-time ≈ 10 KB), BLE + camera 144 KB, Wi-Fi 144 KB, Wi-Fi + httpd 134 KB. |
| Field firmware: window, auth, spool, thumbnail | Pass | Cold boot opens the window; owner-key auth; time.set; Shoot → spool/9/000000.jpg → spool.list → 5.7 KB thumb over BLE at 32 KB/s. |
| Field firmware: handoff cycle | Once | wifi.up → hotspot → beacon → /status 200 → POST /ble → re-linked. The 10-cycle proof was on the bench firmware. |
| BLE config edit persists and syncs (P1.1a, P1.1b) | Pass | period_s 30 → 45 survives sleep/wake; push accepted at the next self-contact (base=1 current=1 → cfg=2); a server-side edit first gave 409 and the camera ran the server's value. |
| Button-only contact, no phone (P1.3) | Pass ×2 | BOOT press in deep sleep → 120 s window → self-contact done at 262.8 s, sent=1 failed=0. GPIO0 wake from deep sleep runs the app, not download mode (XIAO gate 1). |
| Field provisioning of a blank camera (P1.2) | Open | Not run. Blockers from review are fixed (--blank is verifiable; first_contact opens a window after restart); the recipe is in the status doc. |
| Drain throughput with trimmed Wi-Fi buffers (P1.4) | Open | Not run; needs a ≥ 200-frame spool. |
wifi.scan beside BLE | Partial | Link survived, 8 APs in 6.7 s, −9 KB internal afterwards; whether that cost is one-time or per scan is unknown. |
| Wi-Fi viewing polish, app polish, field sessions (P2–P4) | Open | Frames grid, MJPEG view, full-res over HTTP, mDNS, the local-only-hotspot experiment and the two field sessions are planned, not built or run. |
One honest footnote: on 2026-09-15 the field firmware was found crash-looping at the first drain (a main-task stack overflow, ~3.4 KB past a 12 KB stack). It was fixed on 2026-09-16 — stack to 20 KB, large locals moved to PSRAM, heap guards at phase boundaries — and a full contact, sleep and 31 warm timer wakes in 30 min were verified afterwards. The P1.3 pass above is on the fixed image.
Sources
Read the ground truth.
Everything above is a summary. These are the files it was drawn from.