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:
2026-05-08 16:03:22 -04:00
parent bbd5e84db0
commit a0dc4e0115
5 changed files with 87 additions and 3 deletions
+25 -2
View File
@@ -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