diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c
index 0b25e94..f43b726 100644
--- a/firmware/main/frame_client.c
+++ b/firmware/main/frame_client.c
@@ -99,11 +99,14 @@ static void save_wifi_cache(esp_netif_t *netif)
* normally a bare "host:port", defaulting to plain http; it may instead
* carry an explicit "http://" or "https://" prefix to pick the scheme,
* e.g. "https://frame.example.com" if a reverse proxy is terminating
- * TLS in front of the tools server. The token, once the server has
- * MANAGEMENT_TOKEN set, is required on every request the server
- * receives (device-facing endpoints included, not just the web UI) --
- * this is the one chokepoint all of them go through, so every caller
- * gets it for free instead of needing to remember to add it. */
+ * TLS in front of the tools server. Every URL carries ?id= (the device's
+ * MAC-derived identity -- how a multi-frame server tells frames apart
+ * and how an unknown frame self-registers) plus &token=: the server-
+ * issued per-frame device token once one has been delivered via
+ * /frame/config, else the provisioned access token (the legacy shared
+ * secret, also what a pre-multi-frame server still expects). This is
+ * the one chokepoint all requests go through, so every caller gets both
+ * for free instead of needing to remember to add them. */
static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path)
{
const char *toolsserver = cfg->toolsserver;
@@ -113,8 +116,16 @@ static void build_url(char *out, size_t out_size, const frame_config_t *cfg, con
} else {
len = (size_t)snprintf(out, out_size, "http://%s/%s", toolsserver, path);
}
- if (cfg->access_token[0] != '\0' && len < out_size) {
- snprintf(out + len, out_size - len, "?token=%s", cfg->access_token);
+
+ char device_id[FRAME_DEVICE_ID_LEN + 1];
+ frame_device_id_get(device_id, sizeof(device_id));
+ if (len < out_size) {
+ len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
+ }
+
+ const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token;
+ if (token[0] != '\0' && len < out_size) {
+ snprintf(out + len, out_size - len, "&token=%s", token);
}
}
@@ -266,6 +277,11 @@ typedef struct {
bool reachable;
uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */
char firmware_version[32]; /* server's uploaded OTA image version; empty if none/unreachable */
+ /* Per-frame token the server pushes until this device has
+ * authenticated with it once; empty when absent. Persisted via
+ * frame_config_set_device_token() and used by build_url() from the
+ * next request on. */
+ char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
} frame_server_config_t;
/* Finds the first integer value associated with "key" in a small JSON
@@ -356,6 +372,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
.refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
};
result.firmware_version[0] = '\0';
+ result.device_token[0] = '\0';
char url[256];
build_url(url, sizeof(url), cfg, "frame/config");
@@ -380,7 +397,10 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
esp_http_client_fetch_headers(client);
result.reachable = true;
- char body[256];
+ /* 512 (was 256): the response also carries "device_token" during the
+ * one-time identity handshake -- worst case is still well under half
+ * of this, the rest is headroom for future fields. */
+ char body[512];
int total = 0;
int n;
while (total < (int)sizeof(body) - 1 &&
@@ -400,6 +420,7 @@ static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
(int)result.refresh_interval_s);
}
json_extract_string(body, "firmware_version", result.firmware_version, sizeof(result.firmware_version));
+ json_extract_string(body, "device_token", result.device_token, sizeof(result.device_token));
return result;
}
@@ -952,6 +973,20 @@ void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool sho
frame_server_config_t server_cfg = fetch_frame_config(cfg);
sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;
+ /* One-time identity handshake: the server pushes this frame's
+ * own token until we've authenticated with it once. Persist it
+ * and use it immediately (the OTA below is part of this same
+ * cycle) via a local working copy -- cfg itself is const. */
+ frame_config_t updated_cfg;
+ if (server_cfg.device_token[0] != '\0' &&
+ strcmp(server_cfg.device_token, cfg->device_token) != 0) {
+ frame_config_set_device_token(server_cfg.device_token);
+ updated_cfg = *cfg;
+ snprintf(updated_cfg.device_token, sizeof(updated_cfg.device_token), "%s",
+ server_cfg.device_token);
+ cfg = &updated_cfg;
+ }
+
/* Last, deliberately -- the photo's already on screen and the
* battery report already sent, so a reboot here (whether OTA
* succeeds or the device is mid-update) never loses either. */
diff --git a/firmware/main/ota_update.c b/firmware/main/ota_update.c
index b15c631..9fd6c7d 100644
--- a/firmware/main/ota_update.c
+++ b/firmware/main/ota_update.c
@@ -17,7 +17,7 @@ static const char *TAG = "ota_update";
#define OTA_HTTP_TIMEOUT_MS 30000
/* Built the same way as every other tools-server URL -- scheme/cert/
- * token handling all come from build_url()'s conventions. Duplicated
+ * id/token handling all come from build_url()'s conventions. Duplicated
* tiny helper rather than exporting frame_client.c's static build_url();
* kept byte-identical in behavior (see frame_client.c). */
static void build_ota_url(char *out, size_t out_size, const frame_config_t *cfg)
@@ -29,8 +29,16 @@ static void build_ota_url(char *out, size_t out_size, const frame_config_t *cfg)
} else {
len = (size_t)snprintf(out, out_size, "http://%s/frame/firmware", toolsserver);
}
- if (cfg->access_token[0] != '\0' && len < out_size) {
- snprintf(out + len, out_size - len, "?token=%s", cfg->access_token);
+
+ char device_id[FRAME_DEVICE_ID_LEN + 1];
+ frame_device_id_get(device_id, sizeof(device_id));
+ if (len < out_size) {
+ len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
+ }
+
+ const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token;
+ if (token[0] != '\0' && len < out_size) {
+ snprintf(out + len, out_size - len, "&token=%s", token);
}
}
diff --git a/firmware/main/root.html b/firmware/main/root.html
index 42c7a08..4cfdebf 100644
--- a/firmware/main/root.html
+++ b/firmware/main/root.html
@@ -98,10 +98,14 @@
-
-
+
+
+
After saving, this page will
+ take you to the server to claim your frame — reconnect to
+ your normal WiFi if it doesn't happen automatically.
+
diff --git a/firmware/main/wifi_provisioning.c b/firmware/main/wifi_provisioning.c
index 634c2b8..d224fda 100644
--- a/firmware/main/wifi_provisioning.c
+++ b/firmware/main/wifi_provisioning.c
@@ -83,10 +83,39 @@ esp_err_t frame_config_load(frame_config_t *out)
return token_err;
}
+ /* Optional: absent until the server has pushed a per-frame token
+ * (see frame_config_set_device_token). */
+ len = sizeof(out->device_token);
+ token_err = nvs_get_str(handle, "device_token", out->device_token, &len);
+ if (token_err != ESP_OK && token_err != ESP_ERR_NVS_NOT_FOUND) {
+ nvs_close(handle);
+ return token_err;
+ }
+
nvs_close(handle);
return ESP_OK;
}
+void frame_device_id_get(char *out, size_t out_size)
+{
+ uint8_t mac[6] = {0};
+ ESP_ERROR_CHECK(esp_read_mac(mac, ESP_MAC_WIFI_STA));
+ snprintf(out, out_size, "%02x%02x%02x%02x%02x%02x",
+ mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
+}
+
+void frame_config_set_device_token(const char *token)
+{
+ nvs_handle_t handle;
+ if (nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) {
+ return;
+ }
+ nvs_set_str(handle, "device_token", token);
+ nvs_commit(handle);
+ nvs_close(handle);
+ ESP_LOGI(TAG, "Stored server-issued device token");
+}
+
esp_err_t frame_config_save(const frame_config_t *cfg)
{
nvs_handle_t handle;
@@ -106,6 +135,10 @@ esp_err_t frame_config_save(const frame_config_t *cfg)
err = nvs_set_str(handle, "access_token", cfg->access_token);
}
if (err == ESP_OK) {
+ /* Re-provisioning restarts the identity handshake: the server
+ * (possibly a different one now) re-issues a device token when
+ * the frame next introduces itself. */
+ nvs_erase_key(handle, "device_token");
/* Fresh (re)provisioning -- the next successful connection should
* show the status screen again. */
err = nvs_set_u8(handle, "connected_once", 0);
@@ -159,6 +192,7 @@ void frame_config_clear(void)
nvs_erase_key(handle, "sta_pass");
nvs_erase_key(handle, "toolsserver");
nvs_erase_key(handle, "access_token");
+ nvs_erase_key(handle, "device_token");
nvs_erase_key(handle, "connected_once");
nvs_commit(handle);
nvs_close(handle);
@@ -412,8 +446,35 @@ static esp_err_t save_config_post_handler(httpd_req_t *req)
ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s' access_token=%s", cfg.sta_ssid, cfg.toolsserver,
strlen(cfg.access_token) ? "set" : "none");
- static const char resp[] =
- "
Saved. Restarting and connecting to your WiFi...
";
+ /* The success page hands the browser off to the server's claim page,
+ * carrying this device's id -- how a frame gets linked to a user
+ * account. The ~7s delay covers the phone dropping this softAP (the
+ * device reboots right after this response) and rejoining its normal
+ * WiFi before the redirect fires; the visible link is the fallback
+ * if the phone loses that race. Scheme handling matches
+ * frame_client.c's build_url(): a bare host gets http://. */
+ char device_id[FRAME_DEVICE_ID_LEN + 1];
+ frame_device_id_get(device_id, sizeof(device_id));
+
+ char claim_url[FRAME_CFG_SERVER_MAX_LEN + 64];
+ const char *scheme = "";
+ if (strncmp(cfg.toolsserver, "http://", 7) != 0 && strncmp(cfg.toolsserver, "https://", 8) != 0) {
+ scheme = "http://";
+ }
+ snprintf(claim_url, sizeof(claim_url), "%s%s/claim?device_id=%s", scheme, cfg.toolsserver, device_id);
+
+ char resp[1024];
+ snprintf(resp, sizeof(resp),
+ ""
+ ""
+ ""
+ "
Saved — the frame is restarting
"
+ "
Reconnect to your normal WiFi. You'll be taken to the claim page "
+ "in a few seconds…
This frame already belongs to someone. If it's yours,
+ ask them (or an admin) to link your account to it.
+
+ {% elif status == "unregistered" %}
+ {% if pending_yours %}
+
Claim recorded.
+
Waiting for the frame to connect for the first time --
+ it links to your account automatically the moment it checks in.
+ This page refreshes itself; it's safe to close, too.
+ {% else %}
+
The frame hasn't checked in yet -- it's probably still
+ restarting and joining your WiFi. This page refreshes itself.
+ {% if user %}You can claim it now anyway; it'll attach when it
+ arrives.{% endif %}
+ {% endif %}
+
+ {% if user and status in ("unclaimed", "unregistered") and not pending_yours %}
+
+ {% endif %}
+
+
+ {% if not user and status in ("unclaimed", "unregistered") %}
+
+
Create your account
+
A valid frame is your invitation -- set up an account to
+ claim it. Already have one?
+ Log in instead.