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

  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

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 5120 Per-pixel motion threshold; higher = less sensitive.
min_blob_px 164096 Minimum connected foreground pixels to count as a blob; higher = fewer false positives from small motion.
max_move 2.050.0 Max inter-frame track displacement, in pixels on the 96×96 frame.
max_missed 160 Frames a track can be missed before dropped.
line_offset 0100 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 config object → 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)
Description
No description provided
Readme 293 KiB
Languages
C++ 58.6%
Python 36.8%
C 4.6%