8 Commits

Author SHA1 Message Date
cbbdd25ebb docs(spec): document server-push runtime tuning in design spec
Reflects the feature shipped on this branch: backend can push per-device
CV tuning in the heartbeat response, device validates + persists to NVS.
Removes the stale line_offset row from the operator-provisioning table
(moved into CVTuning, server-managed).

Also adds .agent/, firmware/.pio/, and graphify-out/ to .gitignore so
local working dirs and build artifacts don't get accidentally tracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 22:06:59 -07:00
e5eeea2b47 fix(reporter,docs): save config before applying; correct README trust-model
Reorder reporter_heartbeat so NVS persistence commits before in-RAM apply.
If save fails, log and return without touching runtime state; RAM and NVS
stay consistent on the prior version instead of diverging until reboot.

Rewrite README "Trust model" to state reality: reporting is plain HTTP and
HMAC signs only requests, not responses. A LAN-local MITM can push any
config that passes the device range validator. Add roadmap entry for
authenticated config push (HTTPS or signed envelope).
2026-04-16 17:47:41 -07:00
bcb02b6d73 docs(readme): document server-push config + roadmap entry for gated local portal 2026-04-16 17:43:00 -07:00
b4b3a56019 fix(reporter): accept int JSON for max_move + reject malformed config fields
- max_move now accepts bare int JSON literals (server may emit 12 vs 12.0)
- Present-but-wrong-type fields reject the whole update with a log,
  preventing cfg_version from advancing on malformed payloads
- Zero-init CVTuning& out in cv_get_tuning before mutex take for safety
2026-04-16 17:41:22 -07:00
21f3bc77d1 feat(reporter): apply server-pushed CV tuning from heartbeat response
Heartbeat POST now captures the response body (up to 2048 bytes) and
looks for a "config" object. If cfg_version advances past the stored
value and all tunable fields pass range validation, the new tuning is
applied to g_cv and persisted to NVS.

- cv_tuning_validate: pure range checker (cv.cpp)
- cv_apply_tuning / cv_get_tuning: mutex-guarded helpers in main.cpp
  exposed via cv_apply.h; 500 ms timeout, drop on contention
- post_json now returns int (HTTP status) and optionally captures the
  response body; existing callers check == 200
- heartbeat: parse → cfg_version check → override present fields →
  validate → apply → save. Silent no-op when server returns no config.
- 3 new native tests (15/15 pass). timercam flash 1,423,897 bytes
  (+9,828 vs baseline).
2026-04-16 17:34:34 -07:00
94d74e425c refactor(cv): read thresholds from runtime tuning + load from NVS on boot
cv_process and helpers (frame_diff, extract_blob, find_centroids) now read
diff_thresh, min_blob_px, max_move, max_missed, and line_offset from
state.tuning instead of file-scope static const constants. The four
thresholds are promoted to file-local constexpr defaults in cv.cpp
(CV_DEFAULT_*) and are no longer part of the public cv.h API — external
code can't depend on them.

cv_process signature drops the line_pct parameter; callers use
state.tuning.line_offset instead. This eliminates the drift hazard of
having two sources of truth (DeviceConfig.line_offset vs
CVTuning.line_offset); the former is deleted.

main.cpp now calls config_load_tuning(g_cv.tuning) after cv_init on boot
so previously persisted tuning survives reboot; logs whether tuning came
from NVS or defaults.

The legacy NVS key "line_offset" is intentionally left alone — harmless
and flash_device.py may still write it during provisioning. Migration is
out of scope.

Tests: 12/12 passing (11 existing + 1 new
test_cv_process_respects_runtime_min_blob proving the runtime-read path).
Flash: 1,414,069 bytes (89.9%).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 15:55:35 -07:00
a992bfe391 fix(config): make cv_ver atomic commit marker in config_save_tuning 2026-04-16 15:49:41 -07:00
e28a4c1863 feat(cv): add CVTuning struct and NVS persistence scaffolding
Adds CVTuning to CVState, populates defaults from existing file-scope
constants in cv_init, and introduces config_load_tuning/config_save_tuning
backed by the doorcounter NVS namespace. No runtime behavior change yet;
CV code still reads the existing constants (Task 2 will migrate reads).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 15:45:53 -07:00
11 changed files with 443 additions and 51 deletions

3
.gitignore vendored
View File

@@ -1 +1,4 @@
.worktrees/
.agent/
firmware/.pio/
graphify-out/

View File

