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).
This commit is contained in:
@@ -36,6 +36,16 @@ 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.
|
||||
|
||||
@@ -43,3 +43,7 @@ struct CVResult {
|
||||
void cv_init(CVState& state);
|
||||
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
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,27 @@ 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) {
|
||||
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); }
|
||||
|
||||
|
||||
@@ -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,55 @@ 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;
|
||||
if (obj["diff_thresh"].is<int>()) candidate.diff_thresh = (uint8_t)obj["diff_thresh"].as<int>();
|
||||
if (obj["min_blob_px"].is<int>()) candidate.min_blob_px = obj["min_blob_px"].as<int>();
|
||||
if (obj["max_move"].is<float>() || obj["max_move"].is<double>())
|
||||
candidate.max_move = obj["max_move"].as<float>();
|
||||
if (obj["max_missed"].is<int>()) candidate.max_missed = obj["max_missed"].as<int>();
|
||||
if (obj["line_offset"].is<int>()) candidate.line_offset = (uint8_t)obj["line_offset"].as<int>();
|
||||
|
||||
if (!cv_tuning_validate(candidate)) {
|
||||
Serial.printf("[CFG] rejected invalid config v=%u\n", (unsigned)new_ver);
|
||||
return;
|
||||
}
|
||||
|
||||
cv_apply_tuning(candidate);
|
||||
if (!config_save_tuning(candidate)) {
|
||||
Serial.printf("[CFG] applied v=%u but NVS save failed\n", (unsigned)new_ver);
|
||||
} else {
|
||||
Serial.printf("[CFG] applied v=%u\n", (unsigned)new_ver);
|
||||
}
|
||||
}
|
||||
|
||||
void reporter_flush(const DeviceConfig& cfg) {
|
||||
@@ -167,7 +227,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 +235,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);
|
||||
|
||||
@@ -183,6 +183,54 @@ void test_cv_process_respects_runtime_min_blob() {
|
||||
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);
|
||||
@@ -212,5 +260,8 @@ int main() {
|
||||
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