diff --git a/firmware/lib/cv/cv.cpp b/firmware/lib/cv/cv.cpp index d76629a..695107d 100644 --- a/firmware/lib/cv/cv.cpp +++ b/firmware/lib/cv/cv.cpp @@ -15,6 +15,12 @@ void cv_init(CVState& state) { state.tracks.clear(); state.entries = 0; state.exits = 0; + state.tuning.diff_thresh = CV_DIFF_THRESH; + state.tuning.min_blob_px = CV_MIN_BLOB_PX; + state.tuning.max_move = CV_MAX_MOVE; + state.tuning.max_missed = CV_MAX_MISSED; + state.tuning.line_offset = 50; + state.tuning.cfg_version = 0; } void cv_reset_counts(CVState& state) { diff --git a/firmware/lib/cv/cv.h b/firmware/lib/cv/cv.h index 30de5b1..fca5d62 100644 --- a/firmware/lib/cv/cv.h +++ b/firmware/lib/cv/cv.h @@ -19,6 +19,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 +37,7 @@ struct CVState { std::vector tracks; int entries; int exits; + CVTuning tuning; }; struct CVResult { diff --git a/firmware/src/config.cpp b/firmware/src/config.cpp index 8e349d4..02ef1c3 100644 --- a/firmware/src/config.cpp +++ b/firmware/src/config.cpp @@ -46,3 +46,50 @@ 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); + size_t r6 = prefs.putUInt("cv_ver", tuning.cfg_version); + prefs.end(); + return (r1 > 0) && (r2 > 0) && (r3 > 0) && (r4 > 0) && (r5 > 0) && (r6 > 0); +} diff --git a/firmware/src/config.h b/firmware/src/config.h index 3e07891..e10f8d3 100644 --- a/firmware/src/config.h +++ b/firmware/src/config.h @@ -1,6 +1,7 @@ // firmware/src/config.h #pragma once #include +#include "cv.h" struct DeviceConfig { String device_id; // e.g. "dc-0042" @@ -22,3 +23,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); diff --git a/firmware/test/test_cv/test_cv.cpp b/firmware/test/test_cv/test_cv.cpp index 72b9cba..95895f2 100644 --- a/firmware/test/test_cv/test_cv.cpp +++ b/firmware/test/test_cv/test_cv.cpp @@ -135,6 +135,26 @@ void test_blob_crossing_line_bottom_to_top_is_exit() { 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); + + TEST_ASSERT_EQUAL_UINT8(CV_DIFF_THRESH, state.tuning.diff_thresh); + TEST_ASSERT_EQUAL_INT(CV_MIN_BLOB_PX, state.tuning.min_blob_px); + TEST_ASSERT_EQUAL_FLOAT(CV_MAX_MOVE, state.tuning.max_move); + TEST_ASSERT_EQUAL_INT(CV_MAX_MISSED, state.tuning.max_missed); + TEST_ASSERT_EQUAL_UINT8(50, state.tuning.line_offset); + TEST_ASSERT_EQUAL_UINT32(0, state.tuning.cfg_version); +} + void test_no_crossing_same_side_no_count() { CVState state; cv_init(state); @@ -162,5 +182,6 @@ 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); return UNITY_END(); }