@@ -84,6 +84,65 @@ Endpoint: `http://logs.research.bike`
All requests are HMAC-SHA256 signed. See [design spec](docs/superpowers/specs/2026-04-13-door-counter-design.md) for full API shapes and auth scheme.
## Runtime Configuration
The backend can push CV tuning parameters to individual devices in the response to `POST /api/v1/heartbeat`. No HTTP server runs on the device — updates ride the existing outbound, HMAC-authenticated channel.
### Configurable fields
| Field | Type / Range | Meaning |
|-------|--------------|---------|
| `cfg_version` | uint32, non-zero | Monotonic version; device ignores updates with version ≤ stored. |
| `diff_thresh` | 5120 | Per-pixel motion threshold; higher = less sensitive. |
| `min_blob_px` | 164096 | Minimum connected foreground pixels to count as a blob; higher = fewer false positives from small motion. |
| `max_move` | 2.050.0 | Max inter-frame track displacement, in pixels on the 96×96 frame. |
| `max_missed` | 160 | Frames a track can be missed before dropped. |
| `line_offset` | 0100 | Virtual counting line, as percent of frame height. |
### Push flow
The heartbeat response MAY include a `config` object. All fields except `cfg_version` are optional; missing fields retain the device's current value.
```json
{
"config": {
"cfg_version": 7,
"diff_thresh": 25,
"min_blob_px": 200,
"max_move": 12.0,
"max_missed": 8,
"line_offset": 55
}
}
```
### Validation and apply rules
- Missing response body, non-200, malformed JSON, or missing `config` object → silent no-op.
- Missing `cfg_version` → rejected, logged `[CFG] missing cfg_version`.
- `cfg_version` ≤ stored → rejected as stale, logged.
- Any present field with wrong JSON type → whole update rejected, logged `[CFG] rejected malformed config`.
- Any field out of range → whole update rejected, logged `[CFG] rejected invalid config`.
- Valid update → applied atomically under mutex, persisted to NVS, logged `[CFG] applied v=N`.
### Persistence
Tuning is stored in the `doorcounter` NVS namespace and survives reboot. On boot, the device loads the persisted values; if none present, compiled defaults apply.
### Trust model
The reporting channel is plain HTTP today. The HMAC scheme signs only outbound **requests** (method + path + timestamp + sha256(body)) — it does not authenticate response bodies. A network attacker with access to the customer LAN can rewrite a heartbeat response and push any config that passes the device's range validator (`diff_thresh` 5120, `min_blob_px` 164096, `max_move` 2.050.0, `max_missed` 160, `line_offset` 0100). The validator is the last line of defense: malicious-but-in-range pushes can still degrade counting (e.g., `min_blob_px = 16` makes the detector noisy).
Per-device targeting (keyed by `device_id`) still works correctly and is unaffected by the integrity gap — each device only applies updates addressed to itself.
Operators should treat customer LANs as untrusted and rely on monitoring heartbeat cadence and count anomalies to detect tampering. No inbound HTTP surface is exposed on customer LANs by design — the device only makes outbound requests.
## Roadmap
**Gated local config portal.** Holding the BOOT button for ~3 seconds would raise a WiFiManager-style captive portal on the local network for ~5 minutes, exposing a tuning page for field techs operating without backend connectivity. Deferred because (a) the server-push mechanism above covers routine tuning, (b) an always-on HTTP server on customer LANs is an undesirable attack surface, and (c) the gated-by-physical-access model needs additional auth design to be safe.
**Authenticated config push.** Move reporting to HTTPS, or include a signed envelope on pushed config (e.g., `config_sig = HMAC(secret, cfg_version || canonical_json(config))` verified on device) so pushed tuning is tamper-evident over plain HTTP.
## Project Structure
```

View File

@@ -12,7 +12,7 @@
```
[TimerCamera-F Device]
├── Provisioning module — captive portal AP on first boot
├── Config store — NVS: device_id, location_id, HMAC secret, WiFi creds, line_offset
├── Config store — NVS: device_id, location_id, HMAC secret, WiFi creds, CV tuning (server-pushed)
├── Camera + CV module — captures frames, runs line-crossing counter
├── BLE scanner — continuous passive scan (WiFi coexistence mode)
├── Report buffer — accumulates counts in RAM, flushes hourly
@@ -65,7 +65,12 @@ Writes directly to NVS over serial. WiFi credentials are optional — if omitted
| `hmac_secret` | Operator | Yes |
| `wifi_ssid` | User/operator | Yes |
| `wifi_pass` | User/operator | Yes |
| `line_offset` | Default 50% | No |
CV tuning (`cv_diff`, `cv_blob`, `cv_move`, `cv_miss`, `cv_line`, `cv_ver`) lives in the same namespace but is set at runtime by the backend via heartbeat-response push — see §2.1. On first boot with no pushed config, compiled defaults apply.
### 2.1 Runtime tuning (server push)
The backend may include a `config` object in the `POST /api/v1/heartbeat` response to update per-device CV parameters. The device validates, persists to NVS, and applies atomically under mutex. Stale (`cfg_version ≤ stored`), malformed, or out-of-range updates are rejected. See `README.md` → "Runtime Configuration" for the full wire contract, field ranges, and trust-model caveat (plain HTTP; HMAC signs requests only).
### Factory reset

