From 95d9c7ef4c09c5befcd0b1918e2b5c51416cf890 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 13 Apr 2026 13:04:55 -0700 Subject: [PATCH] =?UTF-8?q?chore:=20initial=20commit=20=E2=80=94=20spec=20?= =?UTF-8?q?and=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-04-13-door-counter.md | 2054 +++++++++++++++++ .../specs/2026-04-13-door-counter-design.md | 238 ++ 2 files changed, 2292 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-13-door-counter.md create mode 100644 docs/superpowers/specs/2026-04-13-door-counter-design.md diff --git a/docs/superpowers/plans/2026-04-13-door-counter.md b/docs/superpowers/plans/2026-04-13-door-counter.md new file mode 100644 index 0000000..271c24d --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-door-counter.md @@ -0,0 +1,2054 @@ +# Door Counter — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build ESP32 firmware for the M5Stack TimerCamera-F that counts door entries/exits via overhead camera + BLE activity, reports hourly to `logs.research.bike`, and provisions easily via captive portal. + +**Architecture:** PlatformIO/Arduino firmware with FreeRTOS tasks for camera CV (frame diff + centroid tracking), BLE passive scanning (coexistence mode), and hourly HMAC-signed HTTP batches. A Python provisioning script burns NVS config pre-ship; end users provision WiFi via captive portal on first boot. + +**Tech Stack:** PlatformIO + Arduino framework, ESP32 (TimerCamera-F), OV3660 camera, WiFiManager, ArduinoJson, mbedTLS (bundled), FastAPI + SQLite (server additions), Python 3 + esptool (operator tools) + +--- + +## File Map + +### Firmware +| File | Responsibility | +|------|---------------| +| `firmware/platformio.ini` | Board config, lib deps, native test env | +| `firmware/src/main.cpp` | FreeRTOS task setup, LED indicators, factory reset button | +| `firmware/src/config.h` + `config.cpp` | NVS read/write via `Preferences` | +| `firmware/src/hmac.h` + `hmac.cpp` | HMAC-SHA256 signing via mbedTLS (no Arduino deps in .h) | +| `firmware/src/cv.h` + `cv.cpp` | Pure C++ CV pipeline: frame diff, blob detection, centroid tracking, line crossing | +| `firmware/src/camera.h` + `camera.cpp` | OV3660 init, frame capture, downscale to 96×96 grayscale | +| `firmware/src/ble_scanner.h` + `ble_scanner.cpp` | Passive BLE scan, RSSI bucketing, MAC hashing | +| `firmware/src/reporter.h` + `reporter.cpp` | Hourly batch POST, RAM buffer (24 records), HMAC signing | +| `firmware/src/provisioning.h` + `provisioning.cpp` | WiFiManager captive portal, factory reset | +| `firmware/test/test_native/test_hmac.cpp` | Native unit tests for HMAC output | +| `firmware/test/test_native/test_cv.cpp` | Native unit tests for CV algorithm | + +### Server (additions to existing repo) +| File | Responsibility | +|------|---------------| +| `/migrations/004_camera_records.sql` | New `camera_records` table | +| `/main.py` | Add `CameraRecord`, `CameraEventsRequest`, `CameraEventsResponse` models + endpoint | +| `/tests/test_camera_endpoint.py` | Endpoint tests | + +### Operator tools +| File | Responsibility | +|------|---------------| +| `tools/flash_device.py` | Write NVS config to device over serial using esptool NVS partition | +| `tools/ota_push.py` | Push firmware binary to device via Arduino OTA (HTTP) | + +--- + +## Phase 1 — Firmware Foundation + +### Task 1: PlatformIO project scaffold + +**Files:** +- Create: `firmware/platformio.ini` +- Create: `firmware/src/.gitkeep` + +- [ ] **Step 1: Create platformio.ini** + +```ini +; firmware/platformio.ini +[platformio] +default_envs = timercam + +[env:timercam] +platform = espressif32 +board = esp32dev +framework = arduino +board_build.partitions = huge_app.csv +build_flags = + -DBOARD_HAS_PSRAM + -mfix-esp32-psram-cache-issue + -DCORE_DEBUG_LEVEL=1 +monitor_speed = 115200 +upload_speed = 921600 +lib_deps = + tzapu/WiFiManager@^2.0.17 + bblanchon/ArduinoJson@^7.0.0 + +[env:native] +platform = native +test_framework = unity +build_flags = + -std=c++17 + -DNATIVE_TEST +``` + +- [ ] **Step 2: Verify PlatformIO can resolve the environment** + +```bash +cd firmware && pio pkg install +``` +Expected: no errors, `.pio/` directory created. + +- [ ] **Step 3: Commit** + +```bash +git init +git add firmware/platformio.ini +git commit -m "chore: init PlatformIO project for TimerCamera-F" +``` + +--- + +### Task 2: Config module (NVS) + +**Files:** +- Create: `firmware/src/config.h` +- Create: `firmware/src/config.cpp` + +- [ ] **Step 1: Write config.h** + +```cpp +// firmware/src/config.h +#pragma once +#include + +struct DeviceConfig { + String device_id; // e.g. "dc-0042" + String location_id; // e.g. "retailer-123" + String hmac_secret; // 32-byte hex string + String wifi_ssid; + String wifi_pass; + uint8_t line_offset; // 0-100, percent of frame height for virtual line +}; + +// Load all config from NVS. Returns false if device_id/location_id/hmac_secret missing. +bool config_load(DeviceConfig& cfg); + +// Save WiFi credentials to NVS (called by provisioning after captive portal). +bool config_save_wifi(const String& ssid, const String& pass); + +// Returns true if wifi_ssid is set in NVS. +bool config_has_wifi(); + +// Erase WiFi credentials only (factory reset — preserves device_id etc). +void config_clear_wifi(); +``` + +- [ ] **Step 2: Write config.cpp** + +```cpp +// firmware/src/config.cpp +#include "config.h" +#include + +static const char* NS = "doorcounter"; + +bool config_load(DeviceConfig& cfg) { + Preferences prefs; + prefs.begin(NS, true); // read-only + + cfg.device_id = prefs.getString("device_id", ""); + cfg.location_id = prefs.getString("location_id", ""); + cfg.hmac_secret = prefs.getString("hmac_secret", ""); + cfg.wifi_ssid = prefs.getString("wifi_ssid", ""); + cfg.wifi_pass = prefs.getString("wifi_pass", ""); + cfg.line_offset = (uint8_t)prefs.getUInt("line_offset", 50); + + prefs.end(); + + return !cfg.device_id.isEmpty() && + !cfg.location_id.isEmpty() && + !cfg.hmac_secret.isEmpty(); +} + +bool config_save_wifi(const String& ssid, const String& pass) { + Preferences prefs; + prefs.begin(NS, false); + bool ok = prefs.putString("wifi_ssid", ssid) && + prefs.putString("wifi_pass", pass); + prefs.end(); + return ok; +} + +bool config_has_wifi() { + Preferences prefs; + prefs.begin(NS, true); + String ssid = prefs.getString("wifi_ssid", ""); + prefs.end(); + return !ssid.isEmpty(); +} + +void config_clear_wifi() { + Preferences prefs; + prefs.begin(NS, false); + prefs.remove("wifi_ssid"); + prefs.remove("wifi_pass"); + prefs.end(); +} +``` + +- [ ] **Step 3: Verify it compiles** + +```bash +cd firmware && pio run -e timercam +``` +Expected: compiles without errors (will warn about empty main — that's fine until Task 11). + +- [ ] **Step 4: Commit** + +```bash +git add firmware/src/config.h firmware/src/config.cpp +git commit -m "feat: config module — NVS read/write via Preferences" +``` + +--- + +### Task 3: HMAC module + native tests + +**⚠️ Before implementing:** Open the existing server's `verify_device_hmac` function and confirm the message format. The design doc assumes: `HMAC-SHA256(secret, device_id + ":" + timestamp + ":" + sha256_hex(body))`. If the server uses a different format, update `hmac.cpp` and the test vectors to match before proceeding. + +**Files:** +- Create: `firmware/src/hmac.h` +- Create: `firmware/src/hmac.cpp` +- Create: `firmware/test/test_native/test_hmac.cpp` + +- [ ] **Step 1: Write hmac.h** + +```cpp +// firmware/src/hmac.h +#pragma once +#include + +#ifdef NATIVE_TEST +#include +using HString = std::string; +#else +#include +using HString = String; +#endif + +// Returns lowercase hex-encoded HMAC-SHA256 signature. +// Message signed: device_id + ":" + timestamp_str + ":" + hex(sha256(body)) +HString hmac_sign(const HString& secret_hex, const HString& device_id, + uint32_t timestamp, const HString& body); +``` + +- [ ] **Step 2: Write hmac.cpp** + +```cpp +// firmware/src/hmac.cpp +#include "hmac.h" +#include "mbedtls/md.h" +#include +#include + +static HString bytes_to_hex(const uint8_t* bytes, size_t len) { + HString out; + char buf[3]; + for (size_t i = 0; i < len; i++) { + snprintf(buf, sizeof(buf), "%02x", bytes[i]); + out += buf; + } + return out; +} + +static void hex_to_bytes(const HString& hex, uint8_t* out, size_t out_len) { + for (size_t i = 0; i < out_len && (i * 2 + 1) < hex.size(); i++) { + char byte_str[3] = {hex[i*2], hex[i*2+1], 0}; + out[i] = (uint8_t)strtol(byte_str, nullptr, 16); + } +} + +static void sha256(const uint8_t* data, size_t len, uint8_t out[32]) { + mbedtls_md_context_t ctx; + const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + mbedtls_md_init(&ctx); + mbedtls_md_setup(&ctx, info, 0); + mbedtls_md_starts(&ctx); + mbedtls_md_update(&ctx, data, len); + mbedtls_md_finish(&ctx, out); + mbedtls_md_free(&ctx); +} + +HString hmac_sign(const HString& secret_hex, const HString& device_id, + uint32_t timestamp, const HString& body) { + // 1. SHA256(body) + uint8_t body_hash[32]; + sha256((const uint8_t*)body.c_str(), body.size(), body_hash); + HString body_hash_hex = bytes_to_hex(body_hash, 32); + + // 2. Build message + char ts_buf[12]; + snprintf(ts_buf, sizeof(ts_buf), "%u", (unsigned)timestamp); + HString message = device_id + ":" + ts_buf + ":" + body_hash_hex; + + // 3. Decode secret from hex + size_t secret_len = secret_hex.size() / 2; + uint8_t secret[64] = {}; + hex_to_bytes(secret_hex, secret, secret_len); + + // 4. HMAC-SHA256(secret, message) + uint8_t hmac_result[32]; + mbedtls_md_context_t ctx; + const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + mbedtls_md_init(&ctx); + mbedtls_md_setup(&ctx, info, 1); + mbedtls_md_hmac_starts(&ctx, secret, secret_len); + mbedtls_md_hmac_update(&ctx, (const uint8_t*)message.c_str(), message.size()); + mbedtls_md_hmac_finish(&ctx, hmac_result); + mbedtls_md_free(&ctx); + + return bytes_to_hex(hmac_result, 32); +} +``` + +- [ ] **Step 3: Generate test vector with Python** + +Run this locally to get expected values: +```python +import hmac, hashlib + +secret_hex = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" +device_id = "dc-0042" +timestamp = 1712000000 +body = '{"location_id":"retailer-123","records":[]}' + +secret = bytes.fromhex(secret_hex) +body_hash = hashlib.sha256(body.encode()).hexdigest() +message = f"{device_id}:{timestamp}:{body_hash}" +sig = hmac.new(secret, message.encode(), hashlib.sha256).hexdigest() +print(sig) # copy this into the test below +``` + +- [ ] **Step 4: Write test_hmac.cpp** (substitute the printed sig from Step 3) + +```cpp +// firmware/test/test_native/test_hmac.cpp +#include +#include "hmac.h" + +void test_hmac_known_vector() { + HString secret = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + HString device = "dc-0042"; + HString body = "{\"location_id\":\"retailer-123\",\"records\":[]}"; + uint32_t ts = 1712000000; + + HString result = hmac_sign(secret, device, ts, body); + + // Replace with value printed by the Python script in Step 3 + TEST_ASSERT_EQUAL_STRING("REPLACE_WITH_PYTHON_OUTPUT", result.c_str()); +} + +void test_hmac_different_timestamp_gives_different_sig() { + HString secret = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + HString device = "dc-0042"; + HString body = "{}"; + + HString sig1 = hmac_sign(secret, device, 1712000000, body); + HString sig2 = hmac_sign(secret, device, 1712000001, body); + TEST_ASSERT_NOT_EQUAL(0, sig1.compare(sig2)); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_hmac_known_vector); + RUN_TEST(test_hmac_different_timestamp_gives_different_sig); + return UNITY_END(); +} +``` + +- [ ] **Step 5: Run native tests** + +```bash +cd firmware && pio test -e native +``` +Expected: 2 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add firmware/src/hmac.h firmware/src/hmac.cpp firmware/test/test_native/test_hmac.cpp +git commit -m "feat: HMAC-SHA256 signing module with native tests" +``` + +--- + +## Phase 2 — CV Algorithm + +### Task 4: CV data structures and frame differencing + +**Files:** +- Create: `firmware/src/cv.h` +- Create: `firmware/src/cv.cpp` +- Create: `firmware/test/test_native/test_cv.cpp` (initial) + +- [ ] **Step 1: Write cv.h** + +```cpp +// firmware/src/cv.h +#pragma once +#include +#include + +static const int CV_W = 96; +static const int CV_H = 96; +static const int CV_PIXELS = CV_W * CV_H; + +// Threshold for frame difference to be considered foreground (0-255). +static const uint8_t CV_DIFF_THRESH = 30; + +// Minimum blob pixel count to be considered a person. +static const int CV_MIN_BLOB_PX = 64; // ~8x8 + +// Max pixels a centroid can move between frames and still be the same track. +static const float CV_MAX_MOVE = 15.0f; + +// Frames a track can go unmatched before being dropped. +static const int CV_MAX_MISSED = 10; + +// Seconds of inactivity before background is updated. +static const int CV_BG_UPDATE_INTERVAL_S = 2; + +struct CVTrack { + int id; + float x, y; + bool above_line; // was centroid above the line last frame? + int missed; // consecutive unmatched frames +}; + +struct CVState { + uint8_t background[CV_PIXELS]; + bool bg_valid; + uint32_t last_motion_frame; // frame index of last detected motion + uint32_t frame_index; + int next_id; + std::vector tracks; + int entries; + int exits; +}; + +struct CVResult { + int entries_delta; // new entries detected this frame + int exits_delta; // new exits detected this frame +}; + +// Initialize CVState. Must be called before cv_process. +void cv_init(CVState& state); + +// Process one 96x96 grayscale frame. Updates state and returns deltas. +// line_pct: virtual line position as percent of frame height (0-100). +CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct); + +// Reset entry/exit accumulators (call at start of each hour). +void cv_reset_counts(CVState& state); +``` + +- [ ] **Step 2: Write cv.cpp frame diff section only** + +```cpp +// firmware/src/cv.cpp +#include "cv.h" +#include +#include +#include + +void cv_init(CVState& state) { + memset(&state, 0, sizeof(CVState)); + state.next_id = 1; +} + +void cv_reset_counts(CVState& state) { + state.entries = 0; + state.exits = 0; +} + +// Apply frame diff + threshold. fg[i]=1 where foreground, 0 otherwise. +static void frame_diff(const uint8_t* frame, const uint8_t* bg, + uint8_t* fg, int pixels) { + for (int i = 0; i < pixels; i++) { + int diff = (int)frame[i] - (int)bg[i]; + if (diff < 0) diff = -diff; + fg[i] = (diff > CV_DIFF_THRESH) ? 1 : 0; + } +} + +// ... (blob detection and tracking added in Tasks 5-6) +CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct) { + CVResult result = {0, 0}; + state.frame_index++; + + if (!state.bg_valid) { + memcpy(state.background, frame, CV_PIXELS); + state.bg_valid = true; + return result; + } + + uint8_t fg[CV_PIXELS]; + frame_diff(frame, state.background, fg, CV_PIXELS); + + // Count foreground pixels to detect motion + int fg_count = 0; + for (int i = 0; i < CV_PIXELS; i++) fg_count += fg[i]; + + bool motion = fg_count > CV_MIN_BLOB_PX; + if (!motion) { + // No motion — update background slowly + if (state.frame_index - state.last_motion_frame > 10) { + memcpy(state.background, frame, CV_PIXELS); + } + // Age out all tracks + for (auto& t : state.tracks) t.missed++; + state.tracks.erase( + std::remove_if(state.tracks.begin(), state.tracks.end(), + [](const CVTrack& t){ return t.missed > CV_MAX_MISSED; }), + state.tracks.end()); + return result; + } + + state.last_motion_frame = state.frame_index; + // Blob detection and tracking (stubbed here, implemented in Tasks 5-6) + return result; +} +``` + +- [ ] **Step 3: Write initial test_cv.cpp testing frame diff** + +```cpp +// firmware/test/test_native/test_cv.cpp +#include +#include +#include "cv.h" + +// Helper: create uniform gray frame +static void fill_frame(uint8_t* f, uint8_t val) { + memset(f, val, CV_PIXELS); +} + +void test_frame_diff_no_change_gives_no_fg() { + CVState state; + cv_init(&state); + + uint8_t frame[CV_PIXELS]; + fill_frame(frame, 128); + + // First call initializes background — no result + CVResult r1 = cv_process(state, frame, 50); + TEST_ASSERT_EQUAL_INT(0, r1.entries_delta); + + // Second call with identical frame — no foreground + CVResult r2 = cv_process(state, frame, 50); + TEST_ASSERT_EQUAL_INT(0, r2.entries_delta); + TEST_ASSERT_EQUAL_INT(0, r2.exits_delta); +} + +void test_frame_diff_large_change_detected() { + CVState state; + cv_init(&state); + + uint8_t bg[CV_PIXELS], fg_frame[CV_PIXELS]; + fill_frame(bg, 100); + fill_frame(fg_frame, 200); // delta = 100, well above CV_DIFF_THRESH=30 + + cv_process(state, bg, 50); // init background + CVResult r = cv_process(state, fg_frame, 50); + + // Should detect motion (entries/exits only when tracking is added) + // For now just verify no crash and state is valid + TEST_ASSERT_EQUAL_INT(0, r.entries_delta); // tracking not yet added +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_frame_diff_no_change_gives_no_fg); + RUN_TEST(test_frame_diff_large_change_detected); + return UNITY_END(); +} +``` + +- [ ] **Step 4: Run native tests** + +```bash +cd firmware && pio test -e native -f test_cv +``` +Expected: 2 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add firmware/src/cv.h firmware/src/cv.cpp firmware/test/test_native/test_cv.cpp +git commit -m "feat: CV module — frame diff + threshold (blob tracking TODO)" +``` + +--- + +### Task 5: Blob detection and centroid tracking + +**Files:** +- Modify: `firmware/src/cv.cpp` (add blob detection + tracking to `cv_process`) +- Modify: `firmware/test/test_native/test_cv.cpp` (add tracking tests) + +- [ ] **Step 1: Add blob detection helper to cv.cpp** + +Add after `frame_diff` function, before `cv_process`: + +```cpp +struct Point { int x, y; }; + +// BFS flood fill on binary fg image. Returns centroid {cx, cy} of blob. +// Marks visited pixels in fg (sets to 0). Returns {-1,-1} if blob too small. +static std::pair extract_blob(uint8_t* fg, int start_x, int start_y) { + std::vector queue; + queue.reserve(CV_PIXELS / 4); + queue.push_back({start_x, start_y}); + fg[start_y * CV_W + start_x] = 0; // mark visited + + float sum_x = 0, sum_y = 0; + int count = 0; + + while (!queue.empty()) { + Point p = queue.back(); queue.pop_back(); + sum_x += p.x; sum_y += p.y; count++; + + const int dx[] = {-1, 1, 0, 0}; + const int dy[] = {0, 0, -1, 1}; + for (int d = 0; d < 4; d++) { + int nx = p.x + dx[d], ny = p.y + dy[d]; + if (nx < 0 || nx >= CV_W || ny < 0 || ny >= CV_H) continue; + int ni = ny * CV_W + nx; + if (!fg[ni]) continue; + fg[ni] = 0; + queue.push_back({nx, ny}); + } + } + + if (count < CV_MIN_BLOB_PX) return {-1.0f, -1.0f}; + return {sum_x / count, sum_y / count}; +} + +// Find all blob centroids in a foreground mask. +static std::vector> find_centroids(uint8_t* fg) { + std::vector> result; + // Work on a copy so we can mark visited + uint8_t fg_copy[CV_PIXELS]; + memcpy(fg_copy, fg, CV_PIXELS); + + for (int y = 0; y < CV_H; y++) { + for (int x = 0; x < CV_W; x++) { + if (!fg_copy[y * CV_W + x]) continue; + auto c = extract_blob(fg_copy, x, y); + if (c.first >= 0) result.push_back(c); + } + } + return result; +} +``` + +- [ ] **Step 2: Add nearest-neighbour tracker to cv_process** + +Replace the `// Blob detection and tracking (stubbed...)` comment in `cv_process` with: + +```cpp + uint8_t fg_copy[CV_PIXELS]; + memcpy(fg_copy, fg, CV_PIXELS); + auto centroids = find_centroids(fg_copy); + + // Match centroids to existing tracks (nearest neighbour) + std::vector centroid_matched(centroids.size(), false); + + for (auto& track : state.tracks) { + float best_dist = CV_MAX_MOVE * CV_MAX_MOVE; + int best_idx = -1; + + for (int i = 0; i < (int)centroids.size(); i++) { + if (centroid_matched[i]) continue; + float dx = centroids[i].first - track.x; + float dy = centroids[i].second - track.y; + float d2 = dx*dx + dy*dy; + if (d2 < best_dist) { best_dist = d2; best_idx = i; } + } + + if (best_idx >= 0) { + centroid_matched[best_idx] = true; + track.x = centroids[best_idx].first; + track.y = centroids[best_idx].second; + track.missed = 0; + } else { + track.missed++; + } + } + + // Remove stale tracks + state.tracks.erase( + std::remove_if(state.tracks.begin(), state.tracks.end(), + [](const CVTrack& t){ return t.missed > CV_MAX_MISSED; }), + state.tracks.end()); + + // Spawn new tracks for unmatched centroids + float line_y = (line_pct / 100.0f) * CV_H; + for (int i = 0; i < (int)centroids.size(); i++) { + if (centroid_matched[i]) continue; + CVTrack t; + t.id = state.next_id++; + t.x = centroids[i].first; + t.y = centroids[i].second; + t.above_line = (t.y < line_y); + t.missed = 0; + state.tracks.push_back(t); + } +``` + +- [ ] **Step 3: Add tracking test to test_cv.cpp** + +```cpp +void test_tracking_spawns_track_for_new_blob() { + CVState state; + cv_init(&state); + + // Background frame + uint8_t bg[CV_PIXELS]; + fill_frame(bg, 100); + cv_process(state, bg, 50); + + // Frame with a bright blob in top half (30x30 patch at top-left) + uint8_t blob_frame[CV_PIXELS]; + fill_frame(blob_frame, 100); + for (int y = 5; y < 35; y++) + for (int x = 5; x < 35; x++) + blob_frame[y * CV_W + x] = 200; + + cv_process(state, blob_frame, 50); + + TEST_ASSERT_EQUAL_INT(1, (int)state.tracks.size()); + // Centroid should be around (20, 20) + TEST_ASSERT_FLOAT_WITHIN(5.0f, 20.0f, state.tracks[0].x); + TEST_ASSERT_FLOAT_WITHIN(5.0f, 20.0f, state.tracks[0].y); +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cd firmware && pio test -e native -f test_cv +``` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add firmware/src/cv.cpp firmware/test/test_native/test_cv.cpp +git commit -m "feat: CV blob detection and centroid tracking" +``` + +--- + +### Task 6: Line crossing detection + +**Files:** +- Modify: `firmware/src/cv.cpp` (add line-crossing check after track update loop) +- Modify: `firmware/test/test_native/test_cv.cpp` (add crossing tests) + +- [ ] **Step 1: Add line-crossing check after the "spawn new tracks" block in cv_process** + +```cpp + // Check line crossings on updated tracks + for (auto& track : state.tracks) { + if (track.missed > 0) continue; // only check tracks that matched this frame + bool now_above = (track.y < line_y); + if (now_above != track.above_line) { + if (!now_above) { + // was above, now below → entry (top-down = into store) + state.entries++; + result.entries_delta++; + } else { + // was below, now above → exit + state.exits++; + result.exits_delta++; + } + } + track.above_line = now_above; + } + + return result; +``` + +- [ ] **Step 2: Add crossing test to test_cv.cpp** + +```cpp +// Helper: frame with bright blob at given center (radius ~12px) +static void make_blob_frame(uint8_t* f, int cx, int cy) { + fill_frame(f, 100); + for (int y = cy - 12; y <= cy + 12; y++) + for (int x = cx - 12; x <= cx + 12; x++) + if (y >= 0 && y < CV_H && x >= 0 && x < CV_W) + f[y * CV_W + x] = 200; +} + +void test_blob_crossing_line_top_to_bottom_is_entry() { + CVState state; + cv_init(&state); + + // Line at 50% = y=48 + uint8_t bg[CV_PIXELS]; + fill_frame(bg, 100); + cv_process(state, bg, 50); // init background + + // Frame 1: blob at y=20 (above line) + uint8_t f1[CV_PIXELS]; make_blob_frame(f1, 48, 20); + cv_process(state, f1, 50); + + // Frame 2: blob at y=76 (below line — crossed) + uint8_t f2[CV_PIXELS]; make_blob_frame(f2, 48, 76); + CVResult r = cv_process(state, f2, 50); + + TEST_ASSERT_EQUAL_INT(1, r.entries_delta); + TEST_ASSERT_EQUAL_INT(0, r.exits_delta); + TEST_ASSERT_EQUAL_INT(1, state.entries); +} + +void test_blob_crossing_line_bottom_to_top_is_exit() { + CVState state; + cv_init(&state); + + uint8_t bg[CV_PIXELS]; fill_frame(bg, 100); + cv_process(state, bg, 50); + + uint8_t f1[CV_PIXELS]; make_blob_frame(f1, 48, 76); // below line + cv_process(state, f1, 50); + + uint8_t f2[CV_PIXELS]; make_blob_frame(f2, 48, 20); // above line — crossed + CVResult r = cv_process(state, f2, 50); + + TEST_ASSERT_EQUAL_INT(0, r.entries_delta); + TEST_ASSERT_EQUAL_INT(1, r.exits_delta); +} + +void test_cv_reset_counts() { + CVState state; + cv_init(&state); + state.entries = 5; + state.exits = 3; + cv_reset_counts(&state); + TEST_ASSERT_EQUAL_INT(0, state.entries); + TEST_ASSERT_EQUAL_INT(0, state.exits); +} +``` + +- [ ] **Step 3: Add new tests to main() in test_cv.cpp** + +```cpp +// In main(): +RUN_TEST(test_tracking_spawns_track_for_new_blob); +RUN_TEST(test_blob_crossing_line_top_to_bottom_is_entry); +RUN_TEST(test_blob_crossing_line_bottom_to_top_is_exit); +RUN_TEST(test_cv_reset_counts); +``` + +- [ ] **Step 4: Run all CV tests** + +```bash +cd firmware && pio test -e native -f test_cv +``` +Expected: 6 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add firmware/src/cv.cpp firmware/test/test_native/test_cv.cpp +git commit -m "feat: CV line-crossing entry/exit detection with tests" +``` + +--- + +## Phase 3 — Hardware Modules + +### Task 7: Camera hardware module + +**Files:** +- Create: `firmware/src/camera.h` +- Create: `firmware/src/camera.cpp` + +- [ ] **Step 1: Write camera.h** + +```cpp +// firmware/src/camera.h +#pragma once +#include +#include + +// Initialise OV3660 camera. Returns false on failure. +bool camera_init(); + +// Capture a 96x96 grayscale frame into buf (must be CV_PIXELS bytes). +// Returns false on capture failure. +bool camera_capture_96(uint8_t* buf); +``` + +- [ ] **Step 2: Write camera.cpp with TimerCamera-F pin definitions** + +```cpp +// firmware/src/camera.cpp +// OV3660 pin assignments for M5Stack TimerCamera-F +// Ref: https://docs.m5stack.com/en/unit/timercam_f +#include "camera.h" +#include "cv.h" // for CV_W, CV_H +#include "esp_camera.h" +#include + +#define CAM_PIN_PWDN -1 +#define CAM_PIN_RESET 15 +#define CAM_PIN_XCLK 27 +#define CAM_PIN_SIOD 25 +#define CAM_PIN_SIOC 23 +#define CAM_PIN_D7 19 +#define CAM_PIN_D6 36 +#define CAM_PIN_D5 18 +#define CAM_PIN_D4 39 +#define CAM_PIN_D3 5 +#define CAM_PIN_D2 34 +#define CAM_PIN_D1 35 +#define CAM_PIN_D0 32 +#define CAM_PIN_VSYNC 22 +#define CAM_PIN_HREF 26 +#define CAM_PIN_PCLK 21 + +bool camera_init() { + camera_config_t cfg = {}; + cfg.ledc_channel = LEDC_CHANNEL_0; + cfg.ledc_timer = LEDC_TIMER_0; + cfg.pin_d0 = CAM_PIN_D0; + cfg.pin_d1 = CAM_PIN_D1; + cfg.pin_d2 = CAM_PIN_D2; + cfg.pin_d3 = CAM_PIN_D3; + cfg.pin_d4 = CAM_PIN_D4; + cfg.pin_d5 = CAM_PIN_D5; + cfg.pin_d6 = CAM_PIN_D6; + cfg.pin_d7 = CAM_PIN_D7; + cfg.pin_xclk = CAM_PIN_XCLK; + cfg.pin_pclk = CAM_PIN_PCLK; + cfg.pin_vsync = CAM_PIN_VSYNC; + cfg.pin_href = CAM_PIN_HREF; + cfg.pin_sscb_sda = CAM_PIN_SIOD; + cfg.pin_sscb_scl = CAM_PIN_SIOC; + cfg.pin_pwdn = CAM_PIN_PWDN; + cfg.pin_reset = CAM_PIN_RESET; + cfg.xclk_freq_hz = 20000000; + cfg.pixel_format = PIXFORMAT_GRAYSCALE; + cfg.frame_size = FRAMESIZE_QVGA; // 320x240 + cfg.fb_count = 1; + cfg.grab_mode = CAMERA_GRAB_WHEN_EMPTY; + + esp_err_t err = esp_camera_init(&cfg); + if (err != ESP_OK) return false; + + // Flip vertically if mounted upside-down (adjust as needed per install) + sensor_t* s = esp_camera_sensor_get(); + s->set_vflip(s, 1); + s->set_hmirror(s, 0); + + return true; +} + +// Simple box-filter bilinear downscale from src (src_w x src_h) to dst (CV_W x CV_H) +static void downscale(const uint8_t* src, int src_w, int src_h, + uint8_t* dst) { + int bx = src_w / CV_W; + int by = src_h / CV_H; + for (int dy = 0; dy < CV_H; dy++) { + for (int dx = 0; dx < CV_W; dx++) { + int sum = 0, cnt = 0; + for (int ky = 0; ky < by; ky++) + for (int kx = 0; kx < bx; kx++) { + int sx = dx * bx + kx; + int sy = dy * by + ky; + sum += src[sy * src_w + sx]; + cnt++; + } + dst[dy * CV_W + dx] = (uint8_t)(sum / cnt); + } + } +} + +bool camera_capture_96(uint8_t* buf) { + camera_fb_t* fb = esp_camera_fb_get(); + if (!fb) return false; + downscale(fb->buf, fb->width, fb->height, buf); + esp_camera_fb_return(fb); + return true; +} +``` + +- [ ] **Step 3: Compile check only (no unit tests for hardware)** + +```bash +cd firmware && pio run -e timercam +``` +Expected: compiles without errors. + +- [ ] **Step 4: Commit** + +```bash +git add firmware/src/camera.h firmware/src/camera.cpp +git commit -m "feat: camera module — OV3660 init and 96x96 grayscale capture" +``` + +--- + +### Task 8: BLE scanner + +**Files:** +- Create: `firmware/src/ble_scanner.h` +- Create: `firmware/src/ble_scanner.cpp` + +- [ ] **Step 1: Write ble_scanner.h** + +```cpp +// firmware/src/ble_scanner.h +#pragma once +#include +#include + +struct BLEHourlyRecord { + uint32_t period_start; + uint32_t period_end; + int unique_devices; + int max_concurrent; + int near_count; // RSSI > -65 dBm + int mid_count; // RSSI -65 to -80 dBm + int far_count; // RSSI < -80 dBm + std::vector device_hashes; // SHA256(MAC) hex, first 16 chars +}; + +// Start BLE passive scanning in WiFi-coexistence mode. +void ble_scanner_start(); + +// Stop BLE scan (call for ~3s during HTTP upload). +void ble_scanner_pause(); +void ble_scanner_resume(); + +// Collect current hour's record and reset accumulators. +BLEHourlyRecord ble_scanner_collect(uint32_t period_start, uint32_t period_end); +``` + +- [ ] **Step 2: Write ble_scanner.cpp** + +```cpp +// firmware/src/ble_scanner.cpp +#include "ble_scanner.h" +#include +#include +#include +#include "mbedtls/md.h" +#include +#include + +// RSSI thresholds (match existing BLE device firmware) +#define RSSI_NEAR -65 +#define RSSI_MID -80 + +static BLEScan* s_scan = nullptr; +static std::mutex s_mutex; + +struct DeviceObs { + int rssi_sum; + int count; + int rssi_max; // strongest signal seen +}; + +static std::map s_seen; // hash → observations +static int s_near = 0, s_mid = 0, s_far = 0; +static int s_max_concurrent = 0; + +static String sha256_prefix(const String& input) { + uint8_t hash[32]; + mbedtls_md_context_t ctx; + const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + mbedtls_md_init(&ctx); + mbedtls_md_setup(&ctx, info, 0); + mbedtls_md_starts(&ctx); + mbedtls_md_update(&ctx, (const uint8_t*)input.c_str(), input.length()); + mbedtls_md_finish(&ctx, hash); + mbedtls_md_free(&ctx); + String hex = ""; + char buf[3]; + for (int i = 0; i < 16; i++) { snprintf(buf, 3, "%02x", hash[i]); hex += buf; } + return hex; +} + +class ScanCallback : public BLEAdvertisedDeviceCallbacks { + void onResult(BLEAdvertisedDevice dev) override { + String mac = String(dev.getAddress().toString().c_str()); + String hash = sha256_prefix(mac); + int rssi = dev.getRSSI(); + + std::lock_guard lock(s_mutex); + if (s_seen.find(hash) == s_seen.end()) { + s_seen[hash] = {rssi, 1, rssi}; + } else { + auto& o = s_seen[hash]; + o.rssi_sum += rssi; o.count++; + if (rssi > o.rssi_max) o.rssi_max = rssi; + } + // Update max concurrent (snapshot of current scan window) + int concurrent = (int)s_seen.size(); + if (concurrent > s_max_concurrent) s_max_concurrent = concurrent; + } +}; + +static ScanCallback s_callback; + +void ble_scanner_start() { + BLEDevice::init(""); + s_scan = BLEDevice::getScan(); + s_scan->setAdvertisedDeviceCallbacks(&s_callback, true); // true = save duplicates + s_scan->setActiveScan(false); // passive scan + s_scan->setInterval(100); + s_scan->setWindow(99); + s_scan->start(0, nullptr, false); // 0 = continuous +} + +void ble_scanner_pause() { if (s_scan) s_scan->stop(); } +void ble_scanner_resume() { if (s_scan) s_scan->start(0, nullptr, false); } + +BLEHourlyRecord ble_scanner_collect(uint32_t period_start, uint32_t period_end) { + std::lock_guard lock(s_mutex); + BLEHourlyRecord rec; + rec.period_start = period_start; + rec.period_end = period_end; + rec.unique_devices = (int)s_seen.size(); + rec.max_concurrent = s_max_concurrent; + rec.near_count = 0; rec.mid_count = 0; rec.far_count = 0; + + for (auto& kv : s_seen) { + float avg_rssi = kv.second.rssi_sum / (float)kv.second.count; + if (avg_rssi > RSSI_NEAR) rec.near_count++; + else if (avg_rssi > RSSI_MID) rec.mid_count++; + else rec.far_count++; + rec.device_hashes.push_back(kv.first); + } + + // Reset accumulators for next hour + s_seen.clear(); + s_max_concurrent = 0; + + return rec; +} +``` + +- [ ] **Step 3: Compile check** + +```bash +cd firmware && pio run -e timercam +``` +Expected: compiles without errors. + +- [ ] **Step 4: Commit** + +```bash +git add firmware/src/ble_scanner.h firmware/src/ble_scanner.cpp +git commit -m "feat: BLE passive scanner with RSSI bucketing and MAC hashing" +``` + +--- + +### Task 9: Reporter (HTTP client + hourly buffer) + +**Files:** +- Create: `firmware/src/reporter.h` +- Create: `firmware/src/reporter.cpp` + +- [ ] **Step 1: Write reporter.h** + +```cpp +// firmware/src/reporter.h +#pragma once +#include +#include "config.h" +#include "ble_scanner.h" + +struct CameraHourlyRecord { + uint32_t period_start; + uint32_t period_end; + int entries; + int exits; +}; + +static const int REPORTER_MAX_BUFFER = 24; +static const char* REPORTER_API_HOST = "https://logs.research.bike"; + +// Submit one camera record. Buffers locally if WiFi unavailable. +void reporter_submit_camera(const DeviceConfig& cfg, const CameraHourlyRecord& rec); + +// Submit one BLE record. Buffers locally if WiFi unavailable. +void reporter_submit_ble(const DeviceConfig& cfg, const BLEHourlyRecord& rec); + +// Submit device heartbeat. +void reporter_heartbeat(const DeviceConfig& cfg, uint32_t uptime_s, int wifi_rssi); + +// Flush any buffered records. Call after WiFi reconnects. +void reporter_flush(const DeviceConfig& cfg); +``` + +- [ ] **Step 2: Write reporter.cpp** + +```cpp +// firmware/src/reporter.cpp +#include "reporter.h" +#include "hmac.h" +#include +#include +#include +#include + +// In-RAM buffer for offline records +static std::vector s_camera_buf; +static std::vector s_ble_buf; + +static bool post_json(const DeviceConfig& cfg, const char* path, + const String& body) { + uint32_t ts = (uint32_t)(WiFi.getTime()); // NTP-synced + String sig = hmac_sign(cfg.hmac_secret, cfg.device_id, ts, body); + + HTTPClient http; + String url = String(REPORTER_API_HOST) + path; + http.begin(url); + http.addHeader("Content-Type", "application/json"); + http.addHeader("X-Device-Id", cfg.device_id); + http.addHeader("X-Timestamp", String(ts)); + http.addHeader("X-HMAC-Signature", sig); + + int code = http.POST(body); + http.end(); + return (code == 200); +} + +static String build_camera_batch(const DeviceConfig& cfg, + const std::vector& recs) { + JsonDocument doc; + doc["location_id"] = cfg.location_id; + JsonArray arr = doc["records"].to(); + for (const auto& r : recs) { + JsonObject o = arr.add(); + o["period_start"] = r.period_start; + o["period_end"] = r.period_end; + o["entries"] = r.entries; + o["exits"] = r.exits; + } + String out; + serializeJson(doc, out); + return out; +} + +static String build_ble_batch(const DeviceConfig& cfg, + const std::vector& recs) { + JsonDocument doc; + doc["location_id"] = cfg.location_id; + JsonArray arr = doc["records"].to(); + for (const auto& r : recs) { + JsonObject o = arr.add(); + o["period_start"] = r.period_start; + o["period_end"] = r.period_end; + o["unique_devices"] = r.unique_devices; + o["max_concurrent"] = r.max_concurrent; + o["near_count"] = r.near_count; + o["mid_count"] = r.mid_count; + o["far_count"] = r.far_count; + if (!r.device_hashes.empty()) { + JsonArray ha = o["device_hashes"].to(); + for (const auto& h : r.device_hashes) ha.add(h); + } + } + String out; + serializeJson(doc, out); + return out; +} + +void reporter_submit_camera(const DeviceConfig& cfg, const CameraHourlyRecord& rec) { + if (WiFi.status() != WL_CONNECTED) { + if ((int)s_camera_buf.size() < REPORTER_MAX_BUFFER) + s_camera_buf.push_back(rec); + return; + } + std::vector batch = {rec}; + // Prepend any buffered records + if (!s_camera_buf.empty()) { + batch.insert(batch.begin(), s_camera_buf.begin(), s_camera_buf.end()); + s_camera_buf.clear(); + } + String body = build_camera_batch(cfg, batch); + if (!post_json(cfg, "/api/v1/camera/events/batch", body)) { + // On failure re-buffer (keep most recent MAX_BUFFER) + for (const auto& r : batch) + if ((int)s_camera_buf.size() < REPORTER_MAX_BUFFER) + s_camera_buf.push_back(r); + } +} + +void reporter_submit_ble(const DeviceConfig& cfg, const BLEHourlyRecord& rec) { + if (WiFi.status() != WL_CONNECTED) { + if ((int)s_ble_buf.size() < REPORTER_MAX_BUFFER) + s_ble_buf.push_back(rec); + return; + } + std::vector batch = {rec}; + if (!s_ble_buf.empty()) { + batch.insert(batch.begin(), s_ble_buf.begin(), s_ble_buf.end()); + s_ble_buf.clear(); + } + String body = build_ble_batch(cfg, batch); + if (!post_json(cfg, "/api/v1/events/batch", body)) { + for (const auto& r : batch) + if ((int)s_ble_buf.size() < REPORTER_MAX_BUFFER) + s_ble_buf.push_back(r); + } +} + +void reporter_heartbeat(const DeviceConfig& cfg, uint32_t uptime_s, int wifi_rssi) { + JsonDocument doc; + doc["firmware_version"] = "1.0.0"; + doc["free_storage_pct"] = 100; // RAM buffer only, no flash storage + doc["wifi_rssi"] = wifi_rssi; + doc["pending_records"] = (int)(s_camera_buf.size() + s_ble_buf.size()); + doc["uptime_seconds"] = uptime_s; + String body; serializeJson(doc, body); + post_json(cfg, "/api/v1/heartbeat", body); +} + +void reporter_flush(const DeviceConfig& cfg) { + if (!s_camera_buf.empty()) { + String body = build_camera_batch(cfg, s_camera_buf); + if (post_json(cfg, "/api/v1/camera/events/batch", body)) + s_camera_buf.clear(); + } + if (!s_ble_buf.empty()) { + String body = build_ble_batch(cfg, s_ble_buf); + if (post_json(cfg, "/api/v1/events/batch", body)) + s_ble_buf.clear(); + } +} +``` + +- [ ] **Step 3: Compile check** + +```bash +cd firmware && pio run -e timercam +``` +Expected: compiles without errors. + +- [ ] **Step 4: Commit** + +```bash +git add firmware/src/reporter.h firmware/src/reporter.cpp +git commit -m "feat: reporter — HMAC-signed hourly POST with 24-record offline buffer" +``` + +--- + +### Task 10: Provisioning (captive portal) + +**Files:** +- Create: `firmware/src/provisioning.h` +- Create: `firmware/src/provisioning.cpp` + +- [ ] **Step 1: Write provisioning.h** + +```cpp +// firmware/src/provisioning.h +#pragma once +#include + +// Start WiFi captive portal. Blocks until user submits WiFi credentials +// or timeout_ms elapses. Returns true if credentials were saved. +// Stores credentials via config_save_wifi() internally. +bool provisioning_run(uint32_t timeout_ms = 5 * 60 * 1000); +``` + +- [ ] **Step 2: Write provisioning.cpp** + +```cpp +// firmware/src/provisioning.cpp +#include "provisioning.h" +#include "config.h" +#include + +bool provisioning_run(uint32_t timeout_ms) { + WiFiManager wm; + wm.setConfigPortalTimeout(timeout_ms / 1000); + wm.setTitle("DoorCounter Setup"); + + // Custom minimal page — just SSID/password, no extra fields + // (location_id is pre-provisioned, not user-configurable) + wm.setCustomHeadElement( + "" + ); + + bool connected = wm.startConfigPortal("DoorCounter-Setup"); + + if (connected) { + config_save_wifi(wm.getWiFiSSID(), wm.getWiFiPass()); + } + return connected; +} +``` + +- [ ] **Step 3: Compile check** + +```bash +cd firmware && pio run -e timercam +``` +Expected: compiles without errors. + +- [ ] **Step 4: Commit** + +```bash +git add firmware/src/provisioning.h firmware/src/provisioning.cpp +git commit -m "feat: WiFiManager captive portal provisioning" +``` + +--- + +### Task 11: main.cpp — FreeRTOS wiring + +**Files:** +- Create: `firmware/src/main.cpp` + +**⚠️ Verify LED pin:** The TimerCamera-F LED pin is GPIO2 based on M5Stack schematics, but confirm against the physical board before flashing. Factory reset button is GPIO37 (TimerCamera-F's built-in button). + +- [ ] **Step 1: Write main.cpp** + +```cpp +// firmware/src/main.cpp +#include +#include +#include "config.h" +#include "provisioning.h" +#include "camera.h" +#include "cv.h" +#include "ble_scanner.h" +#include "reporter.h" + +// LED: red=no wifi, blue=counting, yellow=uploading +// TimerCamera-F uses RGB LED on GPIO2 (single LED — we use blink patterns) +// Confirm pin with schematic before flashing. +#define LED_PIN 2 +#define BUTTON_PIN 37 // built-in button for factory reset +#define FACTORY_RESET_HOLD_MS 5000 + +#define CAM_FPS 5 +#define CAM_INTERVAL_MS (1000 / CAM_FPS) +#define REPORT_INTERVAL_S 3600 // 1 hour + +static DeviceConfig g_cfg; +static CVState g_cv; + +enum class LedState { RED, BLUE, YELLOW }; + +static void set_led(LedState s) { + // Single LED: rapid blink=red, slow blink=blue, solid=yellow + // Simplified: just toggle for now; replace with PWM if board has RGB + digitalWrite(LED_PIN, s == LedState::YELLOW ? HIGH : LOW); +} + +// FreeRTOS task: capture frames and update CV state +static void task_camera(void*) { + uint8_t frame[CV_PIXELS]; + while (true) { + if (camera_capture_96(frame)) { + cv_process(g_cv, frame, g_cfg.line_offset); + } + vTaskDelay(pdMS_TO_TICKS(CAM_INTERVAL_MS)); + } +} + +// FreeRTOS task: hourly reporting +static void task_reporter(void*) { + uint32_t last_report = millis() / 1000; + while (true) { + vTaskDelay(pdMS_TO_TICKS(10000)); // check every 10s + uint32_t now = millis() / 1000; + if (now - last_report < REPORT_INTERVAL_S) continue; + + uint32_t period_end = now; + uint32_t period_start = last_report; + last_report = now; + + // Pause BLE for upload window + ble_scanner_pause(); + set_led(LedState::YELLOW); + + CameraHourlyRecord cam_rec; + cam_rec.period_start = period_start; + cam_rec.period_end = period_end; + cam_rec.entries = g_cv.entries; + cam_rec.exits = g_cv.exits; + cv_reset_counts(g_cv); + + BLEHourlyRecord ble_rec = ble_scanner_collect(period_start, period_end); + + reporter_submit_camera(g_cfg, cam_rec); + reporter_submit_ble(g_cfg, ble_rec); + reporter_heartbeat(g_cfg, now, WiFi.RSSI()); + + ble_scanner_resume(); + set_led(LedState::BLUE); + } +} + +// Check factory reset button (hold BUTTON_PIN low for FACTORY_RESET_HOLD_MS) +static void check_factory_reset() { + if (digitalRead(BUTTON_PIN) == LOW) { + uint32_t held = millis(); + while (digitalRead(BUTTON_PIN) == LOW) { + if (millis() - held >= FACTORY_RESET_HOLD_MS) { + config_clear_wifi(); + ESP.restart(); + } + delay(50); + } + } +} + +void setup() { + Serial.begin(115200); + pinMode(LED_PIN, OUTPUT); + pinMode(BUTTON_PIN, INPUT_PULLUP); + set_led(LedState::RED); + + // Load config — abort with serial error if not provisioned by operator + if (!config_load(g_cfg)) { + Serial.println("FATAL: device not provisioned (device_id/location_id/hmac_secret missing)"); + while (true) delay(1000); + } + + // WiFi: connect or start captive portal + WiFi.begin(g_cfg.wifi_ssid.c_str(), g_cfg.wifi_pass.c_str()); + uint32_t wifi_start = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - wifi_start < 15000) { + check_factory_reset(); + delay(200); + } + + if (WiFi.status() != WL_CONNECTED) { + if (!config_has_wifi()) { + provisioning_run(); + } else { + // Saved creds failed — re-show portal + provisioning_run(); + } + ESP.restart(); // reboot with new creds + } + + set_led(LedState::BLUE); + configTime(0, 0, "pool.ntp.org"); // sync time for HMAC timestamps + + cv_init(g_cv); + if (!camera_init()) { + Serial.println("FATAL: camera init failed"); + while (true) delay(1000); + } + + ble_scanner_start(); + + xTaskCreatePinnedToCore(task_camera, "cam", 4096, nullptr, 2, nullptr, 1); + xTaskCreatePinnedToCore(task_reporter, "rep", 8192, nullptr, 1, nullptr, 0); +} + +void loop() { + check_factory_reset(); + // Reconnect WiFi if dropped + if (WiFi.status() != WL_CONNECTED) { + set_led(LedState::RED); + WiFi.reconnect(); + delay(5000); + if (WiFi.status() == WL_CONNECTED) { + set_led(LedState::BLUE); + reporter_flush(g_cfg); + } + } + delay(1000); +} +``` + +- [ ] **Step 2: Full compile** + +```bash +cd firmware && pio run -e timercam +``` +Expected: compiles without errors or warnings about undeclared identifiers. + +- [ ] **Step 3: Run all native tests one final time** + +```bash +cd firmware && pio test -e native +``` +Expected: all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add firmware/src/main.cpp +git commit -m "feat: main.cpp — FreeRTOS tasks, LED indicators, factory reset" +``` + +--- + +## Phase 4 — Server Additions + +### Task 12: camera_records DB migration + +**Files:** +- Create: `/migrations/004_camera_records.sql` + +**Note:** Adjust migration number to follow your existing sequence. Run the migration against your dev DB before deploying. + +- [ ] **Step 1: Write migration SQL** + +```sql +-- migrations/004_camera_records.sql +CREATE TABLE IF NOT EXISTS camera_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + location_id TEXT NOT NULL, + period_start INTEGER NOT NULL, + period_end INTEGER NOT NULL, + entries INTEGER NOT NULL, + exits INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(device_id, period_start) +); + +CREATE INDEX IF NOT EXISTS idx_camera_location_time + ON camera_records(location_id, period_start); +``` + +- [ ] **Step 2: Apply migration to dev DB** + +```bash +sqlite3 < migrations/004_camera_records.sql +``` +Expected: no errors. + +- [ ] **Step 3: Verify table exists** + +```bash +sqlite3 ".schema camera_records" +``` +Expected: schema printed. + +- [ ] **Step 4: Commit** + +```bash +git add migrations/004_camera_records.sql +git commit -m "feat: camera_records table migration" +``` + +--- + +### Task 13: Camera batch endpoint + +**⚠️ Before coding:** Read the existing `verify_device_hmac` dependency in `main.py`. Confirm: (a) the HMAC header name and message format, and (b) whether camera devices need to be registered in `ble_devices` first or if device registration is implicit. Update `hmac.cpp` in the firmware if the format differs from what Task 3 implemented. + +**Files:** +- Modify: `/main.py` +- Create: `/tests/test_camera_endpoint.py` + +- [ ] **Step 1: Add Pydantic models to main.py** + +Add alongside existing `BatchEventsRequest` / `HeartbeatRequest` models: + +```python +from pydantic import BaseModel +from typing import List + +class CameraRecord(BaseModel): + period_start: int + period_end: int + entries: int + exits: int + +class CameraEventsRequest(BaseModel): + location_id: str + records: List[CameraRecord] + +class CameraEventsResponse(BaseModel): + status: str + accepted: int +``` + +- [ ] **Step 2: Add endpoint to main.py** + +Add after `receive_batch_events`: + +```python +@app.post("/api/v1/camera/events/batch", response_model=CameraEventsResponse) +async def receive_camera_events( + batch: CameraEventsRequest, + device_id: str = Depends(verify_device_hmac), + db: sqlite3.Connection = Depends(get_db), +): + """Receive a batch of hourly camera entry/exit records; idempotent on (device_id, period_start).""" + cursor = db.cursor() + accepted = 0 + for record in batch.records: + try: + cursor.execute( + """INSERT INTO camera_records + (device_id, location_id, period_start, period_end, entries, exits) + VALUES (?, ?, ?, ?, ?, ?)""", + ( + device_id, + batch.location_id, + record.period_start, + record.period_end, + record.entries, + record.exits, + ), + ) + accepted += 1 + except sqlite3.IntegrityError: + pass # duplicate (device_id, period_start) — idempotent + db.commit() + return CameraEventsResponse(status="ok", accepted=accepted) +``` + +- [ ] **Step 3: Write test_camera_endpoint.py** + +```python +# tests/test_camera_endpoint.py +import pytest +import time +from fastapi.testclient import TestClient +# Adjust import to match your project structure +from main import app + +# Use your test fixture pattern (match existing test setup in the repo) +@pytest.fixture +def client(): + with TestClient(app) as c: + yield c + +def auth_headers(device_id: str, body: str) -> dict: + """ + Build HMAC auth headers. Adapt to match your test helper if one exists. + Look at existing tests in the repo for the correct pattern. + """ + import hmac, hashlib, json + secret = b"test_secret_hex_decoded" # use test device secret from fixtures + ts = int(time.time()) + body_hash = hashlib.sha256(body.encode()).hexdigest() + message = f"{device_id}:{ts}:{body_hash}".encode() + sig = hmac.new(secret, message, hashlib.sha256).hexdigest() + return { + "Content-Type": "application/json", + "X-Device-Id": device_id, + "X-Timestamp": str(ts), + "X-HMAC-Signature": sig, + } + +def test_camera_batch_accepted(client): + body = '{"location_id":"test-loc","records":[{"period_start":1712000000,"period_end":1712003600,"entries":5,"exits":3}]}' + headers = auth_headers("dc-test-01", body) + resp = client.post("/api/v1/camera/events/batch", content=body, headers=headers) + assert resp.status_code == 200 + assert resp.json()["accepted"] == 1 + +def test_camera_batch_idempotent(client): + body = '{"location_id":"test-loc","records":[{"period_start":1712007200,"period_end":1712010800,"entries":2,"exits":1}]}' + headers = auth_headers("dc-test-01", body) + client.post("/api/v1/camera/events/batch", content=body, headers=headers) + # Second identical POST — should still return 200 with accepted=0 + headers2 = auth_headers("dc-test-01", body) + resp2 = client.post("/api/v1/camera/events/batch", content=body, headers=headers2) + assert resp2.status_code == 200 + assert resp2.json()["accepted"] == 0 + +def test_camera_batch_invalid_hmac_rejected(client): + body = '{"location_id":"test-loc","records":[]}' + resp = client.post("/api/v1/camera/events/batch", content=body, headers={ + "Content-Type": "application/json", + "X-Device-Id": "dc-test-01", + "X-Timestamp": "1712000000", + "X-HMAC-Signature": "badhash", + }) + assert resp.status_code == 401 +``` + +- [ ] **Step 4: Run server tests** + +```bash +pytest tests/test_camera_endpoint.py -v +``` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add main.py tests/test_camera_endpoint.py +git commit -m "feat: camera events batch endpoint with idempotent inserts" +``` + +--- + +## Phase 5 — Operator Tools + +### Task 14: flash_device.py + +**Files:** +- Create: `tools/flash_device.py` + +- [ ] **Step 1: Write flash_device.py** + +```python +#!/usr/bin/env python3 +""" +flash_device.py — Write NVS config to TimerCamera-F over serial. + +Requires: pip install esptool nvs-partition-gen +Usage: python 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"] +""" +import argparse +import csv +import os +import secrets +import subprocess +import sys +import tempfile + +NVS_NAMESPACE = "doorcounter" +NVS_PARTITION_OFFSET = "0x9000" # default NVS offset for ESP32 +NVS_PARTITION_SIZE = "0x6000" # 24KB default + + +def build_nvs_csv(device_id, location_id, hmac_secret, + wifi_ssid=None, wifi_pass=None, line_offset=50): + """Return NVS CSV content as a string.""" + rows = [ + ["key", "type", "encoding", "value"], + [NVS_NAMESPACE, "namespace", "", ""], + ["device_id", "data", "string", device_id], + ["location_id", "data", "string", location_id], + ["hmac_secret", "data", "string", hmac_secret], + ["line_offset", "data", "u8", str(line_offset)], + ] + if wifi_ssid: + rows.append(["wifi_ssid", "data", "string", wifi_ssid]) + if wifi_pass: + rows.append(["wifi_pass", "data", "string", wifi_pass]) + + lines = [] + for row in rows: + lines.append(",".join(row)) + return "\n".join(lines) + "\n" + + +def main(): + parser = argparse.ArgumentParser(description="Provision TimerCamera-F NVS config") + parser.add_argument("--port", required=True, help="Serial port, e.g. /dev/ttyUSB0") + parser.add_argument("--device-id", required=True, help="Unique device ID, e.g. dc-0042") + parser.add_argument("--location-id", required=True, help="Retailer location ID") + parser.add_argument("--hmac-secret", default=None, help="32-byte hex HMAC secret (generated if omitted)") + parser.add_argument("--wifi-ssid", default=None, help="WiFi SSID (optional)") + parser.add_argument("--wifi-password",default=None, help="WiFi password (optional)") + parser.add_argument("--line-offset", type=int, default=50, help="Virtual line position %% (default 50)") + args = parser.parse_args() + + hmac_secret = args.hmac_secret or secrets.token_hex(32) + print(f"HMAC secret: {hmac_secret} ← SAVE THIS for your records") + + with tempfile.TemporaryDirectory() as tmp: + csv_path = os.path.join(tmp, "nvs.csv") + bin_path = os.path.join(tmp, "nvs.bin") + + csv_content = build_nvs_csv( + args.device_id, args.location_id, hmac_secret, + args.wifi_ssid, args.wifi_password, args.line_offset + ) + with open(csv_path, "w") as f: + f.write(csv_content) + + # Generate NVS binary + ret = subprocess.run([ + sys.executable, "-m", "nvs_partition_gen", "generate", + csv_path, bin_path, NVS_PARTITION_SIZE + ], capture_output=True, text=True) + if ret.returncode != 0: + print("nvs_partition_gen error:", ret.stderr) + sys.exit(1) + + # Flash NVS partition + ret = subprocess.run([ + "esptool.py", "--port", args.port, + "--chip", "esp32", + "write_flash", NVS_PARTITION_OFFSET, bin_path + ], capture_output=False) + if ret.returncode != 0: + sys.exit(1) + + print(f"✓ Device {args.device_id} provisioned at {args.location_id}") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: Smoke test (no device required — test CSV generation)** + +```bash +cd tools +python flash_device.py --port /dev/null \ + --device-id dc-0001 --location-id test-123 \ + --hmac-secret $(python3 -c "import secrets; print(secrets.token_hex(32))") \ + --wifi-ssid TestNet --wifi-password hunter2 2>&1 | head -5 +``` +Expected: prints HMAC secret line and CSV is generated before failing on esptool (no device = expected error after CSV step). + +- [ ] **Step 3: Commit** + +```bash +git add tools/flash_device.py +git commit -m "feat: flash_device.py operator provisioning script" +``` + +--- + +### Task 15: ota_push.py + +**Files:** +- Create: `tools/ota_push.py` + +- [ ] **Step 1: Write ota_push.py** + +```python +#!/usr/bin/env python3 +""" +ota_push.py — Push firmware OTA to a TimerCamera-F device via Arduino OTA. + +Requires: pip install requests +Device must be connected to WiFi and advertising via mDNS as .local + +Usage: python ota_push.py --host dc-0042.local --firmware firmware/.pio/build/timercam/firmware.bin +""" +import argparse +import hashlib +import os +import struct +import sys +import socket +import time + +OTA_PORT = 3232 # Arduino OTA default +MAGIC = 0xE9 + + +def get_md5(path): + h = hashlib.md5() + with open(path, "rb") as f: + while chunk := f.read(8192): + h.update(chunk) + return h.hexdigest() + + +def resolve_host(host, timeout=10): + """Resolve mDNS hostname to IP.""" + deadline = time.time() + timeout + while time.time() < deadline: + try: + ip = socket.gethostbyname(host) + return ip + except socket.gaierror: + time.sleep(0.5) + raise RuntimeError(f"Could not resolve {host} within {timeout}s") + + +def push_ota(host, firmware_path): + size = os.path.getsize(firmware_path) + md5 = get_md5(firmware_path) + + print(f"Resolving {host}...") + ip = resolve_host(host) + print(f" → {ip}") + + print(f"Connecting to OTA port {OTA_PORT}...") + with socket.create_connection((ip, OTA_PORT), timeout=10) as sock: + # Arduino OTA handshake: send header + header = f"0 {size} {md5}\n".encode() + sock.sendall(header) + + resp = sock.recv(32).decode(errors="ignore").strip() + if resp != "OK": + raise RuntimeError(f"OTA handshake failed: {resp!r}") + + print(f"Sending {size} bytes...") + sent = 0 + with open(firmware_path, "rb") as f: + while chunk := f.read(4096): + sock.sendall(chunk) + sent += len(chunk) + pct = sent * 100 // size + print(f"\r {pct:3d}%", end="", flush=True) + print() + + final = sock.recv(32).decode(errors="ignore").strip() + if final != "OK": + raise RuntimeError(f"OTA write failed: {final!r}") + + print("✓ OTA complete — device rebooting") + + +def main(): + parser = argparse.ArgumentParser(description="Push OTA firmware to TimerCamera-F") + parser.add_argument("--host", required=True, help="mDNS hostname, e.g. dc-0042.local") + parser.add_argument("--firmware", required=True, help="Path to .bin firmware file") + args = parser.parse_args() + + if not os.path.exists(args.firmware): + print(f"Error: firmware file not found: {args.firmware}") + sys.exit(1) + + push_ota(args.host, args.firmware) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: Verify script parses correctly** + +```bash +python tools/ota_push.py --help +``` +Expected: usage printed without errors. + +- [ ] **Step 3: Commit** + +```bash +git add tools/ota_push.py +git commit -m "feat: ota_push.py operator firmware update script" +``` + +--- + +## Self-Review Notes + +**Spec coverage check:** +- ✅ Captive portal first-boot (Task 10) +- ✅ Pre-provisioning with location ID (Task 14) +- ✅ HMAC auth (Tasks 3, 13) +- ✅ Camera line-crossing counting (Tasks 4-7) +- ✅ Hourly batching (Task 9, 11) +- ✅ BLE continuous scan + coexistence (Task 8) +- ✅ Factory reset (Task 11) +- ✅ 24-record offline buffer (Task 9) +- ✅ LED indicators (Task 11) +- ✅ OTA updates (Task 15) +- ✅ New server endpoint + migration (Tasks 12-13) + +**Open items to resolve before/during implementation:** +1. Confirm `verify_device_hmac` message format in server (Task 3 + Task 13 both flag this) +2. Confirm LED pin (GPIO2) against physical board before first flash (Task 11) +3. Confirm OTA Arduino library integration in platformio.ini — add `ArduinoOTA` to `lib_deps` if not bundled +4. Confirm `WiFi.getTime()` returns valid NTP time before first report (may need `configTime` wait loop) diff --git a/docs/superpowers/specs/2026-04-13-door-counter-design.md b/docs/superpowers/specs/2026-04-13-door-counter-design.md new file mode 100644 index 0000000..209d0fa --- /dev/null +++ b/docs/superpowers/specs/2026-04-13-door-counter-design.md @@ -0,0 +1,238 @@ +# Door Counter — System Design + +**Date:** 2026-04-13 +**Device:** M5Stack TimerCamera-F (ESP32 + OV3660, PSRAM, WiFi/BLE) +**Target:** Retail door traffic counting, non-tech-savvy end users +**Framework:** PlatformIO + Arduino + +--- + +## 1. Architecture Overview + +``` +[TimerCamera-F Device] + ├── Provisioning module — captive portal AP on first boot + ├── Config store — NVS: device_id, location_id, HMAC secret, WiFi creds, line_offset + ├── Camera + CV module — captures frames, runs line-crossing counter + ├── BLE scanner — continuous passive scan (WiFi coexistence mode) + ├── Report buffer — accumulates counts in RAM, flushes hourly + └── HTTP client — HMAC-signed POSTs to logs.research.bike + +[logs.research.bike API] (additions needed) + ├── POST /api/v1/camera/events/batch — new endpoint, mirrors BLE batch shape + └── camera_records table — new DB table + +[Operator tooling] + ├── flash_device.py — serial script to burn NVS config before shipping + └── ota_push.py — push firmware update over mDNS/HTTP +``` + +All firmware modules run as FreeRTOS tasks. Config survives firmware updates (stored in NVS). OTA updates supported via Arduino OTA — no physical access needed after initial flash. + +--- + +## 2. Provisioning & Configuration + +### First-boot flow (end user) + +1. Device powers on, no WiFi credentials in NVS +2. Starts AP: `DoorCounter-Setup` (open, no password) +3. User connects phone → captive portal opens automatically in browser +4. Single-page form: WiFi network dropdown (scanned) + password field +5. On submit: credentials saved to NVS → device reboots → begins counting +6. On connection failure: automatically returns to AP mode + +### Pre-provisioning flow (operator) + +```bash +python 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"] +``` + +Writes directly to NVS over serial. WiFi credentials are optional — if omitted, device falls through to captive portal on first boot. + +### NVS config keys + +| Key | Set by | Required to operate | +|---------------|---------------|---------------------| +| `device_id` | Operator | Yes | +| `location_id` | Operator | Yes | +| `hmac_secret` | Operator | Yes | +| `wifi_ssid` | User/operator | Yes | +| `wifi_pass` | User/operator | Yes | +| `line_offset` | Default 50% | No | + +### Factory reset + +Hold built-in button 5 seconds → wipes WiFi credentials (preserves device_id, location_id, HMAC secret) → returns to captive portal. Allows redeployment to a new store without operator intervention. + +--- + +## 3. People Counting Algorithm + +**Mounting:** Overhead, camera pointing straight down, centered above doorway. + +**Frame pipeline** (~5 fps, FreeRTOS task): + +``` +Capture → Grayscale → Downscale 96×96 → Frame diff → Threshold → Blob detect → Centroid track → Line cross check +``` + +| Step | Detail | +|------|--------| +| Capture | OV3660 configured QVGA (320×240), grayscale | +| Downscale | Bilinear to 96×96 (~11× compute reduction) | +| Frame diff | Absolute difference against rolling background (updated every ~2s when no motion) | +| Threshold | Pixels > 30 intensity delta = foreground | +| Blob detect | Connected components; blobs < 8×8 px discarded as noise | +| Centroid track | Nearest-centroid matching frame-to-frame (max 15px), tracks persist up to 10 missed frames | +| Line crossing | Virtual horizontal line at configurable vertical position (default: 50% of frame height) | + +**Counting logic:** +- Centroid crosses line top→bottom = **entry** +- Centroid crosses line bottom→top = **exit** + +Counts accumulate as `{entries, exits}` in RAM and reset each hour on report. + +--- + +## 4. BLE Scanning + +Uses ESP32 built-in WiFi+BLE coexistence mode — BLE scans continuously while WiFi remains available. The only pause is a ~3s window during the hourly HTTP POST. + +- Passive BLE scan, accumulates unique device hashes, near/mid/far counts per hour +- Reports to existing `/api/v1/events/batch` endpoint (no server changes needed for BLE) +- Data shape identical to existing BLE-only devices — same `ble_records` schema + +--- + +## 5. API & Authentication + +### HMAC scheme + +Each request includes header: +``` +X-HMAC-Signature: +``` +Computed as: +``` +HMAC-SHA256(secret, device_id + ":" + unix_timestamp + ":" + body_sha256) +``` +Timestamp prevents replay attacks. Server validates within ±5 minute window. + +### New endpoint: POST /api/v1/camera/events/batch + +Request body: +```json +{ + "location_id": "retailer-123", + "records": [ + { + "period_start": 1712000000, + "period_end": 1712003600, + "entries": 42, + "exits": 39 + } + ] +} +``` + +Success response: +```json +{ "status": "ok", "accepted": 1 } +``` + +### New DB table: camera_records + +```sql +CREATE TABLE camera_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + location_id TEXT NOT NULL, + period_start INTEGER NOT NULL, + period_end INTEGER NOT NULL, + entries INTEGER NOT NULL, + exits INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(device_id, period_start) +); +``` + +Idempotent on `(device_id, period_start)` — duplicate submissions are silently ignored. + +### Existing endpoints (unchanged) + +- `POST /api/v1/heartbeat` — device health ping, reused as-is +- `POST /api/v1/events/batch` — BLE data, reused as-is + +### Local buffering + +If WiFi is unavailable at report time, counts are held in RAM (up to 24 records / 24 hours). Flushed in chronological order on reconnect. + +--- + +## 6. Deployment & OTA + +### Physical installation (end user steps) + +1. Mount device overhead, centered above doorway, camera pointing straight down +2. Plug into USB power (any phone charger) +3. Connect phone to `DoorCounter-Setup` WiFi network +4. Browser opens automatically → enter store WiFi password → done + +**LED indicators:** +- Red — no WiFi connection +- Blue — connected, counting +- Yellow — uploading hourly report + +The captive portal page is a single HTML file served from flash (no internet required), written in plain language with a mounting orientation diagram. + +### OTA updates (operator) + +Devices announce via mDNS as `.local`. Push firmware: + +```bash +python ota_push.py \ + --host dc-0042.local \ + --firmware build/firmware.bin +``` + +Future: devices can poll for updates on heartbeat via a `firmware_version` field in the heartbeat response — server signals when a newer version is available. + +--- + +## 7. Firmware Project Structure + +``` +DoorCounter/ +├── firmware/ +│ ├── platformio.ini +│ └── src/ +│ ├── main.cpp +│ ├── config.h / config.cpp — NVS read/write +│ ├── provisioning.h / .cpp — captive portal +│ ├── camera.h / .cpp — frame capture + CV pipeline +│ ├── ble_scanner.h / .cpp — BLE passive scan +│ ├── reporter.h / .cpp — hourly batch POST + local buffer +│ └── hmac.h / .cpp — HMAC-SHA256 signing +├── tools/ +│ ├── flash_device.py — NVS provisioning script +│ └── ota_push.py — OTA push script +└── docs/ + └── superpowers/specs/ + └── 2026-04-13-door-counter-design.md +``` + +--- + +## 8. Open Questions / Future Work + +- Confirm HMAC validation window (±5 min) matches existing server implementation +- Line offset calibration: consider a web UI at `.local/config` for adjusting the virtual line position after install +- Multi-zone counting (two lines = zone dwell time) — out of scope for v1 +- Dashboard / analytics UI — out of scope for v1