feat: door counter firmware — camera CV, BLE, HMAC reporting, captive portal

This commit is contained in:
2026-04-14 10:35:31 -07:00
27 changed files with 1651 additions and 0 deletions

168
firmware/lib/cv/cv.cpp Normal file
View File

@@ -0,0 +1,168 @@
// firmware/lib/cv/cv.cpp
#include "cv.h"
#include <string.h>
#include <math.h>
#include <algorithm>
#include <vector>
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<float,float> extract_blob(uint8_t* fg, int start_x, int start_y) {
std::vector<Point> 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<std::pair<float,float>> find_centroids(const uint8_t* fg) {
std::vector<std::pair<float,float>> 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<bool> 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;
}

40
firmware/lib/cv/cv.h Normal file
View File

@@ -0,0 +1,40 @@
// firmware/lib/cv/cv.h
#pragma once
#include <stdint.h>
#include <vector>
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<CVTrack> 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);

View File

@@ -0,0 +1,70 @@
// firmware/src/hmac.cpp
#include "hmac.h"
#include "mbedtls/md.h"
#include <stdio.h>
#include <string.h>
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);
}

16
firmware/lib/hmac/hmac.h Normal file
View File

@@ -0,0 +1,16 @@
// firmware/src/hmac.h
#pragma once
#include <stdint.h>
#ifdef NATIVE_TEST
#include <string>
using HString = std::string;
#else
#include <Arduino.h>
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);

View File

@@ -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
1 # Name Type SubType Offset Size
2 nvs data nvs 0x9000 0x5000
3 otadata data ota 0xe000 0x2000
4 app0 app ota_0 0x10000 0x300000
5 app1 app ota_1 0x310000 0x300000
6 spiffs data spiffs 0x610000 0x1F0000

31
firmware/platformio.ini Normal file
View File

@@ -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

0
firmware/src/.gitkeep Normal file
View File

View File

@@ -0,0 +1,108 @@
// firmware/src/ble_scanner.cpp
#include "ble_scanner.h"
#include <NimBLEDevice.h>
#include <NimBLEScan.h>
#include <NimBLEAdvertisedDevice.h>
#include "mbedtls/md.h"
#include <map>
#include <mutex>
#define RSSI_NEAR -65
#define RSSI_MID -80
static std::mutex s_mutex;
struct DeviceObs {
int rssi_sum;
int count;
};
static std::map<String, DeviceObs> 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<std::mutex> 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<String, DeviceObs> local_seen;
int local_max = 0;
{
std::lock_guard<std::mutex> 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;
}

View File

@@ -0,0 +1,25 @@
// firmware/src/ble_scanner.h
#pragma once
#include <Arduino.h>
#include <vector>
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<String> 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);

92
firmware/src/camera.cpp Normal file
View File

@@ -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 <string.h>
#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;
}

11
firmware/src/camera.h Normal file
View File

@@ -0,0 +1,11 @@
// firmware/src/camera.h
#pragma once
#include <stdint.h>
#include <stddef.h>
// 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);

48
firmware/src/config.cpp Normal file
View File

@@ -0,0 +1,48 @@
// firmware/src/config.cpp
#include "config.h"
#include <Preferences.h>
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();
}

24
firmware/src/config.h Normal file
View File

@@ -0,0 +1,24 @@
// firmware/src/config.h
#pragma once
#include <Arduino.h>
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();

174
firmware/src/main.cpp Normal file
View File

@@ -0,0 +1,174 @@
// firmware/src/main.cpp
#include <Arduino.h>
#include <WiFi.h>
#include <ArduinoOTA.h>
#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);
}

View File

@@ -0,0 +1,24 @@
// firmware/src/provisioning.cpp
#include "provisioning.h"
#include "config.h"
#include <WiFiManager.h>
bool provisioning_run(uint32_t timeout_ms) {
WiFiManager wm;
wm.setConfigPortalTimeout(timeout_ms / 1000);
wm.setTitle("DoorCounter Setup");
wm.setCustomHeadElement(
"<style>"
"body{font-family:sans-serif;max-width:400px;margin:40px auto;padding:0 16px}"
"h1{font-size:1.2em;color:#333}"
"p{color:#666;font-size:.9em}"
"</style>"
);
bool connected = wm.startConfigPortal("DoorCounter-Setup");
if (connected) {
config_save_wifi(wm.getWiFiSSID(), wm.getWiFiPass());
}
return connected;
}

View File

@@ -0,0 +1,7 @@
// firmware/src/provisioning.h
#pragma once
#include <stdint.h>
// 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);

185
firmware/src/reporter.cpp Normal file
View File

@@ -0,0 +1,185 @@
// firmware/src/reporter.cpp
#include "reporter.h"
#include "hmac.h"
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <WiFi.h>
#include <vector>
#include <time.h>
#include <freertos/semphr.h>
static std::vector<CameraHourlyRecord> s_cam_buf;
static std::vector<BLEHourlyRecord> 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<CameraHourlyRecord>& recs) {
JsonDocument doc;
doc["location_id"] = cfg.location_id;
JsonArray arr = doc["records"].to<JsonArray>();
for (const auto& r : recs) {
JsonObject o = arr.add<JsonObject>();
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<BLEHourlyRecord>& recs) {
JsonDocument doc;
doc["location_id"] = cfg.location_id;
JsonArray arr = doc["records"].to<JsonArray>();
for (const auto& r : recs) {
JsonObject o = arr.add<JsonObject>();
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<JsonArray>();
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<CameraHourlyRecord> 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<BLEHourlyRecord> 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<CameraHourlyRecord> cam_snap = s_cam_buf;
std::vector<BLEHourlyRecord> 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);
}
}
}

21
firmware/src/reporter.h Normal file
View File

@@ -0,0 +1,21 @@
// firmware/src/reporter.h
#pragma once
#include <Arduino.h>
#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);

View File

@@ -0,0 +1,166 @@
// firmware/test/test_cv/test_cv.cpp
#include <unity.h>
#include <string.h>
#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();
}

View File

View File

@@ -0,0 +1,41 @@
// firmware/test/test_native/test_hmac.cpp
#include <unity.h>
#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();
}

0
server/__init__.py Normal file
View File

69
server/camera_endpoint.py Normal file
View File

@@ -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)

View File

@@ -0,0 +1,18 @@
-- migrations/004_camera_records.sql
-- Add camera_records table for TimerCamera-F door counter events
-- Apply: sqlite3 <db_file> < 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);

View File

@@ -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)

104
tools/flash_device.py Executable file
View File

@@ -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()

103
tools/ota_push.py Executable file
View File

@@ -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:<size>:<md5>\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()