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>
This commit is contained in:
2026-04-16 15:45:53 -07:00
parent 9d5b588231
commit e28a4c1863
5 changed files with 92 additions and 0 deletions

View File

@@ -15,6 +15,12 @@ void cv_init(CVState& state) {
state.tracks.clear(); state.tracks.clear();
state.entries = 0; state.entries = 0;
state.exits = 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) { void cv_reset_counts(CVState& state) {

View File

@@ -19,6 +19,15 @@ struct CVTrack {
int missed; 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 { struct CVState {
uint8_t background[CV_PIXELS]; uint8_t background[CV_PIXELS];
bool bg_valid; bool bg_valid;
@@ -28,6 +37,7 @@ struct CVState {
std::vector<CVTrack> tracks; std::vector<CVTrack> tracks;
int entries; int entries;
int exits; int exits;
CVTuning tuning;
}; };
struct CVResult { struct CVResult {

View File

@@ -46,3 +46,50 @@ void config_clear_wifi() {
prefs.remove("wifi_pass"); prefs.remove("wifi_pass");
prefs.end(); 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);
}

View File

@@ -1,6 +1,7 @@
// firmware/src/config.h // firmware/src/config.h
#pragma once #pragma once
#include <Arduino.h> #include <Arduino.h>
#include "cv.h"
struct DeviceConfig { struct DeviceConfig {
String device_id; // e.g. "dc-0042" 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). // Erase WiFi credentials only (factory reset — preserves device_id etc).
void config_clear_wifi(); 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);

View File

@@ -135,6 +135,26 @@ void test_blob_crossing_line_bottom_to_top_is_exit() {
TEST_ASSERT_EQUAL_INT(1, r.exits_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);
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() { void test_no_crossing_same_side_no_count() {
CVState state; CVState state;
cv_init(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_top_to_bottom_is_entry);
RUN_TEST(test_blob_crossing_line_bottom_to_top_is_exit); RUN_TEST(test_blob_crossing_line_bottom_to_top_is_exit);
RUN_TEST(test_no_crossing_same_side_no_count); RUN_TEST(test_no_crossing_same_side_no_count);
RUN_TEST(test_cv_init_populates_tuning_defaults);
return UNITY_END(); return UNITY_END();
} }