A single person walking under the overhead camera was generating both an entry and an exit within a few seconds — the line-crossing logic treated a blob's traversal into one side of the frame and out the other as two separate events whenever the track spawned near the line, oscillated against shadows, or churned at creation. Replaced line-crossing semantics with directional traversal: - Each track records spawn_y at creation and a counted flag. - An event fires only if the track is not yet counted, spawned firm on one side of the line (|spawn_y - line_y| > CV_TRAVERSAL_MARGIN_PX), and is now firm on the opposite side. Direction of travel determines entry vs exit. The track is then flagged counted — one trip, one count. - Cooldown remains as a secondary safety net. main.cpp: single/double LED pulse on entry/exit detections. Saves and restores the current LED state so upload (yellow-on) and no-WiFi indicators aren't clobbered. Tests updated to walk blobs beyond the margin and register two new cases: wobble-at-line doesn't count, and a reversed full traversal doesn't double-count on the same track. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
159 lines
6.3 KiB
Markdown
159 lines
6.3 KiB
Markdown
# 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
|
||
```
|