# 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`. ```bash cd firmware pio run -t upload --upload-port /dev/ttyUSB0 ``` ### What it does | Module | Behavior | |--------|----------| | CV pipeline | 5 fps, 96×96 grayscale, blob tracking, directional traversal count (origin→destination, once per track) with per-direction cooldown safety net | | Detection LED | Single blink on entry, double blink on exit (preserves upload/no-WiFi status LED) | | 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 ### Directional counting Each tracked blob fires at most **one** event over its lifetime, and only when it has genuinely traversed the frame — specifically, when its spawn position and current position are both at least `CV_TRAVERSAL_MARGIN_PX` (14 px ≈ 15% of the 96×96 frame) from the line, and on opposite sides. - Top half → bottom half traversal = **entry** - Bottom half → top half traversal = **exit** A blob that appears near the line and wobbles across it does not count (both positions are within the margin band). A blob that fully traverses then reverses under the same track also does not double-count (the track is flagged `counted`). If tracking churns — the track dies mid-traversal and respawns on the other side — a new track with a new spawn on the crossed side is the normal path to a correct count. See `firmware/lib/cv/cv.h` for margin and `cv.cpp` for the crossing logic. ### Crossing cooldown (safety net) On top of directional counting, each direction enforces a cooldown between counted events. Default: `CV_CROSSING_COOLDOWN_FRAMES = 5` (≈0.8s at 5 fps). Entries and exits maintain separate cooldowns, so a real entry immediately followed by a real exit still counts both. ## Operator Setup ### 1. Flash firmware ```bash cd firmware pio run -t upload --upload-port /dev/ttyUSB0 ``` ### 2. Provision device identity ```bash 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. > **Re-provision after firmware uploads.** Flashing firmware via > `pio run -t upload` may clear the NVS partition on this board. If the device > boots into a ~1 Hz LED blink (the "not provisioned" fatal state) after a > firmware update, re-run `flash_device.py` with the same credentials. See > [Troubleshooting](#troubleshooting). ### 3. OTA updates ```bash python tools/ota_push.py \ --host dc-0042.local \ --firmware firmware/.pio/build/timercam/firmware.bin ``` ## End User Setup 1. Mount device overhead, camera pointing straight down 2. Plug into USB power 3. Connect phone to `DoorCounter-Setup` WiFi 4. Browser opens automatically → enter store WiFi password → done **LED indicators**: Red = no WiFi · Blue = counting · Yellow = uploading · Brief flash (×1) on entry · Brief flash (×2) on exit ## 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](docs/superpowers/specs/2026-04-13-door-counter-design.md) for full API shapes and auth scheme. ## Project Structure ``` DoorCounter/ ├── firmware/ │ ├── platformio.ini │ ├── lib/ │ │ ├── cv/ — CV pipeline (blob tracking, line cross, cooldown) │ │ └── 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 │ └── serial_monitor.py — reset + read serial with timestamps (diagnostic) ├── docs/ │ ├── server-prompt-crossing-cooldown.md — server-side coordination notes │ └── superpowers/specs/2026-04-13-door-counter-design.md └── server/ — API server (separate deployment) ``` ## Troubleshooting | Symptom | Likely cause | Remedy | |---------|--------------|--------| | ~1 Hz LED blink after boot, no serial beyond `esp_core_dump_flash: No core dump partition found!` | NVS missing `device_id` / `location_id` / `hmac_secret`. Commonly triggered by a firmware upload wiping NVS. | Re-run `flash_device.py` with the device's known credentials. | | Device stays on `DoorCounter-Setup` AP instead of joining customer WiFi | SSID/password in NVS wrong, or network out of range. | Connect phone to `DoorCounter-Setup` → captive portal → re-enter WiFi. Or reflash NVS with correct `--wifi-ssid` / `--wifi-password`. | | No entries/exits counted for a known-walking doorway | WiFi captive portal still up (camera task starts only after connect); or camera blocked/unfocused. | Check LED: solid on = booting/uploading, off = counting. Run `serial_monitor.py` to see `[CV] entry/exit` log lines. | Capture a boot log with timestamps: ```bash python tools/serial_monitor.py --port /dev/ttyUSB0 --reset --timestamp --seconds 30 ```