Compare commits
8 Commits
a37207b6ff
...
feature/se
| Author | SHA1 | Date | |
|---|---|---|---|
| cbbdd25ebb | |||
| e5eeea2b47 | |||
| bcb02b6d73 | |||
| b4b3a56019 | |||
| 21f3bc77d1 | |||
| 94d74e425c | |||
| a992bfe391 | |||
| e28a4c1863 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,4 @@
|
||||
.worktrees/
|
||||
.agent/
|
||||
firmware/.pio/
|
||||
graphify-out/
|
||||
|
||||
59
README.md
59
README.md
@@ -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` | 5–120 | Per-pixel motion threshold; higher = less sensitive. |
|
||||
| `min_blob_px` | 16–4096 | Minimum connected foreground pixels to count as a blob; higher = fewer false positives from small motion. |
|
||||
| `max_move` | 2.0–50.0 | Max inter-frame track displacement, in pixels on the 96×96 frame. |
|
||||
| `max_missed` | 1–60 | Frames a track can be missed before dropped. |
|
||||
| `line_offset` | 0–100 | 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` 5–120, `min_blob_px` 16–4096, `max_move` 2.0–50.0, `max_missed` 1–60, `line_offset` 0–100). 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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
13
firmware/src/cv_apply.h
Normal 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);
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: 5–120
|
||||
{ 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: 16–4096
|
||||
{ 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.0–50.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: 1–60
|
||||
{ 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: 0–100 (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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user