diff --git a/firmware/lib/cv/cv.cpp b/firmware/lib/cv/cv.cpp new file mode 100644 index 0000000..7f0be59 --- /dev/null +++ b/firmware/lib/cv/cv.cpp @@ -0,0 +1,168 @@ +// firmware/lib/cv/cv.cpp +#include "cv.h" +#include +#include +#include +#include + +void cv_init(CVState& state) { + state = CVState{}; // value-initialize — calls vector default ctor correctly + state.next_id = 1; +} + +void cv_reset_counts(CVState& state) { + state.entries = 0; + state.exits = 0; +} + +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; + 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; + 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++) { + 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++) { + 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; + + 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 + 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/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/lib/hmac/hmac.cpp b/firmware/lib/hmac/hmac.cpp new file mode 100644 index 0000000..6e174b2 --- /dev/null +++ b/firmware/lib/hmac/hmac.cpp @@ -0,0 +1,70 @@ +// 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) { + 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); + } +} + +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); + 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] = {}; + if (!sha256((const uint8_t*)body.c_str(), body.length(), body_hash)) { + return HString{}; + } + 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.length() / 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); + 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.length()); + 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/partitions_8mb_ota.csv b/firmware/partitions_8mb_ota.csv new file mode 100644 index 0000000..fd51f8b --- /dev/null +++ b/firmware/partitions_8mb_ota.csv @@ -0,0 +1,6 @@ +# Name, Type, SubType, Offset, Size +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/platformio.ini b/firmware/platformio.ini new file mode 100644 index 0000000..8ca4235 --- /dev/null +++ b/firmware/platformio.ini @@ -0,0 +1,31 @@ +; firmware/platformio.ini +[platformio] +default_envs = timercam + +[env:timercam] +platform = espressif32@6.6.0 +board = m5stack-timer-cam +framework = arduino +board_build.partitions = partitions_8mb_ota.csv +build_flags = + -DBOARD_HAS_PSRAM + -mfix-esp32-psram-cache-issue + -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 + espressif/esp32-camera + +[env:native] +platform = native +test_framework = unity +build_flags = + -std=c++17 + -DNATIVE_TEST +lib_deps = + kochcodes/mbedtls@^3.6.2 diff --git a/firmware/src/.gitkeep b/firmware/src/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/firmware/src/ble_scanner.cpp b/firmware/src/ble_scanner.cpp new file mode 100644 index 0000000..b465ba1 --- /dev/null +++ b/firmware/src/ble_scanner.cpp @@ -0,0 +1,108 @@ +// firmware/src/ble_scanner.cpp +#include "ble_scanner.h" +#include +#include +#include +#include "mbedtls/md.h" +#include +#include + +#define RSSI_NEAR -65 +#define RSSI_MID -80 + +static std::mutex s_mutex; + +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) { + 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); + 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; } + 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(); + + std::lock_guard lock(s_mutex); + 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; + } +}; + +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) { + // 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; + } + + // 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; + + 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/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); diff --git a/firmware/src/camera.cpp b/firmware/src/camera.cpp new file mode 100644 index 0000000..095e30b --- /dev/null +++ b/firmware/src/camera.cpp @@ -0,0 +1,92 @@ +// 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.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; + 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(); + if (s) { + s->set_vflip(s, 1); + s->set_hmirror(s, 0); + } + + return true; +} + +// 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; // 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; + for (int ky = 0; ky < by; ky++) + 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)); + } + } +} + +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); diff --git a/firmware/src/config.cpp b/firmware/src/config.cpp new file mode 100644 index 0000000..8e349d4 --- /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); + size_t r1 = prefs.putString("wifi_ssid", ssid); + size_t r2 = prefs.putString("wifi_pass", pass); + prefs.end(); + return (r1 > 0) && (r2 > 0); +} + +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..d474d02 --- /dev/null +++ b/firmware/src/main.cpp @@ -0,0 +1,174 @@ +// firmware/src/main.cpp +#include +#include +#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; +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); } + +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*) { + static uint8_t frame[CV_PIXELS]; // static: avoids 9KB on task stack + while (true) { + if (camera_capture_96(frame)) { + 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)); + } +} + +// Hourly reporter task — runs on core 0 +static void task_reporter(void*) { + 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)); + 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; + last_report_ts = now; + + // Pause BLE during upload + ble_scanner_pause(); + led_set(true); // yellow indicator (single LED: on = uploading) + + 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); + + 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); + } + + 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); + xTaskCreatePinnedToCore(task_reporter, "rep", 8192, nullptr, 1, nullptr, 0); +} + +void loop() { + ArduinoOTA.handle(); + 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); +} 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); diff --git a/firmware/src/reporter.cpp b/firmware/src/reporter.cpp new file mode 100644 index 0000000..865d028 --- /dev/null +++ b/firmware/src/reporter.cpp @@ -0,0 +1,185 @@ +// firmware/src/reporter.cpp +#include "reporter.h" +#include "hmac.h" +#include +#include +#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() { + return (uint32_t)time(nullptr); +} + +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); + 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) { + 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) { + 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)) { + 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) { + 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) { + 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)) { + xSemaphoreTake(s_buf_mutex, portMAX_DELAY); + s_ble_buf = batch; // re-buffer the whole capped batch + xSemaphoreGive(s_buf_mutex); + } +} + +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) { + 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 (!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 new file mode 100644 index 0000000..28c1b55 --- /dev/null +++ b/firmware/src/reporter.h @@ -0,0 +1,21 @@ +// 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_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); +void reporter_flush(const DeviceConfig& cfg); diff --git a/firmware/test/test_cv/test_cv.cpp b/firmware/test/test_cv/test_cv.cpp new file mode 100644 index 0000000..72b9cba --- /dev/null +++ b/firmware/test/test_cv/test_cv.cpp @@ -0,0 +1,166 @@ +// firmware/test/test_cv/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); +} + +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); +} + +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); + 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); + 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(); +} diff --git a/firmware/test/test_native/.gitkeep b/firmware/test/test_native/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/firmware/test/test_native/test_hmac.cpp b/firmware/test/test_native/test_hmac.cpp new file mode 100644 index 0000000..5bd6779 --- /dev/null +++ b/firmware/test/test_native/test_hmac.cpp @@ -0,0 +1,41 @@ +// firmware/test/test_native/test_hmac.cpp +#include +#include "hmac.h" + +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"; + 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(); +} 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..8bababb --- /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, Field + + +class CameraRecord(BaseModel): + period_start: int + period_end: int + entries: int = Field(ge=0) + exits: int = Field(ge=0) + + +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/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); diff --git a/server/test_camera_endpoint.py b/server/test_camera_endpoint.py new file mode 100644 index 0000000..3917b75 --- /dev/null +++ b/server/test_camera_endpoint.py @@ -0,0 +1,100 @@ +# 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" + + +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) diff --git a/tools/flash_device.py b/tools/flash_device.py new file mode 100755 index 0000000..a32a268 --- /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,u32,{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() 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()