diff --git a/firmware/lib/cv/cv.cpp b/firmware/lib/cv/cv.cpp index 44e39bc..e3f0b50 100644 --- a/firmware/lib/cv/cv.cpp +++ b/firmware/lib/cv/cv.cpp @@ -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. diff --git a/firmware/lib/cv/cv.h b/firmware/lib/cv/cv.h index 8f308e0..ca2e1a7 100644 --- a/firmware/lib/cv/cv.h +++ b/firmware/lib/cv/cv.h @@ -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); diff --git a/firmware/src/cv_apply.h b/firmware/src/cv_apply.h new file mode 100644 index 0000000..9048919 --- /dev/null +++ b/firmware/src/cv_apply.h @@ -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); diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index f9fc08a..7dfe4c9 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -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); } diff --git a/firmware/src/reporter.cpp b/firmware/src/reporter.cpp index a896e71..3d7934d 100644 --- a/firmware/src/reporter.cpp +++ b/firmware/src/reporter.cpp @@ -1,6 +1,8 @@ // firmware/src/reporter.cpp #include "reporter.h" #include "hmac.h" +#include "cv_apply.h" +#include "config.h" #include #include #include @@ -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()) return; // no config pushed → silent no-op + JsonObject obj = cfgv.as(); + + if (!obj["cfg_version"].is() && !obj["cfg_version"].is()) { + Serial.println("[CFG] missing cfg_version, skip"); + return; + } + uint32_t new_ver = obj["cfg_version"].as(); + + 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()) candidate.diff_thresh = (uint8_t)obj["diff_thresh"].as(); + if (obj["min_blob_px"].is()) candidate.min_blob_px = obj["min_blob_px"].as(); + if (obj["max_move"].is() || obj["max_move"].is()) + candidate.max_move = obj["max_move"].as(); + if (obj["max_missed"].is()) candidate.max_missed = obj["max_missed"].as(); + if (obj["line_offset"].is()) candidate.line_offset = (uint8_t)obj["line_offset"].as(); + + 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); diff --git a/firmware/test/test_cv/test_cv.cpp b/firmware/test/test_cv/test_cv.cpp index 13d0bcf..92b2ecb 100644 --- a/firmware/test/test_cv/test_cv.cpp +++ b/firmware/test/test_cv/test_cv.cpp @@ -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(); }