From a0dc4e01158822194dc4bc131742a7eb5546506d Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Fri, 8 May 2026 16:03:22 -0400 Subject: [PATCH] feat(operation): X-Just-Provisioned + X-Claimed handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the sell-to-friend gap where a buyer's freshly-reset device would briefly display the seller's photos before the buyer reached /setup/{mac} to claim. The firmware had no way to tell the server "I just got reset" — now it does. Flow: - WiFi-setup completion (handle_connect in main.cpp) writes NVS_KEY_JUST_PROVISIONED=1 alongside the SSID/PASS save. - Every poll while the flag is set sends X-Just-Provisioned: 1. - Server (DeviceImageController, paired commit on the webApp side) responds with 204 + X-Interval-Ms when the binding is stale, forcing the device to its setup-QR fallback. Once the user re-claims via /setup/{mac}, the binding is fresh, and the server answers with X-Claimed: 1 alongside whatever response code applies. - Firmware clears the NVS flag on seeing X-Claimed: 1 — once cleared, the device is back to normal long-stable polling. Tests: - PROV-A: flag set in NVS → header on the request - PROV-B: no flag → no header (steady state) - PROV-C: response with X-Claimed: 1 → flag cleared - PROV-D: response without X-Claimed → flag stays (so the next poll keeps signaling "not yet acknowledged") Co-Authored-By: Claude Opus 4.7 (1M context) --- src/config.h | 7 ++++ src/main.cpp | 7 +++- src/operation.h | 27 ++++++++++++- test/mocks/config.h | 1 + test/test_normal_operation/test_main.cpp | 48 ++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/config.h b/src/config.h index 7562a99..9190eef 100644 --- a/src/config.h +++ b/src/config.h @@ -110,6 +110,13 @@ #define NVS_KEY_DRAW_NEEDED "draw" #define NVS_KEY_ERR_BORDER "err" // set when display is showing a sync-fail border; force a clean redraw on next 200/304 #define NVS_KEY_SCHEMA_V "schema_v" +// Set on every fresh provisioning (WiFi-setup completion). Stays in NVS across +// reboots until the server explicitly acknowledges the device is claimed by +// returning X-Claimed: 1 — at which point the firmware clears the flag and +// resumes regular operation. Without this, a device that gets sold and reset +// would silently keep displaying the prior owner's photos until the new +// owner happens to navigate to /setup/{mac}. +#define NVS_KEY_JUST_PROVISIONED "just_prov" // Bump when introducing a schema migration. Each new value can force a one-shot // recovery action on first boot of the new firmware. diff --git a/src/main.cpp b/src/main.cpp index f5b9fc4..d5ec7cf 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -243,10 +243,15 @@ void loop() { bool ok = attempt_wifi(g_req_ssid, g_req_pass); if (ok) { - // Save credentials for future boots + // Save credentials for future boots, plus the just-provisioned flag. + // The server uses that flag to decide whether to serve a (possibly + // stale) prior-owner image or hold off until the user re-claims via + // /setup/{mac}. The flag persists in NVS across reboots and only + // clears when the server returns X-Claimed: 1 (see operation.h). prefs.begin(NVS_NAMESPACE, false); prefs.putString(NVS_KEY_SSID, g_req_ssid); prefs.putString(NVS_KEY_PASS, g_req_pass); + prefs.putInt(NVS_KEY_JUST_PROVISIONED, 1); prefs.end(); // Show Phase 2 QR and transition to polling loop diff --git a/src/operation.h b/src/operation.h index dea0e28..dc530e4 100644 --- a/src/operation.h +++ b/src/operation.h @@ -106,6 +106,7 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre bool drawNeeded = prefs.getInt(NVS_KEY_DRAW_NEEDED, 0) != 0; bool errBorder = prefs.getInt(NVS_KEY_ERR_BORDER, 0) != 0; int schemaV = prefs.getInt(NVS_KEY_SCHEMA_V, 0); + bool justProvisioned = prefs.getInt(NVS_KEY_JUST_PROVISIONED, 0) != 0; prefs.end(); // Schema migration: on first boot under err-border-aware firmware, the @@ -136,10 +137,32 @@ void normal_operation_impl(const String& mac, HTTP& http, const String& url, Pre http.addHeader("X-Boot-Reason", cause == ESP_SLEEP_WAKEUP_TIMER ? "timer" : "cold"); - const char* collectHeaders[] = { "X-Interval-Ms", "X-Image-Id", "X-Image-Sha256" }; - http.collectHeaders(collectHeaders, 3); + // Tell the server we're freshly provisioned and haven't been + // acknowledged yet. The server uses this to refuse to serve images + // for a stale binding (e.g. sold device whose old owner forgot to + // "Remove this frame") and to send X-Claimed: 1 once the binding is + // current — at which point we clear the NVS flag below. + if (justProvisioned) { + http.addHeader("X-Just-Provisioned", "1"); + } + + const char* collectHeaders[] = { "X-Interval-Ms", "X-Image-Id", "X-Image-Sha256", "X-Claimed" }; + http.collectHeaders(collectHeaders, 4); int code = http.GET(); + // Server confirmed we're claimed → flag clears, regardless of what + // happened to the response body. Without this, every poll forever + // would carry X-Just-Provisioned and force the awaiting-claim gate. + if (justProvisioned) { + String claimed = http.header("X-Claimed"); + if (claimed == "1") { + prefs.begin(NVS_NAMESPACE, false); + prefs.putInt(NVS_KEY_JUST_PROVISIONED, 0); + prefs.end(); + justProvisioned = false; + } + } + // Honor the server's X-Interval-Ms — that's the user's configured // rotationIntervalMinutes / wakeTimes schedule, computed in // DeviceImageController::computeIntervalMs. Clamp to sane physical diff --git a/test/mocks/config.h b/test/mocks/config.h index 3897ebc..4bedbbf 100644 --- a/test/mocks/config.h +++ b/test/mocks/config.h @@ -7,6 +7,7 @@ #define NVS_KEY_PASS "pass" #define NVS_KEY_IMG_ID "img_id" #define NVS_KEY_DRAW_NEEDED "draw" +#define NVS_KEY_JUST_PROVISIONED "just_prov" #define IMAGE_PATH "/img.bin" #define FETCH_INTERVAL_MS_FALLBACK 60000ULL #define SLEEP_CLAMP_MIN_MS 30000ULL diff --git a/test/test_normal_operation/test_main.cpp b/test/test_normal_operation/test_main.cpp index d0b74b7..e5337bf 100644 --- a/test/test_normal_operation/test_main.cpp +++ b/test/test_normal_operation/test_main.cpp @@ -314,6 +314,50 @@ void test_fw_timer_wake_sends_X_Boot_Reason_timer() { TEST_ASSERT_EQUAL_STRING("timer", g_http_request_headers["X-Boot-Reason"].c_str()); } +// FW-PROV-A: just-provisioned NVS flag set → header sent on the poll. Without +// this header the server can't tell a freshly-reset device from a cached +// one, and would happily serve the prior owner's photo to a buyer. +void test_fw_just_provisioned_flag_sets_header() { + prefs.ints[NVS_KEY_JUST_PROVISIONED] = 1; + g_http_response_headers["X-Image-Id"] = "1"; + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_EQUAL_STRING("1", g_http_request_headers["X-Just-Provisioned"].c_str()); +} + +// FW-PROV-B: no flag → no header. Steady-state polls must not look like +// fresh provisioning, otherwise every reboot would force the awaiting- +// claim gate. +void test_fw_no_flag_means_no_header() { + // prefs.ints[NVS_KEY_JUST_PROVISIONED] is unset, default 0. + g_http_response_headers["X-Image-Id"] = "1"; + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_TRUE( + g_http_request_headers.find("X-Just-Provisioned") == g_http_request_headers.end() + ); +} + +// FW-PROV-C: server returns X-Claimed: 1 → flag clears in NVS. From then +// on the firmware polls without the just-provisioned signal, just like a +// long-stable device. +void test_fw_X_Claimed_response_clears_flag() { + prefs.ints[NVS_KEY_JUST_PROVISIONED] = 1; + g_http_response_headers["X-Image-Id"] = "1"; + g_http_response_headers["X-Claimed"] = "1"; + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_EQUAL(0, prefs.getInt(NVS_KEY_JUST_PROVISIONED, -1)); +} + +// FW-PROV-D: server omits X-Claimed (e.g. stale-binding 204) → flag stays +// set so the device keeps signaling "I'm freshly provisioned" until a +// later poll lands on a fresh-binding response. +void test_fw_no_X_Claimed_response_keeps_flag() { + prefs.ints[NVS_KEY_JUST_PROVISIONED] = 1; + g_http_get_code = 204; + // No X-Claimed header set. + normal_operation_impl(String("mac"), http, String("url"), prefs); + TEST_ASSERT_EQUAL(1, prefs.getInt(NVS_KEY_JUST_PROVISIONED, -1)); +} + // FW-11: no X-Interval-Ms → fallback used. Should not happen in normal // operation but guards against rolling deploys / hand-crafted responses. void test_fw11_fallback_used_when_header_absent() { @@ -428,6 +472,10 @@ int main(int argc, char** argv) { RUN_TEST(test_fw11_fallback_used_when_header_absent); RUN_TEST(test_fw_cold_boot_sends_X_Boot_Reason_cold); RUN_TEST(test_fw_timer_wake_sends_X_Boot_Reason_timer); + RUN_TEST(test_fw_just_provisioned_flag_sets_header); + RUN_TEST(test_fw_no_flag_means_no_header); + RUN_TEST(test_fw_X_Claimed_response_clears_flag); + RUN_TEST(test_fw_no_X_Claimed_response_keeps_flag); RUN_TEST(test_fw12_ap_ssid_from_mac_aabbcc); RUN_TEST(test_fw13_ap_ssid_from_real_mac); RUN_TEST(test_fw14_304_skips_epd_sleep);