feat(operation): X-Just-Provisioned + X-Claimed handshake
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
+25
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user