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).

System mapOne radio up at a time · handoff both ways
dustyphone system map Three stacked boxes. Top: the camera (xiaocam1, XIAO ESP32S3 Sense) with a radio state strip SLEEP to BLE to WIFI and back, and two radio blocks, NimBLE peripheral and Wi-Fi station, separated by a solid barrier labelled one radio at a time. Middle: the Android phone running dustyphone, with a BLE central block and a hotspot block, and the app-side states BLE, HANDOFF, WIFI, RETURNING, LOST. Bottom: the sensorhub server, blobgate forwarding to ingest. A BLE link joins the camera's NimBLE block to the phone's BLE central; a Wi-Fi link joins the camera's Wi-Fi block to the phone's hotspot; the hotspot's cell uplink continues down to the sensorhub. The app itself never talks to the server. CAMERA · xiaocam1 XIAO ESP32S3 Sense OpenMV N6 has no BLE radio: server path only button · cold boot wifi.up · contact SLEEP BLE WIFI window over · live POST /ble · phase ends No phone linked when the 120 s window ends: WIFI on its own. Timer wakes never touch a radio; they spool to the SD card. SD spool · frames sensor · lazy init NVS · ident, cfg NimBLE peripheral GATT: cmd rsp data evt adv dc-<device>, fixed addr HMAC-SHA256 owner key MTU 517 · 244 B notifies ONE AT A TIME Wi-Fi station joins the phone hotspot HTTP :8266 · beacon :8267 TLS to sensorhub (contact) stop + deinit when done BLE link framed JSON + JPEG 65–85 KB/s measured Wi-Fi link beacon, /status /spool, /stream handoff: wifi.up → · ← POST /ble PHONE · dustyphone Android · Pixel 6 BLE central DustyLink · Framer GattQueue · Crypto reconnects by address Hotspot AP · 2.4 GHz CamHttp → :8266 Beacon ← udp :8267 routes camera → cell app state (Session.java) BLE HANDOFF WIFI RETURNING LOST RETURNING → BLE on reconnect · LOST: press the button cell uplink TLS · X-Token · the app never talks to the server SENSORHUB config sync: server wins a conflict blobgate Funnel :10000 LAN :8089 ingest :8088 GET /config · POST /config (push) POST /telemetry · POST /blob/<id>/frame GET /firmware/<id>/version

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.

ConnectconnectGatt to the camera's fixed static address; status-133 retry in GattQueue.
DiscoverFind service 7d1e0001-… and its five characteristics.
NotifyEnable notifications on rsp, data and evt.
MTUrequestMtu(517), then high connection priority.
InfoRead info (no auth): device, version, cfg, provisioned flag, nonce, proto — unknown proto is refused.
Authhello exchanges nonces; auth sends HMAC-SHA256(ble_key, camera nonce ‖ phone nonce).
Readyrequest(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.

Wire frameEvery cmd write and every rsp / data / evt notify
BLE frame layout A byte strip: a 4-byte header of id (u8), flags (u8) and idx (u16 little-endian), followed by up to 240 bytes of payload, for a frame of at most 244 bytes. Flags are LAST 0x01, BIN 0x02, ERR 0x04. Below it, the first fragment of a binary transfer: the payload starts with total (u32 LE) and crc32 (u32 LE) before the raw bytes. 4 B header ≤ 244 B of value (MTU capped at 247) id u8 flags u8 idx u16 LE payload ≤ 240 B per fragment id: 1…255 rolling per request · 0 = events flags: 0x01 LAST · 0x02 BIN · 0x04 ERR idx: 0, 1, 2… per fragment; out of order = reject and reset first fragment of a data (BIN) transfer, payload: total u32 LE crc32 u32 LE raw bytes… no per-fragment ACK; checked once

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.

Handoff cyclewifi.up → hotspot → POST /ble → reconnect
BLE to Wi-Fi handoff sequence A sequence diagram with two lifelines, the dustyphone app on the left and the camera on the right. The camera's lifeline is coloured by which radio is up: BLE, a gap while switching, Wi-Fi, another gap, then BLE again. Messages in order: the app sends the wifi.up request; the camera replies with a handoff object and a bye event and drops the link; the camera stops NimBLE until the controller is idle, joins the hotspot and starts its HTTP server and beacon; the beacon reaches the app over UDP 8267; the app fetches /status and enters the WIFI state, polls /status every 2 seconds and fetches spool, thumbnails and the stream; the app posts /ble; the camera replies, stops Wi-Fi, re-initialises NimBLE and advertises on the same address 12 milliseconds later; the app connects directly by address, runs hello and auth and is back in the BLE state. An alternative at the bottom: a bye event with reason window or live puts the app in LOST. dustyphone camera BLE up switching Wi-Fi up switching BLE up BLE cmd: request(id, "wifi.up") or "contact" for the full contact sequence rsp {"handoff":"wifi","back_in_s":N} evt {"ev":"bye","reason":"handoff"} then the camera terminates the link HANDOFF listen udp :8267 dusty_ble_stop() nimble_port_stop + deinit BT controller IDLE, asserted esp_wifi: join hotspot dusty_control :8266 up beacon task, once a second udp :8267 {"dc":1,"device","ip","port"} the app never asks for an IP GET /status → 200 {"radio":"wifi",…} ≈ 2 s after the tap (measured) WIFI GET /status every 2 s ble_back_in_s · drain {sent,pending,failed} GET /spool · /thumb · /stream (view) in contact mode the camera drains instead POST /ble {"ok":true,"radio":"ble","in_s":2} RETURNING httpd stop · Wi-Fi off esp_wifi stop + deinit NimBLE re-init, advertise same address, 12 ms later connectGatt(address) — no scan 3–5 s; scan by address only as fallback discover · notify rsp/data/evt · MTU 517 hello / auth (HMAC-SHA256) → READY ≥ 30 s of BLE guaranteed after POST /ble BLE otherwise, at any point evt bye reason:"window" | "live" LOST "Lost — press the camera's button"

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.

  1. 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.
  2. 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 for contact_idle_s ends it.
  3. 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 to hotspot_join_s.
  4. Clock, announce, firmware. GET /config/<device> is the first request; the server's Date sets the RTC and clock_skew_s is recorded. One announce telemetry goes to POST /telemetry/<device>. GET /firmware/<device>/version is checked before any config is applied — firmware first, then config.
  5. Config push or pull. If tuning was edited over BLE (cfg_src = ble) the camera does POST /config/<device> with {base, config, schema}; the server accepts only if its cfg equals base, otherwise answers 409 with the current config and the camera applies that. Otherwise the pulled config is applied and stored.
  6. Drain. Ranked spool frames up to upload_cap, then debug frames, each as POST /blob/<device>/frame with the meta in X-Meta, through the blob gate to ingest; telemetry every telemetry_s meanwhile. Accepted frames are deleted from the card.
  7. Serve, then leave. The :8266 control plane stays up (and keeps beaconing) until contact_idle_s passes with no request, or POST /ble, or live. 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.

ClaimResultEvidence
Sequential radios on ESP-IDF 5.5 (P0.1b)PassBT 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)PassThumb/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 stateMeasuredBoot 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, thumbnailPassCold boot opens the window; owner-key auth; time.set; Shoot → spool/9/000000.jpgspool.list → 5.7 KB thumb over BLE at 32 KB/s.
Field firmware: handoff cycleOncewifi.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)Passperiod_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 ×2BOOT 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)OpenNot 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)OpenNot run; needs a ≥ 200-frame spool.
wifi.scan beside BLEPartialLink 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)OpenFrames 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.