View File

@@ -5,6 +5,14 @@
#include <algorithm>
#include <vector>
// File-local defaults. Runtime values live in CVState::tuning and can be
// overridden via config_load_tuning() on boot or server push at runtime.
static constexpr uint8_t CV_DEFAULT_DIFF_THRESH = 30;
static constexpr int CV_DEFAULT_MIN_BLOB_PX = 64;
static constexpr float CV_DEFAULT_MAX_MOVE = 15.0f;
static constexpr int CV_DEFAULT_MAX_MISSED = 10;
static constexpr uint8_t CV_DEFAULT_LINE_OFFSET = 50;
void cv_init(CVState& state) {
// Initialize members directly — avoid CVState{} temporary which puts 9KB on stack
memset(state.background, 0, sizeof(state.background));
@@ -15,6 +23,12 @@ void cv_init(CVState& state) {
state.tracks.clear();
state.entries = 0;
state.exits = 0;
state.tuning.diff_thresh = CV_DEFAULT_DIFF_THRESH;
state.tuning.min_blob_px = CV_DEFAULT_MIN_BLOB_PX;
state.tuning.max_move = CV_DEFAULT_MAX_MOVE;
state.tuning.max_missed = CV_DEFAULT_MAX_MISSED;
state.tuning.line_offset = CV_DEFAULT_LINE_OFFSET;
state.tuning.cfg_version = 0;
}
void cv_reset_counts(CVState& state) {
@@ -22,12 +36,23 @@ void cv_reset_counts(CVState& state) {
state.exits = 0;
}
bool cv_tuning_validate(const CVTuning& t) {
if (t.cfg_version == 0) return false;
if (t.diff_thresh < 5 || t.diff_thresh > 120) return false;
if (t.min_blob_px < 16 || t.min_blob_px > 4096) return false;
if (t.max_move < 2.0f || t.max_move > 50.0f) return false;
if (t.max_missed < 1 || t.max_missed > 60) return false;
if (t.line_offset > 100) return false; // uint8, min 0
return true;
}
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) {
// BFS flood fill. Marks visited pixels (sets fg to 0). Returns {-1,-1} if blob < min_blob_px.
static std::pair<float,float> extract_blob(uint8_t* fg, int start_x, int start_y,
int min_blob_px) {
std::vector<Point> queue;
queue.reserve(512);
queue.push_back({start_x, start_y});
@@ -52,11 +77,12 @@ static std::pair<float,float> extract_blob(uint8_t* fg, int start_x, int start_y
}
}
if (count < CV_MIN_BLOB_PX) return {-1.0f, -1.0f};
if (count < 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) {
static std::vector<std::pair<float,float>> find_centroids(const uint8_t* fg,
int min_blob_px) {
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);
@@ -64,7 +90,7 @@ static std::vector<std::pair<float,float>> find_centroids(const uint8_t* fg) {
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);
auto c = extract_blob(fg_copy, x, y, min_blob_px);
if (c.first >= 0) result.push_back(c);
}
}
@@ -72,15 +98,15 @@ static std::vector<std::pair<float,float>> find_centroids(const uint8_t* fg) {
}
static void frame_diff(const uint8_t* frame, const uint8_t* bg,
uint8_t* fg, int pixels) {
uint8_t* fg, int pixels, uint8_t diff_thresh) {
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;
fg[i] = (diff > diff_thresh) ? 1 : 0;
}
}
CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct) {
CVResult cv_process(CVState& state, const uint8_t* frame) {
CVResult result = {0, 0};
state.frame_index++;
@@ -90,13 +116,18 @@ CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct) {
return result;
}
const uint8_t diff_thresh = state.tuning.diff_thresh;
const int min_blob_px = state.tuning.min_blob_px;
const float max_move = state.tuning.max_move;
const int max_missed = state.tuning.max_missed;
static uint8_t fg[CV_PIXELS]; // static: avoids 9KB on task stack
frame_diff(frame, state.background, fg, CV_PIXELS);
frame_diff(frame, state.background, fg, CV_PIXELS, diff_thresh);
int fg_count = 0;
for (int i = 0; i < CV_PIXELS; i++) fg_count += fg[i];
bool motion = fg_count > CV_MIN_BLOB_PX;
bool motion = fg_count > min_blob_px;
if (!motion) {
if (state.frame_index - state.last_motion_frame > 10) {
memcpy(state.background, frame, CV_PIXELS);
@@ -104,19 +135,19 @@ CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct) {
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; }),
[max_missed](const CVTrack& t){ return t.missed > max_missed; }),
state.tracks.end());
return result;
}
state.last_motion_frame = state.frame_index;
auto centroids = find_centroids(fg);
auto centroids = find_centroids(fg, min_blob_px);
std::vector<bool> centroid_matched(centroids.size(), false);
for (auto& track : state.tracks) {
float best_dist = CV_MAX_MOVE * CV_MAX_MOVE;
float best_dist = max_move * max_move;
int best_idx = -1;
for (int i = 0; i < (int)centroids.size(); i++) {
@@ -139,10 +170,10 @@ CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct) {
state.tracks.erase(
std::remove_if(state.tracks.begin(), state.tracks.end(),
[](const CVTrack& t){ return t.missed > CV_MAX_MISSED; }),
[max_missed](const CVTrack& t){ return t.missed > max_missed; }),
state.tracks.end());
float line_y = (line_pct / 100.0f) * CV_H;
float line_y = (state.tuning.line_offset / 100.0f) * CV_H;
for (int i = 0; i < (int)centroids.size(); i++) {
if (centroid_matched[i]) continue;
CVTrack t;

View File

@@ -7,11 +7,6 @@ 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;
@@ -19,6 +14,15 @@ struct CVTrack {
int missed;
};
struct CVTuning {
uint8_t diff_thresh; // per-pixel motion threshold
int min_blob_px; // min foreground pixels for a blob
float max_move; // max inter-frame track jump (px)
int max_missed; // frames before drop
uint8_t line_offset; // 0-100, percent of frame height for virtual line
uint32_t cfg_version; // monotonic; server increments on push
};
struct CVState {
uint8_t background[CV_PIXELS];
bool bg_valid;
@@ -28,6 +32,7 @@ struct CVState {
std::vector<CVTrack> tracks;
int entries;
int exits;
CVTuning tuning;
};
struct CVResult {
@@ -36,5 +41,9 @@ struct CVResult {
};
void cv_init(CVState& state);
CVResult cv_process(CVState& state, const uint8_t* frame, uint8_t line_pct);
CVResult cv_process(CVState& state, const uint8_t* frame);
void cv_reset_counts(CVState& state);
// Pure validator: returns true iff all tunable fields are in range and
// cfg_version is non-zero. No Arduino deps — safe for native tests.
bool cv_tuning_validate(const CVTuning& t);

View File

@@ -13,7 +13,6 @@ bool config_load(DeviceConfig& cfg) {
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();
@@ -46,3 +45,55 @@ void config_clear_wifi() {
prefs.remove("wifi_pass");
prefs.end();
}
bool config_load_tuning(CVTuning& tuning) {
Preferences prefs;
prefs.begin(NS, true); // read-only
uint32_t ver = prefs.getUInt("cv_ver", UINT32_MAX);
if (ver == UINT32_MAX) {
prefs.end();
return false;
}
// All six keys must be present; use sentinels to detect missing.
uint32_t diff = prefs.getUInt("cv_diff", UINT32_MAX);
uint32_t blob = prefs.getUInt("cv_blob", UINT32_MAX);
uint32_t miss = prefs.getUInt("cv_miss", UINT32_MAX);
uint32_t line = prefs.getUInt("cv_line", UINT32_MAX);
bool has_move = prefs.isKey("cv_move");
float move = prefs.getFloat("cv_move", 0.0f);
prefs.end();
if (diff == UINT32_MAX || blob == UINT32_MAX ||
miss == UINT32_MAX || line == UINT32_MAX || !has_move) {
return false;
}
tuning.diff_thresh = (uint8_t)diff;
tuning.min_blob_px = (int)blob;
tuning.max_move = move;
tuning.max_missed = (int)miss;
tuning.line_offset = (uint8_t)line;
tuning.cfg_version = ver;
return true;
}
bool config_save_tuning(const CVTuning& tuning) {
Preferences prefs;
prefs.begin(NS, false);
size_t r1 = prefs.putUInt("cv_diff", (uint32_t)tuning.diff_thresh);
size_t r2 = prefs.putUInt("cv_blob", (uint32_t)tuning.min_blob_px);
size_t r3 = prefs.putFloat("cv_move", tuning.max_move);
size_t r4 = prefs.putUInt("cv_miss", (uint32_t)tuning.max_missed);
size_t r5 = prefs.putUInt("cv_line", (uint32_t)tuning.line_offset);
if (!(r1 > 0 && r2 > 0 && r3 > 0 && r4 > 0 && r5 > 0)) {
prefs.end();
return false;
}
// cv_ver is the atomic commit marker: only written after all tunables succeed.
size_t r6 = prefs.putUInt("cv_ver", tuning.cfg_version);
prefs.end();
return r6 > 0;
}

View File

@@ -1,6 +1,7 @@
// firmware/src/config.h
#pragma once
#include <Arduino.h>
#include "cv.h"
struct DeviceConfig {
String device_id; // e.g. "dc-0042"
@@ -8,7 +9,6 @@ struct DeviceConfig {
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.
@@ -22,3 +22,10 @@ bool config_has_wifi();
// Erase WiFi credentials only (factory reset — preserves device_id etc).
void config_clear_wifi();
// Load CV tuning from NVS. Returns true only if all keys present (cfg_version sentinel).
// If any key missing, tuning is NOT modified (caller keeps its defaults).
bool config_load_tuning(CVTuning& tuning);
// Save CV tuning to NVS atomically. Returns true if all writes succeeded.
bool config_save_tuning(const CVTuning& tuning);

13
firmware/src/cv_apply.h Normal file
View File

@@ -0,0 +1,13 @@
// firmware/src/cv_apply.h
#pragma once
#include "cv.h"
// Take s_cv_mutex, copy the tuning into g_cv.tuning, release.
// Non-blocking semantics: if mutex is unavailable after 500 ms, logs and
// drops (caller may retry next cycle).
void cv_apply_tuning(const CVTuning& incoming);
// Take s_cv_mutex, copy g_cv.tuning into out. For reporter use when
// comparing candidate configs. If the mutex is unavailable, logs and
// leaves `out` unchanged.
void cv_get_tuning(CVTuning& out);

View File

@@ -6,6 +6,7 @@
#include "provisioning.h"
#include "camera.h"
#include "cv.h"
#include "cv_apply.h"
#include "ble_scanner.h"
#include "reporter.h"
@@ -24,6 +25,28 @@ static DeviceConfig g_cfg;
static CVState g_cv;
static SemaphoreHandle_t s_cv_mutex = nullptr;
// cv_apply.h definitions — live here because they need g_cv + s_cv_mutex.
void cv_apply_tuning(const CVTuning& incoming) {
if (!s_cv_mutex) return; // pre-init guard
if (xSemaphoreTake(s_cv_mutex, pdMS_TO_TICKS(500)) == pdTRUE) {
g_cv.tuning = incoming;
xSemaphoreGive(s_cv_mutex);
} else {
Serial.println("[CFG] apply skipped (mutex busy)");
}
}
void cv_get_tuning(CVTuning& out) {
out = CVTuning{};
if (!s_cv_mutex) return;
if (xSemaphoreTake(s_cv_mutex, pdMS_TO_TICKS(500)) == pdTRUE) {
out = g_cv.tuning;
xSemaphoreGive(s_cv_mutex);
} else {
Serial.println("[CFG] get_tuning skipped (mutex busy)");
}
}
// LED: simple on/off — blink patterns can be added later
static void led_set(bool on) { digitalWrite(LED_PIN, on ? HIGH : LOW); }
@@ -45,7 +68,7 @@ static void task_camera(void*) {
while (true) {
if (camera_capture_96(frame)) {
if (xSemaphoreTake(s_cv_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
CVResult r = cv_process(g_cv, frame, g_cfg.line_offset);
CVResult r = cv_process(g_cv, frame);
if (r.entries_delta) Serial.printf("[CV] entry +%d (total %d)\n", r.entries_delta, g_cv.entries);
if (r.exits_delta) Serial.printf("[CV] exit +%d (total %d)\n", r.exits_delta, g_cv.exits);
xSemaphoreGive(s_cv_mutex);
@@ -140,6 +163,11 @@ void setup() {
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
cv_init(g_cv);
if (config_load_tuning(g_cv.tuning)) {
Serial.printf("[CFG] tuning loaded from NVS, cfg_version=%u\n", g_cv.tuning.cfg_version);
} else {
Serial.println("[CFG] no persisted tuning, using defaults");
}
if (!camera_init()) {
Serial.println("FATAL: camera init failed");

View File

@@ -1,6 +1,8 @@
// firmware/src/reporter.cpp
#include "reporter.h"
#include "hmac.h"
#include "cv_apply.h"
#include "config.h"
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <WiFi.h>
@@ -21,12 +23,17 @@ static uint32_t now_ts() {
return (uint32_t)time(nullptr);
}
static bool post_json(const DeviceConfig& cfg, const char* path, const String& body) {
// Returns HTTP status code on success, negative HTTPClient error code on
// transport failure. Returns -1 if pre-flight checks (NTP, HMAC) fail.
// If response_body is non-null and the request succeeded (2xx), captures
// the response (truncated to 2048 chars).
static int post_json(const DeviceConfig& cfg, const char* path,
const String& body, String* response_body = nullptr) {
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
if (ts < 1700000000UL) return -1; // pre-2023 → clock not valid
String sig = hmac_sign(cfg.hmac_secret, "POST", path, ts, body);
if (sig.isEmpty()) return false; // HMAC failed
if (sig.isEmpty()) return -1; // HMAC failed
HTTPClient http;
String url = String(REPORTER_API_HOST) + path;
@@ -37,9 +44,14 @@ static bool post_json(const DeviceConfig& cfg, const char* path, const String& b
http.addHeader("X-Signature", sig);
int code = http.POST(body);
if (response_body && code >= 200 && code < 300) {
String r = http.getString();
if (r.length() > 2048) r.remove(2048);
*response_body = r;
}
http.end();
Serial.printf("[HTTP] POST %s → %d\n", url.c_str(), code);
return (code == 200);
return code;
}
static String build_camera_batch(const DeviceConfig& cfg,
@@ -111,7 +123,7 @@ void reporter_submit_camera(const DeviceConfig& cfg, const CameraHourlyRecord& r
}
String body = build_camera_batch(cfg, batch);
if (!post_json(cfg, "/api/v1/camera/events/batch", body)) {
if (post_json(cfg, "/api/v1/camera/events/batch", body) != 200) {
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
s_cam_buf = batch; // re-buffer the whole capped batch
xSemaphoreGive(s_buf_mutex);
@@ -140,7 +152,7 @@ void reporter_submit_ble(const DeviceConfig& cfg, const BLEHourlyRecord& rec) {
}
String body = build_ble_batch(cfg, batch);
if (!post_json(cfg, "/api/v1/events/batch", body)) {
if (post_json(cfg, "/api/v1/events/batch", body) != 200) {
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
s_ble_buf = batch; // re-buffer the whole capped batch
xSemaphoreGive(s_buf_mutex);
@@ -156,7 +168,80 @@ void reporter_heartbeat(const DeviceConfig& cfg, uint32_t uptime_s, int wifi_rss
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);
String resp;
int code = post_json(cfg, "/api/v1/heartbeat", body, &resp);
if (code != 200 || resp.isEmpty()) return;
JsonDocument rdoc;
DeserializationError err = deserializeJson(rdoc, resp);
if (err) {
Serial.println("[CFG] bad JSON in heartbeat response");
return;
}
JsonVariant cfgv = rdoc["config"];
if (!cfgv.is<JsonObject>()) return; // no config pushed → silent no-op
JsonObject obj = cfgv.as<JsonObject>();
if (!obj["cfg_version"].is<uint32_t>() && !obj["cfg_version"].is<int>()) {
Serial.println("[CFG] missing cfg_version, skip");
return;
}
uint32_t new_ver = obj["cfg_version"].as<uint32_t>();
CVTuning current;
cv_get_tuning(current);
if (new_ver <= current.cfg_version) {
Serial.printf("[CFG] stale version %u (have %u), skip\n",
(unsigned)new_ver, (unsigned)current.cfg_version);
return;
}
CVTuning candidate = current;
candidate.cfg_version = new_ver;
// Present-but-wrong-type fields reject the whole update. Absent fields
// (isNull == true) fall back to the current value. This prevents
// cfg_version bumping while silently dropping malformed fields.
bool bad_type = false;
if (!obj["diff_thresh"].isNull()) {
if (!obj["diff_thresh"].is<int>()) bad_type = true;
else candidate.diff_thresh = (uint8_t)obj["diff_thresh"].as<int>();
}
if (!obj["min_blob_px"].isNull()) {
if (!obj["min_blob_px"].is<int>()) bad_type = true;
else candidate.min_blob_px = obj["min_blob_px"].as<int>();
}
if (!obj["max_move"].isNull()) {
// max_move is float — accept int literal too (JSON 12 vs 12.0)
if (!(obj["max_move"].is<float>() || obj["max_move"].is<double>()
|| obj["max_move"].is<int>())) bad_type = true;
else candidate.max_move = obj["max_move"].as<float>();
}
if (!obj["max_missed"].isNull()) {
if (!obj["max_missed"].is<int>()) bad_type = true;
else candidate.max_missed = obj["max_missed"].as<int>();
}
if (!obj["line_offset"].isNull()) {
if (!obj["line_offset"].is<int>()) bad_type = true;
else candidate.line_offset = (uint8_t)obj["line_offset"].as<int>();
}
if (bad_type) {
Serial.printf("[CFG] rejected malformed config v=%u\n", (unsigned)new_ver);
return;
}
if (!cv_tuning_validate(candidate)) {
Serial.printf("[CFG] rejected invalid config v=%u\n", (unsigned)new_ver);
return;
}
if (!config_save_tuning(candidate)) {
Serial.printf("[CFG] rejected v=%u: NVS save failed\n", (unsigned)new_ver);
return;
}
cv_apply_tuning(candidate);
Serial.printf("[CFG] applied v=%u\n", (unsigned)new_ver);
}
void reporter_flush(const DeviceConfig& cfg) {
@@ -167,7 +252,7 @@ void reporter_flush(const DeviceConfig& cfg) {
if (!cam_snap.empty()) {
String body = build_camera_batch(cfg, cam_snap);
if (post_json(cfg, "/api/v1/camera/events/batch", body)) {
if (post_json(cfg, "/api/v1/camera/events/batch", body) == 200) {
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
s_cam_buf.clear();
xSemaphoreGive(s_buf_mutex);
@@ -175,7 +260,7 @@ void reporter_flush(const DeviceConfig& cfg) {
}
if (!ble_snap.empty()) {
String body = build_ble_batch(cfg, ble_snap);
if (post_json(cfg, "/api/v1/events/batch", body)) {
if (post_json(cfg, "/api/v1/events/batch", body) == 200) {
xSemaphoreTake(s_buf_mutex, portMAX_DELAY);
s_ble_buf.clear();
xSemaphoreGive(s_buf_mutex);

View File

@@ -17,10 +17,10 @@ void test_frame_diff_no_change_gives_no_fg() {
uint8_t frame[CV_PIXELS];
fill_frame(frame, 128);
CVResult r1 = cv_process(state, frame, 50);
CVResult r1 = cv_process(state, frame);
TEST_ASSERT_EQUAL_INT(0, r1.entries_delta);
CVResult r2 = cv_process(state, frame, 50);
CVResult r2 = cv_process(state, frame);
TEST_ASSERT_EQUAL_INT(0, r2.entries_delta);
TEST_ASSERT_EQUAL_INT(0, r2.exits_delta);
}
@@ -33,8 +33,8 @@ void test_frame_diff_large_change_detected_no_crash() {
fill_frame(bg, 100);
fill_frame(fg_frame, 200);
cv_process(state, bg, 50);
CVResult r = cv_process(state, fg_frame, 50);
cv_process(state, bg);
CVResult r = cv_process(state, fg_frame);
// Tracking not yet implemented — just verify no crash and result is zero
TEST_ASSERT_EQUAL_INT(0, r.entries_delta);
@@ -66,7 +66,7 @@ void test_tracking_spawns_track_for_new_blob() {
uint8_t bg[CV_PIXELS];
fill_frame(bg, 100);
cv_process(state, bg, 50); // init background
cv_process(state, bg); // init background
// Frame with a bright 30x30 blob in top-left quadrant
uint8_t blob_frame[CV_PIXELS];
@@ -75,7 +75,7 @@ void test_tracking_spawns_track_for_new_blob() {
for (int x = 5; x < 35; x++)
blob_frame[y * CV_W + x] = 200;
cv_process(state, blob_frame, 50);
cv_process(state, blob_frame);
TEST_ASSERT_EQUAL_INT(1, (int)state.tracks.size());
TEST_ASSERT_FLOAT_WITHIN(5.0f, 20.0f, state.tracks[0].x);
@@ -94,20 +94,20 @@ 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
// Line at 50% = y=48; step ≤14px per frame to stay within max_move (default 15)
uint8_t bg[CV_PIXELS];
fill_frame(bg, 100);
cv_process(state, bg, 50); // init background
cv_process(state, bg); // 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);
cv_process(state, f);
}
uint8_t fcross[CV_PIXELS]; make_blob_frame(fcross, 48, 48);
CVResult r = cv_process(state, fcross, 50);
CVResult r = cv_process(state, fcross);
TEST_ASSERT_EQUAL_INT(1, r.entries_delta);
TEST_ASSERT_EQUAL_INT(0, r.exits_delta);
@@ -119,34 +119,130 @@ void test_blob_crossing_line_bottom_to_top_is_exit() {
cv_init(state);
uint8_t bg[CV_PIXELS]; fill_frame(bg, 100);
cv_process(state, bg, 50);
cv_process(state, bg);
// 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);
cv_process(state, f);
}
uint8_t fcross[CV_PIXELS]; make_blob_frame(fcross, 48, 34);
CVResult r = cv_process(state, fcross, 50);
CVResult r = cv_process(state, fcross);
TEST_ASSERT_EQUAL_INT(0, r.entries_delta);
TEST_ASSERT_EQUAL_INT(1, r.exits_delta);
}
void test_cv_init_populates_tuning_defaults() {
CVState state;
// Pre-pollute to make sure cv_init overwrites
state.tuning.diff_thresh = 0;
state.tuning.min_blob_px = 0;
state.tuning.max_move = 0.0f;
state.tuning.max_missed = 0;
state.tuning.line_offset = 0;
state.tuning.cfg_version = 0xDEADBEEF;
cv_init(state);
// Values mirror CV_DEFAULT_* constants in cv.cpp (now file-local).
TEST_ASSERT_EQUAL_UINT8(30, state.tuning.diff_thresh);
TEST_ASSERT_EQUAL_INT(64, state.tuning.min_blob_px);
TEST_ASSERT_EQUAL_FLOAT(15.0f, state.tuning.max_move);
TEST_ASSERT_EQUAL_INT(10, state.tuning.max_missed);
TEST_ASSERT_EQUAL_UINT8(50, state.tuning.line_offset);
TEST_ASSERT_EQUAL_UINT32(0, state.tuning.cfg_version);
}
void test_cv_process_respects_runtime_min_blob() {
// Proves cv_process reads min_blob_px from state.tuning at runtime
// (not from a compile-time constant). With a very high threshold, the
// same blob-producing frame that spawns a track in other tests must NOT
// spawn one here.
CVState state;
cv_init(state);
state.tuning.min_blob_px = 10000; // larger than CV_PIXELS → no blob can qualify
uint8_t bg[CV_PIXELS];
fill_frame(bg, 100);
cv_process(state, bg); // init background
// Same 30x30 blob used by test_tracking_spawns_track_for_new_blob
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;
CVResult r = cv_process(state, blob_frame);
TEST_ASSERT_EQUAL_INT(0, (int)state.tracks.size());
TEST_ASSERT_EQUAL_INT(0, r.entries_delta);
TEST_ASSERT_EQUAL_INT(0, r.exits_delta);
}
// Helper: init tuning to defaults (via cv_init) with cfg_version = 1
static CVTuning make_default_tuning() {
CVState s;
cv_init(s);
s.tuning.cfg_version = 1;
return s.tuning;
}
void test_cv_tuning_validate_accepts_defaults() {
CVTuning t = make_default_tuning();
TEST_ASSERT_TRUE(cv_tuning_validate(t));
}
void test_cv_tuning_validate_rejects_zero_version() {
CVTuning t = make_default_tuning();
t.cfg_version = 0;
TEST_ASSERT_FALSE(cv_tuning_validate(t));
}
void test_cv_tuning_validate_rejects_each_boundary() {
// diff_thresh: 5120
{ CVTuning t = make_default_tuning(); t.diff_thresh = 4; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
{ CVTuning t = make_default_tuning(); t.diff_thresh = 121; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
// min_blob_px: 164096
{ CVTuning t = make_default_tuning(); t.min_blob_px = 15; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
{ CVTuning t = make_default_tuning(); t.min_blob_px = 4097; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
// max_move: 2.050.0
{ CVTuning t = make_default_tuning(); t.max_move = 1.9f; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
{ CVTuning t = make_default_tuning(); t.max_move = 50.1f; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
// max_missed: 160
{ CVTuning t = make_default_tuning(); t.max_missed = 0; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
{ CVTuning t = make_default_tuning(); t.max_missed = 61; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
// line_offset: 0100 (uint8 so only upper bound is meaningful)
{ CVTuning t = make_default_tuning(); t.line_offset = 101; TEST_ASSERT_FALSE(cv_tuning_validate(t)); }
// Sanity: inclusive mins/maxes still pass
{ CVTuning t = make_default_tuning(); t.diff_thresh = 5; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
{ CVTuning t = make_default_tuning(); t.diff_thresh = 120; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
{ CVTuning t = make_default_tuning(); t.min_blob_px = 16; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
{ CVTuning t = make_default_tuning(); t.min_blob_px = 4096; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
{ CVTuning t = make_default_tuning(); t.max_move = 2.0f; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
{ CVTuning t = make_default_tuning(); t.max_move = 50.0f;TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
{ CVTuning t = make_default_tuning(); t.max_missed = 1; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
{ CVTuning t = make_default_tuning(); t.max_missed = 60; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
{ CVTuning t = make_default_tuning(); t.line_offset = 0; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
{ CVTuning t = make_default_tuning(); t.line_offset = 100; TEST_ASSERT_TRUE(cv_tuning_validate(t)); }
}
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);
cv_process(state, bg);
uint8_t f1[CV_PIXELS]; make_blob_frame(f1, 48, 20); // above line
cv_process(state, f1, 50);
cv_process(state, f1);
uint8_t f2[CV_PIXELS]; make_blob_frame(f2, 48, 30); // still above line, moved closer
CVResult r = cv_process(state, f2, 50);
CVResult r = cv_process(state, f2);
TEST_ASSERT_EQUAL_INT(0, r.entries_delta);
TEST_ASSERT_EQUAL_INT(0, r.exits_delta);
@@ -162,5 +258,10 @@ int main() {
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);
RUN_TEST(test_cv_init_populates_tuning_defaults);
RUN_TEST(test_cv_process_respects_runtime_min_blob);
RUN_TEST(test_cv_tuning_validate_accepts_defaults);
RUN_TEST(test_cv_tuning_validate_rejects_zero_version);
RUN_TEST(test_cv_tuning_validate_rejects_each_boundary);
return UNITY_END();
}