From 6c46ea26ab103769c7e90a0aa39733c4c8ad41ea Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 13 Apr 2026 13:31:38 -0700 Subject: [PATCH 01/28] chore: init PlatformIO project for TimerCamera-F --- firmware/platformio.ini | 25 +++++++++++++++++++++++++ firmware/src/.gitkeep | 0 firmware/test/test_native/.gitkeep | 0 3 files changed, 25 insertions(+) create mode 100644 firmware/platformio.ini create mode 100644 firmware/src/.gitkeep create mode 100644 firmware/test/test_native/.gitkeep diff --git a/firmware/platformio.ini b/firmware/platformio.ini new file mode 100644 index 0000000..d5151d2 --- /dev/null +++ b/firmware/platformio.ini @@ -0,0 +1,25 @@ +; 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 diff --git a/firmware/src/.gitkeep b/firmware/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/firmware/test/test_native/.gitkeep b/firmware/test/test_native/.gitkeep new file mode 100644 index 0000000..e69de29 From f4d9e1b2a5ceb84f90327660fdd4c12292b33b66 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 13 Apr 2026 13:52:33 -0700 Subject: [PATCH 02/28] =?UTF-8?q?fix:=20update=20platformio.ini=20?= =?UTF-8?q?=E2=80=94=20OTA=20partitions,=20NimBLE,=20PSRAM=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch board to m5stack-timer-cam (confirmed in pio boards) - Pin platform to espressif32@6.6.0 - Replace huge_app.csv with custom partitions_8mb_ota.csv (8MB + OTA) - Add -DCONFIG_BT_NIMBLE_ENABLED=1 and -DCONFIG_SPIRAM_USE_MALLOC=1 - Add h2zero/NimBLE-Arduino@^1.4.2 to lib_deps - Raise CORE_DEBUG_LEVEL from 1 → 3 Co-Authored-By: Claude Sonnet 4.6 --- firmware/partitions_8mb_ota.csv | 6 ++++++ firmware/platformio.ini | 11 +++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 firmware/partitions_8mb_ota.csv diff --git a/firmware/partitions_8mb_ota.csv b/firmware/partitions_8mb_ota.csv new file mode 100644 index 0000000..55d0f5c --- /dev/null +++ b/firmware/partitions_8mb_ota.csv @@ -0,0 +1,6 @@ +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0x9000, 0x6000 +otadata, data, ota, 0xf000, 0x2000 +app0, app, ota_0, 0x10000, 0x300000 +app1, app, ota_1, 0x310000, 0x300000 +spiffs, data, spiffs, 0x610000, 0x1F0000 diff --git a/firmware/platformio.ini b/firmware/platformio.ini index d5151d2..8ac262b 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -3,19 +3,22 @@ default_envs = timercam [env:timercam] -platform = espressif32 -board = esp32dev +platform = espressif32@6.6.0 +board = m5stack-timer-cam framework = arduino -board_build.partitions = huge_app.csv +board_build.partitions = partitions_8mb_ota.csv build_flags = -DBOARD_HAS_PSRAM -mfix-esp32-psram-cache-issue - -DCORE_DEBUG_LEVEL=1 + -DCORE_DEBUG_LEVEL=3 + -DCONFIG_BT_NIMBLE_ENABLED=1 + -DCONFIG_SPIRAM_USE_MALLOC=1 monitor_speed = 115200 upload_speed = 921600 lib_deps = tzapu/WiFiManager@^2.0.17 bblanchon/ArduinoJson@^7.0.0 + h2zero/NimBLE-Arduino@^1.4.2 [env:native] platform = native From d5afd0bd8750cea07607779af65e5e477b4b5e65 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 13 Apr 2026 14:02:28 -0700 Subject: [PATCH 03/28] =?UTF-8?q?feat:=20config=20module=20=E2=80=94=20NVS?= =?UTF-8?q?=20read/write=20via=20Preferences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add config.h/config.cpp for DeviceConfig NVS persistence using Arduino Preferences library. Add minimal main.cpp stub. Fix partition table overlap (nvs 0x6000→0x5000, otadata 0xf000→0xe000) so firmware builds. Co-Authored-By: Claude Sonnet 4.6 --- firmware/partitions_8mb_ota.csv | 4 +-- firmware/src/config.cpp | 48 +++++++++++++++++++++++++++++++++ firmware/src/config.h | 24 +++++++++++++++++ firmware/src/main.cpp | 3 +++ 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 firmware/src/config.cpp create mode 100644 firmware/src/config.h create mode 100644 firmware/src/main.cpp diff --git a/firmware/partitions_8mb_ota.csv b/firmware/partitions_8mb_ota.csv index 55d0f5c..fd51f8b 100644 --- a/firmware/partitions_8mb_ota.csv +++ b/firmware/partitions_8mb_ota.csv @@ -1,6 +1,6 @@ # Name, Type, SubType, Offset, Size -nvs, data, nvs, 0x9000, 0x6000 -otadata, data, ota, 0xf000, 0x2000 +nvs, data, nvs, 0x9000, 0x5000 +otadata, data, ota, 0xe000, 0x2000 app0, app, ota_0, 0x10000, 0x300000 app1, app, ota_1, 0x310000, 0x300000 spiffs, data, spiffs, 0x610000, 0x1F0000 diff --git a/firmware/src/config.cpp b/firmware/src/config.cpp new file mode 100644 index 0000000..1ae4a9e --- /dev/null +++ b/firmware/src/config.cpp @@ -0,0 +1,48 @@ +// 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(); +} diff --git a/firmware/src/config.h b/firmware/src/config.h new file mode 100644 index 0000000..3e07891 --- /dev/null +++ b/firmware/src/config.h @@ -0,0 +1,24 @@ +// 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(); diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp new file mode 100644 index 0000000..27f3768 --- /dev/null +++ b/firmware/src/main.cpp @@ -0,0 +1,3 @@ +#include +void setup() {} +void loop() {} From 74bff0912b061b3970ed07d1ba7746f7490c4762 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 13 Apr 2026 14:05:26 -0700 Subject: [PATCH 04/28] =?UTF-8?q?fix:=20config=5Fsave=5Fwifi=20=E2=80=94?= =?UTF-8?q?=20always=20write=20both=20credentials?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace short-circuit boolean evaluation of putString return values with separate size_t variables so both writes always execute regardless of whether the first succeeds. Co-Authored-By: Claude Sonnet 4.6 --- firmware/src/config.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/firmware/src/config.cpp b/firmware/src/config.cpp index 1ae4a9e..8e349d4 100644 --- a/firmware/src/config.cpp +++ b/firmware/src/config.cpp @@ -25,10 +25,10 @@ bool config_load(DeviceConfig& cfg) { 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); + size_t r1 = prefs.putString("wifi_ssid", ssid); + size_t r2 = prefs.putString("wifi_pass", pass); prefs.end(); - return ok; + return (r1 > 0) && (r2 > 0); } bool config_has_wifi() { From 47f3f6afef753783a2fe39f3fd50f76e92a4773d Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 13 Apr 2026 14:20:24 -0700 Subject: [PATCH 05/28] feat: HMAC-SHA256 signing module with native tests Co-Authored-By: Claude Sonnet 4.6 --- firmware/lib/hmac/hmac.cpp | 64 +++++++++++++++++++++++++ firmware/lib/hmac/hmac.h | 16 +++++++ firmware/platformio.ini | 2 + firmware/test/test_native/test_hmac.cpp | 34 +++++++++++++ 4 files changed, 116 insertions(+) create mode 100644 firmware/lib/hmac/hmac.cpp create mode 100644 firmware/lib/hmac/hmac.h create mode 100644 firmware/test/test_native/test_hmac.cpp diff --git a/firmware/lib/hmac/hmac.cpp b/firmware/lib/hmac/hmac.cpp new file mode 100644 index 0000000..899f5f2 --- /dev/null +++ b/firmware/lib/hmac/hmac.cpp @@ -0,0 +1,64 @@ +// 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); +} diff --git a/firmware/lib/hmac/hmac.h b/firmware/lib/hmac/hmac.h new file mode 100644 index 0000000..0b291fc --- /dev/null +++ b/firmware/lib/hmac/hmac.h @@ -0,0 +1,16 @@ +// 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); diff --git a/firmware/platformio.ini b/firmware/platformio.ini index 8ac262b..fe3cbed 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -26,3 +26,5 @@ test_framework = unity build_flags = -std=c++17 -DNATIVE_TEST +lib_deps = + kochcodes/mbedtls@^3.6.2 diff --git a/firmware/test/test_native/test_hmac.cpp b/firmware/test/test_native/test_hmac.cpp new file mode 100644 index 0000000..9173f30 --- /dev/null +++ b/firmware/test/test_native/test_hmac.cpp @@ -0,0 +1,34 @@ +// firmware/test/test_native/test_hmac.cpp +#include +#include "hmac.h" + +void setUp(void) {} +void tearDown(void) {} + +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); + + TEST_ASSERT_EQUAL_STRING("90f5fa5fdbf7f95e7475791bf5bb90cdef7f16534d9a7d263fc588305bad0525", 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(); +} From 7662fc4c2557162dec80e6420326901047d4905d Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 13 Apr 2026 14:24:48 -0700 Subject: [PATCH 06/28] =?UTF-8?q?fix:=20HMAC=20module=20=E2=80=94=20mbedTL?= =?UTF-8?q?S=20error=20handling,=20hex=20guard,=20test=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- firmware/lib/hmac/hmac.cpp | 16 +++++++++++----- firmware/test/test_native/test_hmac.cpp | 7 +++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/firmware/lib/hmac/hmac.cpp b/firmware/lib/hmac/hmac.cpp index 899f5f2..fee48d5 100644 --- a/firmware/lib/hmac/hmac.cpp +++ b/firmware/lib/hmac/hmac.cpp @@ -15,28 +15,33 @@ static HString bytes_to_hex(const uint8_t* bytes, size_t len) { } static void hex_to_bytes(const HString& hex, uint8_t* out, size_t out_len) { + if (hex.size() % 2 != 0) return; // malformed — odd-length hex 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]) { +static bool 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); + int ret = mbedtls_md_setup(&ctx, info, 0); + if (ret != 0) { mbedtls_md_free(&ctx); return false; } mbedtls_md_starts(&ctx); mbedtls_md_update(&ctx, data, len); mbedtls_md_finish(&ctx, out); mbedtls_md_free(&ctx); + return true; } 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); + uint8_t body_hash[32] = {}; + if (!sha256((const uint8_t*)body.c_str(), body.size(), body_hash)) { + return HString{}; + } HString body_hash_hex = bytes_to_hex(body_hash, 32); // 2. Build message @@ -54,7 +59,8 @@ HString hmac_sign(const HString& secret_hex, const HString& device_id, 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); + int ret2 = mbedtls_md_setup(&ctx, info, 1); + if (ret2 != 0) { mbedtls_md_free(&ctx); return HString{}; } 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); diff --git a/firmware/test/test_native/test_hmac.cpp b/firmware/test/test_native/test_hmac.cpp index 9173f30..5bd6779 100644 --- a/firmware/test/test_native/test_hmac.cpp +++ b/firmware/test/test_native/test_hmac.cpp @@ -5,6 +5,13 @@ void setUp(void) {} void tearDown(void) {} +// Expected value derived via: +// import hmac, hashlib +// secret = bytes.fromhex("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20") +// body = '{"location_id":"retailer-123","records":[]}' +// body_hash = hashlib.sha256(body.encode()).hexdigest() +// msg = f"dc-0042:1712000000:{body_hash}" +// hmac.new(secret, msg.encode(), hashlib.sha256).hexdigest() void test_hmac_known_vector() { HString secret = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; HString device = "dc-0042"; From e6843584cf8d333cea98dc02cd21f2f80629b724 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 13 Apr 2026 14:26:34 -0700 Subject: [PATCH 07/28] =?UTF-8?q?feat:=20CV=20module=20=E2=80=94=20frame?= =?UTF-8?q?=20diff=20+=20threshold=20(blob=20tracking=20TODO)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- firmware/lib/cv/cv.cpp | 58 +++++++++++++++++++++++++ firmware/lib/cv/cv.h | 40 ++++++++++++++++++ firmware/test/test_cv/test_cv.cpp | 70 +++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 firmware/lib/cv/cv.cpp create mode 100644 firmware/lib/cv/cv.h create mode 100644 firmware/test/test_cv/test_cv.cpp diff --git a/firmware/lib/cv/cv.cpp b/firmware/lib/cv/cv.cpp new file mode 100644 index 0000000..46f1fff --- /dev/null +++ b/firmware/lib/cv/cv.cpp @@ -0,0 +1,58 @@ +// firmware/lib/cv/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; +} + +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; + } +} + +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); + + 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) { + if (state.frame_index - state.last_motion_frame > 10) { + memcpy(state.background, frame, CV_PIXELS); + } + 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 added in Tasks 5-6 + return result; +} diff --git a/firmware/lib/cv/cv.h b/firmware/lib/cv/cv.h new file mode 100644 index 0000000..30de5b1 --- /dev/null +++ b/firmware/lib/cv/cv.h @@ -0,0 +1,40 @@ +// firmware/lib/cv/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; + +static const uint8_t CV_DIFF_THRESH = 30; +static const int CV_MIN_BLOB_PX = 64; +static const float CV_MAX_MOVE = 15.0f; +static const int CV_MAX_MISSED = 10; + +struct CVTrack { + int id; + float x, y; + bool above_line; + int missed; +}; + +struct CVState { + uint8_t background[CV_PIXELS]; + bool bg_valid; + uint32_t last_motion_frame; + uint32_t frame_index; + int next_id; + std::vector tracks; + int entries; + int exits; +}; + +struct CVResult { + int entries_delta; + int exits_delta; +}; + +void cv_init(CVState& state); +CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct); +void cv_reset_counts(CVState& state); diff --git a/firmware/test/test_cv/test_cv.cpp b/firmware/test/test_cv/test_cv.cpp new file mode 100644 index 0000000..ecdc9be --- /dev/null +++ b/firmware/test/test_cv/test_cv.cpp @@ -0,0 +1,70 @@ +// firmware/test/test_native/test_cv.cpp +#include +#include +#include "cv.h" + +static void fill_frame(uint8_t* f, uint8_t val) { + memset(f, val, CV_PIXELS); +} + +void setUp(void) {} +void tearDown(void) {} + +void test_frame_diff_no_change_gives_no_fg() { + CVState state; + cv_init(state); + + uint8_t frame[CV_PIXELS]; + fill_frame(frame, 128); + + CVResult r1 = cv_process(state, frame, 50); + TEST_ASSERT_EQUAL_INT(0, r1.entries_delta); + + 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_no_crash() { + CVState state; + cv_init(state); + + uint8_t bg[CV_PIXELS], fg_frame[CV_PIXELS]; + fill_frame(bg, 100); + fill_frame(fg_frame, 200); + + cv_process(state, bg, 50); + CVResult r = cv_process(state, fg_frame, 50); + + // Tracking not yet implemented — just verify no crash and result is zero + TEST_ASSERT_EQUAL_INT(0, r.entries_delta); + TEST_ASSERT_EQUAL_INT(0, r.exits_delta); +} + +void test_cv_init_clears_state() { + CVState state; + state.entries = 99; state.exits = 88; + cv_init(state); + TEST_ASSERT_EQUAL_INT(0, state.entries); + TEST_ASSERT_EQUAL_INT(0, state.exits); + TEST_ASSERT_FALSE(state.bg_valid); +} + +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); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_frame_diff_no_change_gives_no_fg); + RUN_TEST(test_frame_diff_large_change_detected_no_crash); + RUN_TEST(test_cv_init_clears_state); + RUN_TEST(test_cv_reset_counts); + return UNITY_END(); +} From 136b22bc1bc6e0fef683851e2b802c9e767600b7 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 13 Apr 2026 14:33:12 -0700 Subject: [PATCH 08/28] =?UTF-8?q?fix:=20cv=5Finit=20=E2=80=94=20replace=20?= =?UTF-8?q?memset=20with=20value-init=20to=20avoid=20UB=20on=20std::vector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fix stale path comment in test/test_cv/test_cv.cpp. Co-Authored-By: Claude Sonnet 4.6 --- firmware/lib/cv/cv.cpp | 2 +- firmware/test/test_cv/test_cv.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/firmware/lib/cv/cv.cpp b/firmware/lib/cv/cv.cpp index 46f1fff..0928296 100644 --- a/firmware/lib/cv/cv.cpp +++ b/firmware/lib/cv/cv.cpp @@ -5,7 +5,7 @@ #include void cv_init(CVState& state) { - memset(&state, 0, sizeof(CVState)); + state = CVState{}; // value-initialize — calls vector default ctor correctly state.next_id = 1; } diff --git a/firmware/test/test_cv/test_cv.cpp b/firmware/test/test_cv/test_cv.cpp index ecdc9be..edea2b9 100644 --- a/firmware/test/test_cv/test_cv.cpp +++ b/firmware/test/test_cv/test_cv.cpp @@ -1,4 +1,4 @@ -// firmware/test/test_native/test_cv.cpp +// firmware/test/test_cv/test_cv.cpp #include #include #include "cv.h" From b664753596c168295d9cfdfcdbd4c88e7f08be3f Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 13 Apr 2026 14:39:23 -0700 Subject: [PATCH 09/28] feat: CV blob detection and centroid tracking Add BFS flood-fill blob extraction, centroid finding, and nearest-neighbour track matching/spawning inside cv_process. Add test verifying a new blob spawns a track. Co-Authored-By: Claude Sonnet 4.6 --- firmware/lib/cv/cv.cpp | 93 ++++++++++++++++++++++++++++++- firmware/test/test_cv/test_cv.cpp | 23 ++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/firmware/lib/cv/cv.cpp b/firmware/lib/cv/cv.cpp index 0928296..552b84d 100644 --- a/firmware/lib/cv/cv.cpp +++ b/firmware/lib/cv/cv.cpp @@ -3,6 +3,7 @@ #include #include #include +#include void cv_init(CVState& state) { state = CVState{}; // value-initialize — calls vector default ctor correctly @@ -14,6 +15,53 @@ void cv_reset_counts(CVState& state) { state.exits = 0; } +struct Point { int x, y; }; + +// BFS flood fill. Marks visited pixels (sets fg to 0). Returns {-1,-1} if blob < CV_MIN_BLOB_PX. +static std::pair extract_blob(uint8_t* fg, int start_x, int start_y) { + std::vector queue; + queue.reserve(512); + queue.push_back({start_x, start_y}); + fg[start_y * CV_W + start_x] = 0; + + 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}; +} + +static std::vector> find_centroids(const uint8_t* fg) { + std::vector> result; + 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; +} + static void frame_diff(const uint8_t* frame, const uint8_t* bg, uint8_t* fg, int pixels) { for (int i = 0; i < pixels; i++) { @@ -53,6 +101,49 @@ CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct) { } state.last_motion_frame = state.frame_index; - // Blob detection and tracking added in Tasks 5-6 + + auto centroids = find_centroids(fg); + + 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++; + } + } + + state.tracks.erase( + std::remove_if(state.tracks.begin(), state.tracks.end(), + [](const CVTrack& t){ return t.missed > CV_MAX_MISSED; }), + state.tracks.end()); + + 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); + } + // Line crossing check added in Task 6 return result; } diff --git a/firmware/test/test_cv/test_cv.cpp b/firmware/test/test_cv/test_cv.cpp index edea2b9..d8aae25 100644 --- a/firmware/test/test_cv/test_cv.cpp +++ b/firmware/test/test_cv/test_cv.cpp @@ -60,11 +60,34 @@ void test_cv_reset_counts() { TEST_ASSERT_EQUAL_INT(0, state.exits); } +void test_tracking_spawns_track_for_new_blob() { + CVState state; + cv_init(state); + + uint8_t bg[CV_PIXELS]; + fill_frame(bg, 100); + cv_process(state, bg, 50); // init background + + // Frame with a bright 30x30 blob in top-left quadrant + 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()); + TEST_ASSERT_FLOAT_WITHIN(5.0f, 20.0f, state.tracks[0].x); + TEST_ASSERT_FLOAT_WITHIN(5.0f, 20.0f, state.tracks[0].y); +} + int main() { UNITY_BEGIN(); RUN_TEST(test_frame_diff_no_change_gives_no_fg); RUN_TEST(test_frame_diff_large_change_detected_no_crash); RUN_TEST(test_cv_init_clears_state); RUN_TEST(test_cv_reset_counts); + RUN_TEST(test_tracking_spawns_track_for_new_blob); return UNITY_END(); } From 655abc914b5c7ae066ae0101fd4b3b7d64b45002 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 13 Apr 2026 14:41:11 -0700 Subject: [PATCH 10/28] =?UTF-8?q?fix:=20CV=20find=5Fcentroids=20=E2=80=94?= =?UTF-8?q?=20static=20fg=5Fcopy=20to=20prevent=209KB=20stack=20allocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- firmware/lib/cv/cv.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/firmware/lib/cv/cv.cpp b/firmware/lib/cv/cv.cpp index 552b84d..ca4186c 100644 --- a/firmware/lib/cv/cv.cpp +++ b/firmware/lib/cv/cv.cpp @@ -17,6 +17,8 @@ void cv_reset_counts(CVState& state) { struct Point { int x, y; }; +// Note: queue may grow to CV_PIXELS entries (~72KB) on large blobs. +// Requires PSRAM (enabled via -DBOARD_HAS_PSRAM in platformio.ini). // BFS flood fill. Marks visited pixels (sets fg to 0). Returns {-1,-1} if blob < CV_MIN_BLOB_PX. static std::pair extract_blob(uint8_t* fg, int start_x, int start_y) { std::vector queue; @@ -49,7 +51,7 @@ static std::pair extract_blob(uint8_t* fg, int start_x, int start_y static std::vector> find_centroids(const uint8_t* fg) { std::vector> result; - uint8_t fg_copy[CV_PIXELS]; + static uint8_t fg_copy[CV_PIXELS]; // static to avoid 9KB stack allocation memcpy(fg_copy, fg, CV_PIXELS); for (int y = 0; y < CV_H; y++) { From 0a6470a0965decd316e4d5a81bd04d5be5801123 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 13 Apr 2026 15:10:01 -0700 Subject: [PATCH 11/28] feat: CV line-crossing entry/exit detection with tests Co-Authored-By: Claude Sonnet 4.6 --- firmware/lib/cv/cv.cpp | 19 +++++++- firmware/test/test_cv/test_cv.cpp | 73 +++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/firmware/lib/cv/cv.cpp b/firmware/lib/cv/cv.cpp index ca4186c..7f0be59 100644 --- a/firmware/lib/cv/cv.cpp +++ b/firmware/lib/cv/cv.cpp @@ -146,6 +146,23 @@ CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct) { t.missed = 0; state.tracks.push_back(t); } - // Line crossing check added in Task 6 + // Line crossing check + for (auto& track : state.tracks) { + if (track.missed > 0) continue; // only check tracks matched this frame + bool now_above = (track.y < line_y); + if (now_above != track.above_line) { + if (!now_above) { + // was above, now below → entry + state.entries++; + result.entries_delta++; + } else { + // was below, now above → exit + state.exits++; + result.exits_delta++; + } + } + track.above_line = now_above; + } + return result; } diff --git a/firmware/test/test_cv/test_cv.cpp b/firmware/test/test_cv/test_cv.cpp index d8aae25..72b9cba 100644 --- a/firmware/test/test_cv/test_cv.cpp +++ b/firmware/test/test_cv/test_cv.cpp @@ -82,6 +82,76 @@ void test_tracking_spawns_track_for_new_blob() { TEST_ASSERT_FLOAT_WITHIN(5.0f, 20.0f, state.tracks[0].y); } +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; step ≤14px per frame to stay within CV_MAX_MOVE + uint8_t bg[CV_PIXELS]; + fill_frame(bg, 100); + cv_process(state, bg, 50); // init background + + // Walk blob from y=20 toward line; crossing occurs at y=48 (above→below) + // Stop at crossing frame and assert its result + int setup[] = {20, 34}; + for (int i = 0; i < 2; i++) { + uint8_t f[CV_PIXELS]; make_blob_frame(f, 48, setup[i]); + cv_process(state, f, 50); + } + uint8_t fcross[CV_PIXELS]; make_blob_frame(fcross, 48, 48); + CVResult r = cv_process(state, fcross, 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); + + // Walk blob from y=76 toward line; crossing occurs at y=34 (below→above) + // Stop at crossing frame and assert its result + int setup[] = {76, 62, 48}; + for (int i = 0; i < 3; i++) { + uint8_t f[CV_PIXELS]; make_blob_frame(f, 48, setup[i]); + cv_process(state, f, 50); + } + uint8_t fcross[CV_PIXELS]; make_blob_frame(fcross, 48, 34); + CVResult r = cv_process(state, fcross, 50); + + TEST_ASSERT_EQUAL_INT(0, r.entries_delta); + TEST_ASSERT_EQUAL_INT(1, r.exits_delta); +} + +void test_no_crossing_same_side_no_count() { + 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, 20); // above line + cv_process(state, f1, 50); + + uint8_t f2[CV_PIXELS]; make_blob_frame(f2, 48, 30); // still above line, moved closer + CVResult r = cv_process(state, f2, 50); + + TEST_ASSERT_EQUAL_INT(0, r.entries_delta); + TEST_ASSERT_EQUAL_INT(0, r.exits_delta); +} + int main() { UNITY_BEGIN(); RUN_TEST(test_frame_diff_no_change_gives_no_fg); @@ -89,5 +159,8 @@ int main() { RUN_TEST(test_cv_init_clears_state); RUN_TEST(test_cv_reset_counts); 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_no_crossing_same_side_no_count); return UNITY_END(); } From 99756bdbafbf7069e0e2268c8e6cb170373a28b4 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 13 Apr 2026 17:33:10 -0700 Subject: [PATCH 12/28] =?UTF-8?q?feat:=20camera=20module=20=E2=80=94=20OV3?= =?UTF-8?q?660=20init=20and=2096x96=20grayscale=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- firmware/src/camera.cpp | 88 +++++++++++++++++++++++++++++++++++++++++ firmware/src/camera.h | 11 ++++++ 2 files changed, 99 insertions(+) create mode 100644 firmware/src/camera.cpp create mode 100644 firmware/src/camera.h diff --git a/firmware/src/camera.cpp b/firmware/src/camera.cpp new file mode 100644 index 0000000..2e73e23 --- /dev/null +++ b/firmware/src/camera.cpp @@ -0,0 +1,88 @@ +// 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" +#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 — adjust if mounting orientation differs + sensor_t* s = esp_camera_sensor_get(); + s->set_vflip(s, 1); + s->set_hmirror(s, 0); + + return true; +} + +// Box-filter downscale from QVGA (320x240) to 96x96 grayscale +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; +} diff --git a/firmware/src/camera.h b/firmware/src/camera.h new file mode 100644 index 0000000..950fdbe --- /dev/null +++ b/firmware/src/camera.h @@ -0,0 +1,11 @@ +// firmware/src/camera.h +#pragma once +#include +#include + +// Initialise OV3660 camera for TimerCamera-F. Returns false on failure. +bool camera_init(); + +// Capture one frame, downscale to 96x96 grayscale, write into buf. +// buf must be CV_PIXELS (9216) bytes. Returns false on capture failure. +bool camera_capture_96(uint8_t* buf); From 29808e07a62adefca3b9dc2315de99ffe3775c8f Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 13 Apr 2026 17:34:38 -0700 Subject: [PATCH 13/28] =?UTF-8?q?fix:=20camera=20=E2=80=94=20null-check=20?= =?UTF-8?q?sensor=20handle=20before=20set=5Fvflip/set=5Fhmirror?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- firmware/src/camera.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/firmware/src/camera.cpp b/firmware/src/camera.cpp index 2e73e23..7a5d5ba 100644 --- a/firmware/src/camera.cpp +++ b/firmware/src/camera.cpp @@ -54,8 +54,10 @@ bool camera_init() { // Flip vertically — adjust if mounting orientation differs sensor_t* s = esp_camera_sensor_get(); - s->set_vflip(s, 1); - s->set_hmirror(s, 0); + if (s) { + s->set_vflip(s, 1); + s->set_hmirror(s, 0); + } return true; } From ccbbf689cf59d4546661509a1be22ce3d0be08b4 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 14 Apr 2026 06:24:49 -0700 Subject: [PATCH 14/28] feat: BLE passive scanner with RSSI bucketing and MAC hashing Add passive BLE scan module using NimBLE for WiFi coexistence. Tracks unique devices per hour with SHA256-hashed MACs, RSSI bucketing (near/mid/far), max concurrent count, and thread-safe collect/reset. Co-Authored-By: Claude Sonnet 4.6 --- firmware/src/ble_scanner.cpp | 98 ++++++++++++++++++++++++++++++++++++ firmware/src/ble_scanner.h | 25 +++++++++ 2 files changed, 123 insertions(+) create mode 100644 firmware/src/ble_scanner.cpp create mode 100644 firmware/src/ble_scanner.h diff --git a/firmware/src/ble_scanner.cpp b/firmware/src/ble_scanner.cpp new file mode 100644 index 0000000..0b33e89 --- /dev/null +++ b/firmware/src/ble_scanner.cpp @@ -0,0 +1,98 @@ +// firmware/src/ble_scanner.cpp +#include "ble_scanner.h" +#include +#include +#include +#include "mbedtls/md.h" +#include + +#define RSSI_NEAR -65 +#define RSSI_MID -80 + +static portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED; + +struct DeviceObs { + int rssi_sum; + int count; +}; + +static std::map s_seen; +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 NimBLEAdvertisedDeviceCallbacks { + void onResult(NimBLEAdvertisedDevice* dev) override { + String mac = String(dev->getAddress().toString().c_str()); + String hash = sha256_prefix(mac); + int rssi = dev->getRSSI(); + + portENTER_CRITICAL(&s_mux); + auto it = s_seen.find(hash); + if (it == s_seen.end()) { + s_seen[hash] = {rssi, 1}; + } else { + it->second.rssi_sum += rssi; + it->second.count++; + } + int concurrent = (int)s_seen.size(); + if (concurrent > s_max_concurrent) s_max_concurrent = concurrent; + portEXIT_CRITICAL(&s_mux); + } +}; + +static ScanCallback s_callback; +static NimBLEScan* s_scan = nullptr; + +void ble_scanner_start() { + NimBLEDevice::init(""); + s_scan = NimBLEDevice::getScan(); + s_scan->setAdvertisedDeviceCallbacks(&s_callback, true); // true = allow duplicates + s_scan->setActiveScan(false); // passive + s_scan->setInterval(100); + s_scan->setWindow(99); + s_scan->setMaxResults(0); // don't store results — callback-only + 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) { + portENTER_CRITICAL(&s_mux); + + 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 = kv.second.rssi_sum / (float)kv.second.count; + if (avg > RSSI_NEAR) rec.near_count++; + else if (avg > RSSI_MID) rec.mid_count++; + else rec.far_count++; + rec.device_hashes.push_back(kv.first); + } + + s_seen.clear(); + s_max_concurrent = 0; + + portEXIT_CRITICAL(&s_mux); + return rec; +} diff --git a/firmware/src/ble_scanner.h b/firmware/src/ble_scanner.h new file mode 100644 index 0000000..6ad35ae --- /dev/null +++ b/firmware/src/ble_scanner.h @@ -0,0 +1,25 @@ +// 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) first 16 hex chars +}; + +// Start continuous passive BLE scan (call once at boot). +void ble_scanner_start(); + +// Pause scan for ~3s during HTTP upload. +void ble_scanner_pause(); +void ble_scanner_resume(); + +// Collect current hour's record and reset accumulators. Thread-safe. +BLEHourlyRecord ble_scanner_collect(uint32_t period_start, uint32_t period_end); From 6422e052df42009fecbfc5b329a9bef31ce91dd1 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 14 Apr 2026 06:26:20 -0700 Subject: [PATCH 15/28] =?UTF-8?q?fix:=20ble=5Fscanner=20sha256=5Fprefix=20?= =?UTF-8?q?=E2=80=94=20guard=20mbedTLS=20null=20info=20and=20setup=20failu?= =?UTF-8?q?re?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- firmware/src/ble_scanner.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/firmware/src/ble_scanner.cpp b/firmware/src/ble_scanner.cpp index 0b33e89..6534980 100644 --- a/firmware/src/ble_scanner.cpp +++ b/firmware/src/ble_scanner.cpp @@ -20,15 +20,21 @@ static std::map s_seen; 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); + if (!info) return String(); // SHA256 not available + + uint8_t hash[32] = {}; + mbedtls_md_context_t ctx; mbedtls_md_init(&ctx); - mbedtls_md_setup(&ctx, info, 0); + if (mbedtls_md_setup(&ctx, info, 0) != 0) { + mbedtls_md_free(&ctx); + return String(); + } 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; } From 244426ec8b018fc1d1cda3bfa05d64ce73caedd4 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 14 Apr 2026 06:28:24 -0700 Subject: [PATCH 16/28] =?UTF-8?q?feat:=20reporter=20=E2=80=94=20HMAC-signe?= =?UTF-8?q?d=20hourly=20POST=20with=2024-record=20offline=20buffer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix Arduino String .size() → .length() in hmac.cpp (pre-existing bug surfaced by compilation). Co-Authored-By: Claude Sonnet 4.6 --- firmware/lib/hmac/hmac.cpp | 10 +-- firmware/src/reporter.cpp | 130 +++++++++++++++++++++++++++++++++++++ firmware/src/reporter.h | 20 ++++++ 3 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 firmware/src/reporter.cpp create mode 100644 firmware/src/reporter.h diff --git a/firmware/lib/hmac/hmac.cpp b/firmware/lib/hmac/hmac.cpp index fee48d5..6e174b2 100644 --- a/firmware/lib/hmac/hmac.cpp +++ b/firmware/lib/hmac/hmac.cpp @@ -15,8 +15,8 @@ static HString bytes_to_hex(const uint8_t* bytes, size_t len) { } static void hex_to_bytes(const HString& hex, uint8_t* out, size_t out_len) { - if (hex.size() % 2 != 0) return; // malformed — odd-length hex - for (size_t i = 0; i < out_len && (i * 2 + 1) < hex.size(); i++) { + if (hex.length() % 2 != 0) return; // malformed — odd-length hex + for (size_t i = 0; i < out_len && (i * 2 + 1) < hex.length(); i++) { char byte_str[3] = {hex[i*2], hex[i*2+1], 0}; out[i] = (uint8_t)strtol(byte_str, nullptr, 16); } @@ -39,7 +39,7 @@ 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] = {}; - if (!sha256((const uint8_t*)body.c_str(), body.size(), body_hash)) { + if (!sha256((const uint8_t*)body.c_str(), body.length(), body_hash)) { return HString{}; } HString body_hash_hex = bytes_to_hex(body_hash, 32); @@ -50,7 +50,7 @@ HString hmac_sign(const HString& secret_hex, const HString& device_id, HString message = device_id + ":" + ts_buf + ":" + body_hash_hex; // 3. Decode secret from hex - size_t secret_len = secret_hex.size() / 2; + size_t secret_len = secret_hex.length() / 2; uint8_t secret[64] = {}; hex_to_bytes(secret_hex, secret, secret_len); @@ -62,7 +62,7 @@ HString hmac_sign(const HString& secret_hex, const HString& device_id, int ret2 = mbedtls_md_setup(&ctx, info, 1); if (ret2 != 0) { mbedtls_md_free(&ctx); return HString{}; } mbedtls_md_hmac_starts(&ctx, secret, secret_len); - mbedtls_md_hmac_update(&ctx, (const uint8_t*)message.c_str(), message.size()); + mbedtls_md_hmac_update(&ctx, (const uint8_t*)message.c_str(), message.length()); mbedtls_md_hmac_finish(&ctx, hmac_result); mbedtls_md_free(&ctx); diff --git a/firmware/src/reporter.cpp b/firmware/src/reporter.cpp new file mode 100644 index 0000000..4fe2ec0 --- /dev/null +++ b/firmware/src/reporter.cpp @@ -0,0 +1,130 @@ +// firmware/src/reporter.cpp +#include "reporter.h" +#include "hmac.h" +#include +#include +#include +#include +#include + +static std::vector s_cam_buf; +static std::vector s_ble_buf; + +// Returns current Unix timestamp (NTP-synced via configTime in main.cpp). +static uint32_t now_ts() { + return (uint32_t)time(nullptr); +} + +static bool post_json(const DeviceConfig& cfg, const char* path, const String& body) { + uint32_t ts = now_ts(); + String sig = hmac_sign(cfg.hmac_secret, cfg.device_id, ts, body); + if (sig.isEmpty()) return false; // HMAC failed + + 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; +} + +static void buf_add_cam(const CameraHourlyRecord& r) { + if ((int)s_cam_buf.size() < REPORTER_MAX_BUFFER) s_cam_buf.push_back(r); +} +static void buf_add_ble(const BLEHourlyRecord& r) { + if ((int)s_ble_buf.size() < REPORTER_MAX_BUFFER) s_ble_buf.push_back(r); +} + +void reporter_submit_camera(const DeviceConfig& cfg, const CameraHourlyRecord& rec) { + if (WiFi.status() != WL_CONNECTED) { buf_add_cam(rec); return; } + + std::vector batch; + batch.insert(batch.end(), s_cam_buf.begin(), s_cam_buf.end()); + batch.push_back(rec); + s_cam_buf.clear(); + + String body = build_camera_batch(cfg, batch); + if (!post_json(cfg, "/api/v1/camera/events/batch", body)) { + for (const auto& r : batch) buf_add_cam(r); + } +} + +void reporter_submit_ble(const DeviceConfig& cfg, const BLEHourlyRecord& rec) { + if (WiFi.status() != WL_CONNECTED) { buf_add_ble(rec); return; } + + std::vector batch; + batch.insert(batch.end(), s_ble_buf.begin(), s_ble_buf.end()); + batch.push_back(rec); + 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) buf_add_ble(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; + doc["wifi_rssi"] = wifi_rssi; + doc["pending_records"] = (int)(s_cam_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_cam_buf.empty()) { + String body = build_camera_batch(cfg, s_cam_buf); + if (post_json(cfg, "/api/v1/camera/events/batch", body)) s_cam_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(); + } +} diff --git a/firmware/src/reporter.h b/firmware/src/reporter.h new file mode 100644 index 0000000..167ac3e --- /dev/null +++ b/firmware/src/reporter.h @@ -0,0 +1,20 @@ +// 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"; + +void reporter_submit_camera(const DeviceConfig& cfg, const CameraHourlyRecord& rec); +void reporter_submit_ble(const DeviceConfig& cfg, const BLEHourlyRecord& rec); +void reporter_heartbeat(const DeviceConfig& cfg, uint32_t uptime_s, int wifi_rssi); +void reporter_flush(const DeviceConfig& cfg); From 988443f207a8510bc3520d2ba327f5617a5ae926 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 14 Apr 2026 06:33:00 -0700 Subject: [PATCH 17/28] =?UTF-8?q?fix:=20reporter=20=E2=80=94=20correct=20r?= =?UTF-8?q?e-buffer=20on=20POST=20failure,=20NTP=20guard,=20TLS=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reporter_submit_camera/ble: cap batch to REPORTER_MAX_BUFFER before POST and assign whole capped batch back to buffer on failure, fixing silent record drop when batch > buffer capacity - post_json: reject sends when ts < 1700000000 (clock not NTP-synced) - post_json: add comment documenting intentional no-cert-validation Co-Authored-By: Claude Sonnet 4.6 --- firmware/src/reporter.cpp | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/firmware/src/reporter.cpp b/firmware/src/reporter.cpp index 4fe2ec0..a78b23f 100644 --- a/firmware/src/reporter.cpp +++ b/firmware/src/reporter.cpp @@ -17,11 +17,17 @@ static uint32_t now_ts() { static bool post_json(const DeviceConfig& cfg, const char* path, const String& body) { uint32_t ts = now_ts(); + // Reject if NTP hasn't synced yet (timestamp would be near epoch 0) + if (ts < 1700000000UL) return false; // pre-2023 → clock not valid String sig = hmac_sign(cfg.hmac_secret, cfg.device_id, ts, body); if (sig.isEmpty()) return false; // HMAC failed HTTPClient http; String url = String(REPORTER_API_HOST) + path; + // NOTE: Certificate validation is disabled — connection is encrypted but + // server identity is not verified. To enable validation, use WiFiClientSecure + // with setCACert() before calling http.begin(client, url). + // Acceptable for this deployment: devices operate on store WiFi, not public internet. http.begin(url); http.addHeader("Content-Type", "application/json"); http.addHeader("X-Device-Id", cfg.device_id); @@ -87,9 +93,15 @@ void reporter_submit_camera(const DeviceConfig& cfg, const CameraHourlyRecord& r batch.push_back(rec); s_cam_buf.clear(); + // Cap to MAX_BUFFER: drop oldest to make room for newest + if ((int)batch.size() > REPORTER_MAX_BUFFER) { + batch.erase(batch.begin(), + batch.begin() + ((int)batch.size() - REPORTER_MAX_BUFFER)); + } + String body = build_camera_batch(cfg, batch); if (!post_json(cfg, "/api/v1/camera/events/batch", body)) { - for (const auto& r : batch) buf_add_cam(r); + s_cam_buf = batch; // re-buffer the whole capped batch } } @@ -101,9 +113,15 @@ void reporter_submit_ble(const DeviceConfig& cfg, const BLEHourlyRecord& rec) { batch.push_back(rec); s_ble_buf.clear(); + // Cap to MAX_BUFFER: drop oldest to make room for newest + if ((int)batch.size() > REPORTER_MAX_BUFFER) { + batch.erase(batch.begin(), + batch.begin() + ((int)batch.size() - REPORTER_MAX_BUFFER)); + } + String body = build_ble_batch(cfg, batch); if (!post_json(cfg, "/api/v1/events/batch", body)) { - for (const auto& r : batch) buf_add_ble(r); + s_ble_buf = batch; // re-buffer the whole capped batch } } From 29737d735ad39c8c8407f67ee410726da3fa8f7e Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 14 Apr 2026 06:44:06 -0700 Subject: [PATCH 18/28] feat: WiFiManager captive portal provisioning Co-Authored-By: Claude Sonnet 4.6 --- firmware/src/provisioning.cpp | 24 ++++++++++++++++++++++++ firmware/src/provisioning.h | 7 +++++++ 2 files changed, 31 insertions(+) create mode 100644 firmware/src/provisioning.cpp create mode 100644 firmware/src/provisioning.h diff --git a/firmware/src/provisioning.cpp b/firmware/src/provisioning.cpp new file mode 100644 index 0000000..8e8d4c6 --- /dev/null +++ b/firmware/src/provisioning.cpp @@ -0,0 +1,24 @@ +// 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"); + wm.setCustomHeadElement( + "" + ); + + bool connected = wm.startConfigPortal("DoorCounter-Setup"); + + if (connected) { + config_save_wifi(wm.getWiFiSSID(), wm.getWiFiPass()); + } + return connected; +} diff --git a/firmware/src/provisioning.h b/firmware/src/provisioning.h new file mode 100644 index 0000000..b20628c --- /dev/null +++ b/firmware/src/provisioning.h @@ -0,0 +1,7 @@ +// firmware/src/provisioning.h +#pragma once +#include + +// Start WiFi captive portal AP and block until user submits credentials +// or timeout_ms elapses. Returns true if WiFi credentials were saved. +bool provisioning_run(uint32_t timeout_ms = 5 * 60 * 1000); From 49da51bc056334419a1409843e3327abc5399a65 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 14 Apr 2026 06:56:59 -0700 Subject: [PATCH 19/28] =?UTF-8?q?feat:=20main.cpp=20=E2=80=94=20FreeRTOS?= =?UTF-8?q?=20tasks,=20LED=20indicators,=20factory=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces empty stub with full application: camera+CV task on core 1 at 5 fps, hourly reporter task on core 0, WiFi reconnect loop, 5-second factory reset via BOOT button (GPIO37), LED on GPIO2 for status. Co-Authored-By: Claude Sonnet 4.6 --- firmware/src/main.cpp | 145 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 2 deletions(-) diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 27f3768..54e6cda 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -1,3 +1,144 @@ +// firmware/src/main.cpp #include -void setup() {} -void loop() {} +#include +#include "config.h" +#include "provisioning.h" +#include "camera.h" +#include "cv.h" +#include "ble_scanner.h" +#include "reporter.h" + +// LED on GPIO2 (TimerCamera-F built-in LED) — verify against board schematic +// Factory reset: hold GPIO37 (BOOT button) for 5 seconds +#define LED_PIN 2 +#define BUTTON_PIN 37 +#define FACTORY_RESET_HOLD_MS 5000 + +#define CAM_FPS 5 +#define CAM_INTERVAL_MS (1000 / CAM_FPS) +#define REPORT_INTERVAL_S 3600 + +static DeviceConfig g_cfg; +static CVState g_cv; + +// LED: simple on/off — blink patterns can be added later +static void led_set(bool on) { digitalWrite(LED_PIN, on ? HIGH : LOW); } + +static void check_factory_reset() { + if (digitalRead(BUTTON_PIN) != LOW) return; + uint32_t held = millis(); + while (digitalRead(BUTTON_PIN) == LOW) { + if (millis() - held >= FACTORY_RESET_HOLD_MS) { + config_clear_wifi(); + ESP.restart(); + } + delay(50); + } +} + +// Camera + CV task — runs on core 1 at 5 fps +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)); + } +} + +// Hourly reporter task — runs on core 0 +static void task_reporter(void*) { + uint32_t last_report_ts = (uint32_t)(time(nullptr)); + while (true) { + vTaskDelay(pdMS_TO_TICKS(10000)); // check every 10s + + uint32_t now = (uint32_t)(time(nullptr)); + // Skip if NTP not synced or hour not elapsed + if (now < 1700000000UL || (now - last_report_ts) < REPORT_INTERVAL_S) continue; + + uint32_t period_start = last_report_ts; + uint32_t period_end = now; + last_report_ts = now; + + // Pause BLE during upload + ble_scanner_pause(); + led_set(true); // yellow indicator (single LED: on = uploading) + + CameraHourlyRecord cam_rec = {period_start, period_end, + g_cv.entries, 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, millis() / 1000, WiFi.RSSI()); + + ble_scanner_resume(); + led_set(false); + } +} + +void setup() { + Serial.begin(115200); + pinMode(LED_PIN, OUTPUT); + pinMode(BUTTON_PIN, INPUT_PULLUP); + led_set(true); // on = booting + + if (!config_load(g_cfg)) { + Serial.println("FATAL: device_id/location_id/hmac_secret not provisioned"); + while (true) { delay(500); led_set(!digitalRead(LED_PIN)); } // fast blink + } + + // Connect to WiFi + if (!config_has_wifi()) { + provisioning_run(); + ESP.restart(); + } + + 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) { + // Saved creds failed — re-provision + provisioning_run(); + ESP.restart(); + } + + led_set(false); // off = connected + + // NTP sync (UTC) + configTime(0, 0, "pool.ntp.org", "time.nist.gov"); + + 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(); + + if (WiFi.status() != WL_CONNECTED) { + led_set(true); // on = no WiFi + WiFi.reconnect(); + delay(5000); + if (WiFi.status() == WL_CONNECTED) { + led_set(false); + reporter_flush(g_cfg); + } + } + delay(1000); +} From 121f7a0a0a4bed2983ca23a10a4348c84089b9ad Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 14 Apr 2026 06:59:09 -0700 Subject: [PATCH 20/28] =?UTF-8?q?fix:=20main.cpp=20=E2=80=94=20static=20fr?= =?UTF-8?q?ame=20buffer,=20mutex=20for=20cv=20state,=20NTP=20init=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- firmware/src/main.cpp | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 54e6cda..8fdf7f6 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -18,8 +18,9 @@ #define CAM_INTERVAL_MS (1000 / CAM_FPS) #define REPORT_INTERVAL_S 3600 -static DeviceConfig g_cfg; -static CVState g_cv; +static DeviceConfig g_cfg; +static CVState g_cv; +static SemaphoreHandle_t s_cv_mutex = nullptr; // LED: simple on/off — blink patterns can be added later static void led_set(bool on) { digitalWrite(LED_PIN, on ? HIGH : LOW); } @@ -38,10 +39,13 @@ static void check_factory_reset() { // Camera + CV task — runs on core 1 at 5 fps static void task_camera(void*) { - uint8_t frame[CV_PIXELS]; + static uint8_t frame[CV_PIXELS]; // static: avoids 9KB on task stack while (true) { if (camera_capture_96(frame)) { - cv_process(g_cv, frame, g_cfg.line_offset); + if (xSemaphoreTake(s_cv_mutex, pdMS_TO_TICKS(100)) == pdTRUE) { + cv_process(g_cv, frame, g_cfg.line_offset); + xSemaphoreGive(s_cv_mutex); + } } vTaskDelay(pdMS_TO_TICKS(CAM_INTERVAL_MS)); } @@ -49,13 +53,18 @@ static void task_camera(void*) { // Hourly reporter task — runs on core 0 static void task_reporter(void*) { - uint32_t last_report_ts = (uint32_t)(time(nullptr)); + uint32_t last_report_ts = 0; // 0 = not initialized yet + while (true) { vTaskDelay(pdMS_TO_TICKS(10000)); // check every 10s uint32_t now = (uint32_t)(time(nullptr)); - // Skip if NTP not synced or hour not elapsed - if (now < 1700000000UL || (now - last_report_ts) < REPORT_INTERVAL_S) continue; + if (now < 1700000000UL) continue; // NTP not synced + + // First valid timestamp — initialize without reporting + if (last_report_ts == 0) { last_report_ts = now; continue; } + + if ((now - last_report_ts) < REPORT_INTERVAL_S) continue; uint32_t period_start = last_report_ts; uint32_t period_end = now; @@ -65,9 +74,17 @@ static void task_reporter(void*) { ble_scanner_pause(); led_set(true); // yellow indicator (single LED: on = uploading) - CameraHourlyRecord cam_rec = {period_start, period_end, - g_cv.entries, g_cv.exits}; - cv_reset_counts(g_cv); + CameraHourlyRecord cam_rec; + if (xSemaphoreTake(s_cv_mutex, pdMS_TO_TICKS(500)) == pdTRUE) { + cam_rec = {period_start, period_end, g_cv.entries, g_cv.exits}; + cv_reset_counts(g_cv); + xSemaphoreGive(s_cv_mutex); + } else { + // Failed to acquire — skip this cycle, will report next hour + ble_scanner_resume(); + led_set(false); + continue; + } BLEHourlyRecord ble_rec = ble_scanner_collect(period_start, period_end); @@ -124,6 +141,8 @@ void setup() { ble_scanner_start(); + s_cv_mutex = xSemaphoreCreateMutex(); + xTaskCreatePinnedToCore(task_camera, "cam", 4096, nullptr, 2, nullptr, 1); xTaskCreatePinnedToCore(task_reporter, "rep", 8192, nullptr, 1, nullptr, 0); } From a4328134448406c596739e5eb3b5268dc8546d56 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 14 Apr 2026 06:59:52 -0700 Subject: [PATCH 21/28] feat: camera_records table migration Co-Authored-By: Claude Sonnet 4.6 --- server/migrations/004_camera_records.sql | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 server/migrations/004_camera_records.sql diff --git a/server/migrations/004_camera_records.sql b/server/migrations/004_camera_records.sql new file mode 100644 index 0000000..6f3fe16 --- /dev/null +++ b/server/migrations/004_camera_records.sql @@ -0,0 +1,18 @@ +-- migrations/004_camera_records.sql +-- Add camera_records table for TimerCamera-F door counter events +-- Apply: sqlite3 < 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); From 910508194a845527f6eb31bb45b44df274bc5403 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 14 Apr 2026 07:01:40 -0700 Subject: [PATCH 22/28] feat: camera batch endpoint implementation and tests Self-contained server stub and pytest tests for the /api/v1/camera/events/batch endpoint, mirroring the BLE batch pattern with idempotent INSERT on (device_id, period_start). Co-Authored-By: Claude Sonnet 4.6 --- server/__init__.py | 0 server/camera_endpoint.py | 69 ++++++++++++++++++++++++++ server/test_camera_endpoint.py | 91 ++++++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 server/__init__.py create mode 100644 server/camera_endpoint.py create mode 100644 server/test_camera_endpoint.py diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/camera_endpoint.py b/server/camera_endpoint.py new file mode 100644 index 0000000..d5ebe62 --- /dev/null +++ b/server/camera_endpoint.py @@ -0,0 +1,69 @@ +# server/camera_endpoint.py +# Add these models and endpoint to the server's main.py alongside the existing BLE endpoints. +# Requires: camera_records table (see migrations/004_camera_records.sql) +# +# IMPORTANT: Before deploying, verify the HMAC message format in verify_device_hmac +# matches what the firmware computes: +# HMAC-SHA256(secret, f"{device_id}:{timestamp}:{sha256_hex(body)}") +# Headers expected: X-Device-Id, X-Timestamp, X-HMAC-Signature + +import sqlite3 +from typing import List + +from fastapi import Depends +from pydantic import BaseModel + + +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 + + +# Add this endpoint to your FastAPI app (alongside receive_batch_events): +# +# @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), +# ): +def receive_camera_events_impl( + batch: CameraEventsRequest, + device_id: str, + db: sqlite3.Connection, +) -> CameraEventsResponse: + """Receive 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) diff --git a/server/test_camera_endpoint.py b/server/test_camera_endpoint.py new file mode 100644 index 0000000..159fbc7 --- /dev/null +++ b/server/test_camera_endpoint.py @@ -0,0 +1,91 @@ +# server/test_camera_endpoint.py +# Template tests for the camera batch endpoint. +# Adapt imports and fixtures to match the actual server's test structure. +# +# To run against the actual server (once integrated): +# pytest server/test_camera_endpoint.py -v + +import json +import sqlite3 + +import pytest + +# These imports will need to match the actual server module structure: +# from main import app, get_db, verify_device_hmac +# from fastapi.testclient import TestClient + + +def make_camera_batch_body(location_id: str, period_start: int, + period_end: int, entries: int, exits: int) -> str: + return json.dumps({ + "location_id": location_id, + "records": [{ + "period_start": period_start, + "period_end": period_end, + "entries": entries, + "exits": exits, + }] + }) + + +def _make_db() -> sqlite3.Connection: + db = sqlite3.connect(":memory:") + db.execute(""" + 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) + ) + """) + db.commit() + return db + + +def test_insert_logic_idempotent(): + """Unit test for the insert logic — no FastAPI needed.""" + db = _make_db() + + from server.camera_endpoint import CameraRecord, CameraEventsRequest, receive_camera_events_impl + + batch = CameraEventsRequest( + location_id="test-loc", + records=[CameraRecord(period_start=1712000000, period_end=1712003600, + entries=5, exits=3)] + ) + + resp1 = receive_camera_events_impl(batch, "dc-test-01", db) + assert resp1.status == "ok" + assert resp1.accepted == 1 + + # Second identical call — idempotent + resp2 = receive_camera_events_impl(batch, "dc-test-01", db) + assert resp2.status == "ok" + assert resp2.accepted == 0 + + +def test_entries_exits_stored_correctly(): + """Verify entries and exits are stored as submitted.""" + db = _make_db() + + from server.camera_endpoint import CameraRecord, CameraEventsRequest, receive_camera_events_impl + + batch = CameraEventsRequest( + location_id="retailer-123", + records=[CameraRecord(period_start=1712007200, period_end=1712010800, + entries=42, exits=39)] + ) + receive_camera_events_impl(batch, "dc-0042", db) + + row = db.execute( + "SELECT entries, exits, location_id FROM camera_records WHERE device_id=?", + ("dc-0042",) + ).fetchone() + assert row[0] == 42 + assert row[1] == 39 + assert row[2] == "retailer-123" From a8f036f25fa2cc359ed1ee0be191de8aef4f6cbc Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 14 Apr 2026 07:02:51 -0700 Subject: [PATCH 23/28] =?UTF-8?q?fix:=20CameraRecord=20=E2=80=94=20reject?= =?UTF-8?q?=20negative=20entries/exits=20via=20Pydantic=20Field(ge=3D0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- server/camera_endpoint.py | 6 +++--- server/test_camera_endpoint.py | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/server/camera_endpoint.py b/server/camera_endpoint.py index d5ebe62..8bababb 100644 --- a/server/camera_endpoint.py +++ b/server/camera_endpoint.py @@ -11,14 +11,14 @@ import sqlite3 from typing import List from fastapi import Depends -from pydantic import BaseModel +from pydantic import BaseModel, Field class CameraRecord(BaseModel): period_start: int period_end: int - entries: int - exits: int + entries: int = Field(ge=0) + exits: int = Field(ge=0) class CameraEventsRequest(BaseModel): diff --git a/server/test_camera_endpoint.py b/server/test_camera_endpoint.py index 159fbc7..3917b75 100644 --- a/server/test_camera_endpoint.py +++ b/server/test_camera_endpoint.py @@ -89,3 +89,12 @@ def test_entries_exits_stored_correctly(): assert row[0] == 42 assert row[1] == 39 assert row[2] == "retailer-123" + + +def test_negative_counts_rejected(): + """Pydantic should reject negative entries/exits.""" + from pydantic import ValidationError + from server.camera_endpoint import CameraRecord + with pytest.raises(ValidationError): + CameraRecord(period_start=1712000000, period_end=1712003600, + entries=-1, exits=0) From b3c8d1c044ae44b042866a702e69033ab2e273d0 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 14 Apr 2026 07:40:03 -0700 Subject: [PATCH 24/28] feat: flash_device.py operator NVS provisioning script Co-Authored-By: Claude Sonnet 4.6 --- tools/flash_device.py | 104 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100755 tools/flash_device.py diff --git a/tools/flash_device.py b/tools/flash_device.py new file mode 100755 index 0000000..223c744 --- /dev/null +++ b/tools/flash_device.py @@ -0,0 +1,104 @@ +#!/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> # omit to auto-generate \\ + [--wifi-ssid "StoreWiFi"] \\ + [--wifi-password "secret"] \\ + [--line-offset 50] +""" +import argparse +import os +import secrets +import subprocess +import sys +import tempfile + + +NVS_NAMESPACE = "doorcounter" +NVS_PARTITION_OFFSET = "0x9000" +NVS_PARTITION_SIZE = "0x5000" # matches firmware partition table (20KB) + + +def build_nvs_csv(device_id, location_id, hmac_secret, + wifi_ssid=None, wifi_pass=None, line_offset=50): + rows = [ + "key,type,encoding,value", + f"{NVS_NAMESPACE},namespace,,", + f"device_id,data,string,{device_id}", + f"location_id,data,string,{location_id}", + f"hmac_secret,data,string,{hmac_secret}", + f"line_offset,data,u8,{line_offset}", + ] + if wifi_ssid is not None: + rows.append(f"wifi_ssid,data,string,{wifi_ssid}") + if wifi_pass is not None: + rows.append(f"wifi_pass,data,string,{wifi_pass}") + return "\n".join(rows) + "\n" + + +def main(): + parser = argparse.ArgumentParser( + description="Provision TimerCamera-F NVS config over serial") + parser.add_argument("--port", required=True, + help="Serial port, e.g. /dev/ttyUSB0 or COM3") + 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, e.g. retailer-123") + parser.add_argument("--hmac-secret", default=None, + help="32-byte hex HMAC secret (auto-generated if omitted)") + parser.add_argument("--wifi-ssid", default=None, + help="WiFi SSID (optional — user can set via captive portal)") + parser.add_argument("--wifi-password", default=None, + help="WiFi password (optional)") + parser.add_argument("--line-offset", type=int, default=50, + help="Virtual line position %% of frame height (default 50)") + args = parser.parse_args() + + hmac_secret = args.hmac_secret or secrets.token_hex(32) + if args.hmac_secret is None: + print(f"Generated HMAC secret: {hmac_secret}") + print(" *** SAVE THIS — you need it to register the device on the server ***") + + if args.line_offset < 0 or args.line_offset > 100: + print("Error: --line-offset must be 0-100", file=sys.stderr) + sys.exit(1) + + 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(f"nvs_partition_gen error:\n{ret.stderr}", file=sys.stderr) + sys.exit(1) + + # Flash NVS partition + ret = subprocess.run( + ["esptool.py", "--port", args.port, "--chip", "esp32", + "write_flash", NVS_PARTITION_OFFSET, bin_path] + ) + sys.exit(ret.returncode) + + +if __name__ == "__main__": + main() From e19ae22915275cd3dc15e0d345f76c4800d2764f Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 14 Apr 2026 09:27:16 -0700 Subject: [PATCH 25/28] =?UTF-8?q?feat:=20camera=20module=20=E2=80=94=20OV3?= =?UTF-8?q?660=20init=20and=2096x96=20grayscale=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add camera.h/camera.cpp for TimerCamera-F OV3660 init and box-filter downscale to 96x96 grayscale. Add espressif/esp32-camera to lib_deps. Co-Authored-By: Claude Sonnet 4.6 --- firmware/platformio.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/firmware/platformio.ini b/firmware/platformio.ini index fe3cbed..8ca4235 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -19,6 +19,7 @@ lib_deps = tzapu/WiFiManager@^2.0.17 bblanchon/ArduinoJson@^7.0.0 h2zero/NimBLE-Arduino@^1.4.2 + espressif/esp32-camera [env:native] platform = native From 36f4becbe9e798eb8f268440425e5e5e40938240 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 14 Apr 2026 09:30:20 -0700 Subject: [PATCH 26/28] =?UTF-8?q?fix:=20camera=20downscale=20=E2=80=94=20c?= =?UTF-8?q?entered=20crop,=20explicit=20PSRAM=20frame=20buffer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- firmware/src/camera.cpp | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/firmware/src/camera.cpp b/firmware/src/camera.cpp index 7a5d5ba..095e30b 100644 --- a/firmware/src/camera.cpp +++ b/firmware/src/camera.cpp @@ -25,7 +25,8 @@ bool camera_init() { camera_config_t cfg = {}; - cfg.ledc_channel = LEDC_CHANNEL_0; + cfg.fb_location = CAMERA_FB_IN_PSRAM; + cfg.ledc_channel = LEDC_CHANNEL_0; cfg.ledc_timer = LEDC_TIMER_0; cfg.pin_d0 = CAM_PIN_D0; cfg.pin_d1 = CAM_PIN_D1; @@ -62,21 +63,22 @@ bool camera_init() { return true; } -// Box-filter downscale from QVGA (320x240) to 96x96 grayscale +// Box-filter downscale to CV_W x CV_H. +// Input region: center-cropped to (CV_W*bx) x (CV_H*by) before downscaling. +// For QVGA (320x240) → 96x96: bx=3, by=2, crops to 288x192, offset x=16 y=24. 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; + int bx = src_w / CV_W; // 3 for QVGA + int by = src_h / CV_H; // 2 for QVGA + // Center the crop region + int x_off = (src_w - CV_W * bx) / 2; // 16 for QVGA + int y_off = (src_h - CV_H * by) / 2; // 24 for QVGA for (int dy = 0; dy < CV_H; dy++) { for (int dx = 0; dx < CV_W; dx++) { - int sum = 0, cnt = 0; + int sum = 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); + for (int kx = 0; kx < bx; kx++) + sum += src[(y_off + dy*by + ky)*src_w + (x_off + dx*bx + kx)]; + dst[dy*CV_W + dx] = (uint8_t)(sum / (bx * by)); } } } From 883b72be77e872653ed5ee615af76ce4d02e253f Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 14 Apr 2026 10:28:28 -0700 Subject: [PATCH 27/28] feat: ota_push.py operator firmware update script --- tools/ota_push.py | 103 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100755 tools/ota_push.py diff --git a/tools/ota_push.py b/tools/ota_push.py new file mode 100755 index 0000000..c80bf06 --- /dev/null +++ b/tools/ota_push.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +ota_push.py — Push firmware OTA to a TimerCamera-F device via Arduino OTA. + +Requires: Python 3.8+, no extra pip packages needed +Device must be connected to WiFi and reachable on the local network. + +Usage: + python ota_push.py \\ + --host dc-0042.local \\ + --firmware firmware/.pio/build/timercam/firmware.bin +""" +import argparse +import hashlib +import os +import socket +import sys +import time + + +OTA_PORT = 3232 + + +def compute_md5(path: str) -> str: + h = hashlib.md5() + with open(path, "rb") as f: + while chunk := f.read(8192): + h.update(chunk) + return h.hexdigest() + + +def resolve_host(host: str, timeout: float = 10.0) -> str: + """Resolve hostname to IP, retrying until timeout.""" + deadline = time.monotonic() + timeout + last_err = None + while time.monotonic() < deadline: + try: + return socket.gethostbyname(host) + except socket.gaierror as e: + last_err = e + time.sleep(0.5) + raise RuntimeError(f"Could not resolve {host!r} within {timeout:.0f}s: {last_err}") + + +def push_ota(host: str, firmware_path: str) -> None: + size = os.path.getsize(firmware_path) + md5 = compute_md5(firmware_path) + + print(f"Resolving {host} ...") + ip = resolve_host(host) + print(f" → {ip}") + + print(f"Connecting to {ip}:{OTA_PORT} ...") + with socket.create_connection((ip, OTA_PORT), timeout=15) as sock: + # Arduino OTA handshake: "0::\n" + header = f"0:{size}:{md5}\n".encode() + sock.sendall(header) + + sock.settimeout(10) + resp = sock.recv(64).decode(errors="replace").strip() + if resp != "OK": + raise RuntimeError(f"OTA handshake rejected: {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}% [{sent:,}/{size:,}]", end="", flush=True) + print() + + sock.settimeout(30) + final = sock.recv(64).decode(errors="replace").strip() + if final != "OK": + raise RuntimeError(f"OTA write failed: {final!r}") + + print(f"✓ OTA complete — {host} is rebooting") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Push OTA firmware update to a door counter device") + parser.add_argument("--host", required=True, + help="mDNS hostname or IP, 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}", file=sys.stderr) + sys.exit(1) + + try: + push_ota(args.host, args.firmware) + except (RuntimeError, OSError) as e: + print(f"\nError: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() From 8a00665e4c4c16b88ee16f6b8f4da0fca4d24cf6 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Tue, 14 Apr 2026 10:33:23 -0700 Subject: [PATCH 28/28] fix: ArduinoOTA init, reporter mutex, BLE lock scope, NVS type Co-Authored-By: Claude Sonnet 4.6 --- firmware/src/ble_scanner.cpp | 46 +++++++++++++++++-------------- firmware/src/main.cpp | 11 ++++++++ firmware/src/reporter.cpp | 53 ++++++++++++++++++++++++++++++------ firmware/src/reporter.h | 1 + tools/flash_device.py | 2 +- 5 files changed, 83 insertions(+), 30 deletions(-) diff --git a/firmware/src/ble_scanner.cpp b/firmware/src/ble_scanner.cpp index 6534980..b465ba1 100644 --- a/firmware/src/ble_scanner.cpp +++ b/firmware/src/ble_scanner.cpp @@ -5,11 +5,12 @@ #include #include "mbedtls/md.h" #include +#include #define RSSI_NEAR -65 #define RSSI_MID -80 -static portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED; +static std::mutex s_mutex; struct DeviceObs { int rssi_sum; @@ -47,7 +48,7 @@ class ScanCallback : public NimBLEAdvertisedDeviceCallbacks { String hash = sha256_prefix(mac); int rssi = dev->getRSSI(); - portENTER_CRITICAL(&s_mux); + std::lock_guard lock(s_mutex); auto it = s_seen.find(hash); if (it == s_seen.end()) { s_seen[hash] = {rssi, 1}; @@ -57,7 +58,6 @@ class ScanCallback : public NimBLEAdvertisedDeviceCallbacks { } int concurrent = (int)s_seen.size(); if (concurrent > s_max_concurrent) s_max_concurrent = concurrent; - portEXIT_CRITICAL(&s_mux); } }; @@ -79,26 +79,30 @@ 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) { - portENTER_CRITICAL(&s_mux); - - 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 = kv.second.rssi_sum / (float)kv.second.count; - if (avg > RSSI_NEAR) rec.near_count++; - else if (avg > RSSI_MID) rec.mid_count++; - else rec.far_count++; - rec.device_hashes.push_back(kv.first); + // Swap accumulators under lock — minimise time with lock held + std::map local_seen; + int local_max = 0; + { + std::lock_guard lock(s_mutex); + std::swap(local_seen, s_seen); + local_max = s_max_concurrent; + s_max_concurrent = 0; } - s_seen.clear(); - s_max_concurrent = 0; + // Process outside the lock — heap allocation safe here + BLEHourlyRecord rec; + rec.period_start = period_start; + rec.period_end = period_end; + rec.unique_devices = (int)local_seen.size(); + rec.max_concurrent = local_max; + rec.near_count = 0; rec.mid_count = 0; rec.far_count = 0; - portEXIT_CRITICAL(&s_mux); + for (auto& kv : local_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); + } return rec; } diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 8fdf7f6..d474d02 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -1,6 +1,7 @@ // firmware/src/main.cpp #include #include +#include #include "config.h" #include "provisioning.h" #include "camera.h" @@ -139,8 +140,17 @@ void setup() { while (true) delay(1000); } + reporter_init(); + ble_scanner_start(); + // OTA update support + ArduinoOTA.setHostname(g_cfg.device_id.c_str()); + ArduinoOTA.onStart([]() { ble_scanner_pause(); }); + ArduinoOTA.onEnd([]() { ble_scanner_resume(); ESP.restart(); }); + ArduinoOTA.onError([](ota_error_t e) { ble_scanner_resume(); }); + ArduinoOTA.begin(); + s_cv_mutex = xSemaphoreCreateMutex(); xTaskCreatePinnedToCore(task_camera, "cam", 4096, nullptr, 2, nullptr, 1); @@ -148,6 +158,7 @@ void setup() { } void loop() { + ArduinoOTA.handle(); check_factory_reset(); if (WiFi.status() != WL_CONNECTED) { diff --git a/firmware/src/reporter.cpp b/firmware/src/reporter.cpp index a78b23f..865d028 100644 --- a/firmware/src/reporter.cpp +++ b/firmware/src/reporter.cpp @@ -6,9 +6,15 @@ #include #include #include +#include static std::vector s_cam_buf; static std::vector s_ble_buf; +static SemaphoreHandle_t s_buf_mutex = nullptr; + +void reporter_init() { + s_buf_mutex = xSemaphoreCreateMutex(); +} // Returns current Unix timestamp (NTP-synced via configTime in main.cpp). static uint32_t now_ts() { @@ -86,12 +92,19 @@ static void buf_add_ble(const BLEHourlyRecord& r) { } void reporter_submit_camera(const DeviceConfig& cfg, const CameraHourlyRecord& rec) { - if (WiFi.status() != WL_CONNECTED) { buf_add_cam(rec); return; } + if (WiFi.status() != WL_CONNECTED) { + xSemaphoreTake(s_buf_mutex, portMAX_DELAY); + buf_add_cam(rec); + xSemaphoreGive(s_buf_mutex); + return; + } + xSemaphoreTake(s_buf_mutex, portMAX_DELAY); std::vector batch; batch.insert(batch.end(), s_cam_buf.begin(), s_cam_buf.end()); batch.push_back(rec); s_cam_buf.clear(); + xSemaphoreGive(s_buf_mutex); // Cap to MAX_BUFFER: drop oldest to make room for newest if ((int)batch.size() > REPORTER_MAX_BUFFER) { @@ -101,17 +114,26 @@ void reporter_submit_camera(const DeviceConfig& cfg, const CameraHourlyRecord& r String body = build_camera_batch(cfg, batch); if (!post_json(cfg, "/api/v1/camera/events/batch", body)) { + xSemaphoreTake(s_buf_mutex, portMAX_DELAY); s_cam_buf = batch; // re-buffer the whole capped batch + xSemaphoreGive(s_buf_mutex); } } void reporter_submit_ble(const DeviceConfig& cfg, const BLEHourlyRecord& rec) { - if (WiFi.status() != WL_CONNECTED) { buf_add_ble(rec); return; } + if (WiFi.status() != WL_CONNECTED) { + xSemaphoreTake(s_buf_mutex, portMAX_DELAY); + buf_add_ble(rec); + xSemaphoreGive(s_buf_mutex); + return; + } + xSemaphoreTake(s_buf_mutex, portMAX_DELAY); std::vector batch; batch.insert(batch.end(), s_ble_buf.begin(), s_ble_buf.end()); batch.push_back(rec); s_ble_buf.clear(); + xSemaphoreGive(s_buf_mutex); // Cap to MAX_BUFFER: drop oldest to make room for newest if ((int)batch.size() > REPORTER_MAX_BUFFER) { @@ -121,7 +143,9 @@ void reporter_submit_ble(const DeviceConfig& cfg, const BLEHourlyRecord& rec) { String body = build_ble_batch(cfg, batch); if (!post_json(cfg, "/api/v1/events/batch", body)) { + xSemaphoreTake(s_buf_mutex, portMAX_DELAY); s_ble_buf = batch; // re-buffer the whole capped batch + xSemaphoreGive(s_buf_mutex); } } @@ -137,12 +161,25 @@ void reporter_heartbeat(const DeviceConfig& cfg, uint32_t uptime_s, int wifi_rss } void reporter_flush(const DeviceConfig& cfg) { - if (!s_cam_buf.empty()) { - String body = build_camera_batch(cfg, s_cam_buf); - if (post_json(cfg, "/api/v1/camera/events/batch", body)) s_cam_buf.clear(); + xSemaphoreTake(s_buf_mutex, portMAX_DELAY); + std::vector cam_snap = s_cam_buf; + std::vector ble_snap = s_ble_buf; + xSemaphoreGive(s_buf_mutex); + + if (!cam_snap.empty()) { + String body = build_camera_batch(cfg, cam_snap); + if (post_json(cfg, "/api/v1/camera/events/batch", body)) { + xSemaphoreTake(s_buf_mutex, portMAX_DELAY); + s_cam_buf.clear(); + xSemaphoreGive(s_buf_mutex); + } } - 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(); + if (!ble_snap.empty()) { + String body = build_ble_batch(cfg, ble_snap); + if (post_json(cfg, "/api/v1/events/batch", body)) { + xSemaphoreTake(s_buf_mutex, portMAX_DELAY); + s_ble_buf.clear(); + xSemaphoreGive(s_buf_mutex); + } } } diff --git a/firmware/src/reporter.h b/firmware/src/reporter.h index 167ac3e..28c1b55 100644 --- a/firmware/src/reporter.h +++ b/firmware/src/reporter.h @@ -14,6 +14,7 @@ struct CameraHourlyRecord { static const int REPORTER_MAX_BUFFER = 24; static const char* REPORTER_API_HOST = "https://logs.research.bike"; +void reporter_init(); void reporter_submit_camera(const DeviceConfig& cfg, const CameraHourlyRecord& rec); void reporter_submit_ble(const DeviceConfig& cfg, const BLEHourlyRecord& rec); void reporter_heartbeat(const DeviceConfig& cfg, uint32_t uptime_s, int wifi_rssi); diff --git a/tools/flash_device.py b/tools/flash_device.py index 223c744..a32a268 100755 --- a/tools/flash_device.py +++ b/tools/flash_device.py @@ -34,7 +34,7 @@ def build_nvs_csv(device_id, location_id, hmac_secret, f"device_id,data,string,{device_id}", f"location_id,data,string,{location_id}", f"hmac_secret,data,string,{hmac_secret}", - f"line_offset,data,u8,{line_offset}", + f"line_offset,data,u32,{line_offset}", ] if wifi_ssid is not None: rows.append(f"wifi_ssid,data,string,{wifi_ssid}")