6.0 KiB
DoorCounter
Retail door traffic counter using M5Stack TimerCamera-F (ESP32 + OV3660). Counts entries/exits via overhead camera CV, passively scans BLE foot traffic, and reports hourly to logs.research.bike.
Hardware
- Device: M5Stack TimerCamera-F (ESP32-S, OV3660, PSRAM, WiFi/BLE)
- Mount: Overhead, camera pointing straight down, centered above doorway
- Power: USB (any phone charger)
Firmware
Built with PlatformIO. Target: timercam.
cd firmware
pio run -t upload --upload-port /dev/ttyUSB0
What it does
| Module | Behavior |
|---|---|
| CV pipeline | 5 fps, 96×96 grayscale, blob tracking, line-crossing count |
| BLE scanner | Continuous passive scan; deinits during hourly upload to free heap |
| Reporter | Hourly HMAC-signed POST; 60s boot report for fast connectivity check |
| Provisioning | Captive portal AP on first boot for WiFi setup |
| OTA | Arduino OTA; operator push via ota_push.py |
Reporting intervals
- First report: 60 seconds after NTP sync (connectivity check)
- Subsequent reports: every 3600 seconds
Operator Setup
1. Flash firmware
cd firmware
pio run -t upload --upload-port /dev/ttyUSB0
2. Provision device identity
python tools/flash_device.py \
--port /dev/ttyUSB0 \
--device-id dc-0042 \
--location-id retailer-123 \
--hmac-secret <32-byte-hex> \
--wifi-ssid "StoreWiFi" \
--wifi-password "secret"
WiFi credentials are optional — if omitted, device starts captive portal on boot.
3. OTA updates
python tools/ota_push.py \
--host dc-0042.local \
--firmware firmware/.pio/build/timercam/firmware.bin
End User Setup
- Mount device overhead, camera pointing straight down
- Plug into USB power
- Connect phone to
DoorCounter-SetupWiFi - Browser opens automatically → enter store WiFi password → done
LED indicators: Red = no WiFi · Blue = counting · Yellow = uploading
API
Endpoint: http://logs.research.bike
| Endpoint | Data |
|---|---|
POST /api/v1/camera/events/batch |
Hourly entry/exit counts |
POST /api/v1/events/batch |
Hourly BLE proximity records |
POST /api/v1/heartbeat |
Device health (uptime, RSSI, pending records) |
All requests are HMAC-SHA256 signed. See design spec for full API shapes and auth scheme.
Runtime Configuration
The backend can push CV tuning parameters to individual devices in the response to POST /api/v1/heartbeat. No HTTP server runs on the device — updates ride the existing outbound, HMAC-authenticated channel.
Configurable fields
| Field | Type / Range | Meaning |
|---|---|---|
cfg_version |
uint32, non-zero | Monotonic version; device ignores updates with version ≤ stored. |
diff_thresh |
5–120 | Per-pixel motion threshold; higher = less sensitive. |
min_blob_px |
16–4096 | Minimum connected foreground pixels to count as a blob; higher = fewer false positives from small motion. |
max_move |
2.0–50.0 | Max inter-frame track displacement, in pixels on the 96×96 frame. |
max_missed |
1–60 | Frames a track can be missed before dropped. |
line_offset |
0–100 | Virtual counting line, as percent of frame height. |
Push flow
The heartbeat response MAY include a config object. All fields except cfg_version are optional; missing fields retain the device's current value.
{
"config": {
"cfg_version": 7,
"diff_thresh": 25,
"min_blob_px": 200,
"max_move": 12.0,
"max_missed": 8,
"line_offset": 55
}
}
Validation and apply rules
- Missing response body, non-200, malformed JSON, or missing
configobject → silent no-op. - Missing
cfg_version→ rejected, logged[CFG] missing cfg_version. cfg_version≤ stored → rejected as stale, logged.- Any present field with wrong JSON type → whole update rejected, logged
[CFG] rejected malformed config. - Any field out of range → whole update rejected, logged
[CFG] rejected invalid config. - Valid update → applied atomically under mutex, persisted to NVS, logged
[CFG] applied v=N.
Persistence
Tuning is stored in the doorcounter NVS namespace and survives reboot. On boot, the device loads the persisted values; if none present, compiled defaults apply.
Trust model
The device enforces validation defensively, but the backend is the source of truth and SHOULD validate bounds before pushing. Integrity of pushed config rides on the HMAC trust boundary that already authenticates the backend. Updates are keyed per device_id, so operators can tune one customer without affecting others.
No inbound HTTP surface is exposed on customer LANs by design — the device only makes outbound requests.
Roadmap
Gated local config portal. Holding the BOOT button for ~3 seconds would raise a WiFiManager-style captive portal on the local network for ~5 minutes, exposing a tuning page for field techs operating without backend connectivity. Deferred because (a) the server-push mechanism above covers routine tuning, (b) an always-on HTTP server on customer LANs is an undesirable attack surface, and (c) the gated-by-physical-access model needs additional auth design to be safe.
Project Structure
DoorCounter/
├── firmware/
│ ├── platformio.ini
│ ├── lib/hmac/ — HMAC-SHA256 signing library
│ └── src/
│ ├── main.cpp — FreeRTOS tasks, boot sequence
│ ├── config.* — NVS read/write
│ ├── provisioning.* — captive portal
│ ├── camera.* — frame capture + CV pipeline
│ ├── ble_scanner.* — BLE passive scan
│ └── reporter.* — hourly batch POST + local buffer
├── tools/
│ ├── flash_device.py — NVS provisioning script
│ └── ota_push.py — OTA push script
├── docs/superpowers/specs/
│ └── 2026-04-13-door-counter-design.md
└── server/ — API server (separate deployment)