diff --git a/docs/events.md b/docs/events.md index 661b6da8e..dad5ed38e 100644 --- a/docs/events.md +++ b/docs/events.md @@ -393,6 +393,7 @@ an event means: | Device commands | all five | `metrics` has no host; the rest are the firmware's (`fos_events.c`) | none: logged and ignored | | When a queued event is delivered | the runner thread's message loop | on the task that sent it, before `frameos_nim_send_event` returns; what a render dispatched, right after the render | before `frameos_wasm_event` returns; right after a render | | An event aimed at a scene that is not the current one (`target`) | delivered if that scene is in memory | dropped: one resident scene | dropped | +| An event before the current scene is built | not a state the runner has: scenes are built when selected | the event builds it (`single_scene_host.send`), so a boot's replayed button press reaches the scene it woke; a scene switch is served without building the scene being left | same as the ESP32 | | `close` on `reload` / `uploadScenes` | not sent | not sent | — | A frame on firmware older than `cloud.since` does not know `scene_event`: the diff --git a/docs/todo.md b/docs/todo.md index dc6536394..a991add4e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -56,28 +56,6 @@ does: the image and refuses a board whose table differs, so a bigger NVS strands every board already on the old table. -## ESP32: one HTTP client helper for the backend pulls - -Seven places in the firmware set up `esp_http_client` by hand for the same -job — a bearer-authenticated request to the backend or a provider, an -ETag or a body, a bounded read into a malloc'd buffer: `fos_settings.c` -(`read_body`), `fos_scenes.c`, `fos_tz.c`, `fos_ota.c` (twice), -`fos_client.c` (twice) and, since the command queue (PR #525), -`fos_commands.c` (`post_json`). Each copy is 1 to 2 KB of flash; the queue's -copy alone was most of what that PR added to the image. - -- [ ] **One helper** (`fos_backend_http.c`, say): URL built from the - config's backend URL and frame id, the bearer header, optional - If-None-Match, optional JSON body, the status, the response ETag, and a - body read capped at a caller-given size. Move `read_body` and - `collect_etag_handler` (both duplicated in settings and scenes) into it. -- [ ] Port the pulls one at a time — settings, scenes, commands, tz, the - OTA manifest — and compare the firmware size comment on each; expect the - set to come out a few KB lighter than before the queue, not just back to - even. -- Keep the image push (`push_image_to_backend`) and the streaming OTA - download out of it: they stream bodies, not buffer them. - ## Pre-release manual test sweep `docs/manual-testing-todo.md` holds the boxes that need hardware: the diff --git a/embedded/esp32/README.md b/embedded/esp32/README.md index 5229ffa3a..96d659ea0 100644 --- a/embedded/esp32/README.md +++ b/embedded/esp32/README.md @@ -34,6 +34,7 @@ main/ boot orchestration + platform modules fos_scenes.c /state scene store: combined payload, per-scene split, OOM-restart mark, wipe fos_scene_state.c scene state across deep sleep: one JSON file per scene under /state (host-tested) fos_commands.c the backend's command queue: pulled once per pass and on a nudge, acked by id + fos_backend_http.c the one HTTP client for the small pulls and posts (settings, scenes, commands, tz, OTA manifest) fos_schedule.c schedule evaluation; fos_schedule_catchup.h its catch-up window (host-tested) fos_events.c the ONE routing function for scene events (fos_events_dispatch): every producer — /event/, the schedule, the cloud verbs, the console's diff --git a/embedded/esp32/main/CMakeLists.txt b/embedded/esp32/main/CMakeLists.txt index 3b83af864..3f1aa3b35 100644 --- a/embedded/esp32/main/CMakeLists.txt +++ b/embedded/esp32/main/CMakeLists.txt @@ -22,6 +22,7 @@ idf_component_register( "fos_scenes.c" "fos_scene_state.c" "fos_commands.c" + "fos_backend_http.c" "fos_events.c" "fos_schedule.c" "fos_settings.c" diff --git a/embedded/esp32/main/fos_backend_http.c b/embedded/esp32/main/fos_backend_http.c new file mode 100644 index 000000000..c716be055 --- /dev/null +++ b/embedded/esp32/main/fos_backend_http.c @@ -0,0 +1,139 @@ +/* One HTTP client for the firmware's small pulls and posts. See the header. */ +#include "fos_backend_http.h" + +#include +#include +#include +#include + +#include "esp_crt_bundle.h" +#include "esp_http_client.h" +#include "esp_log.h" + +#include "fos_config.h" +#include "fos_mem.h" + +static const char *TAG = "fos_http"; + +/* A body of unknown length (chunked) starts here and doubles up to the cap. */ +#define FOS_HTTP_FIRST_CHUNK (16 * 1024) + +static esp_err_t collect_etag_handler(esp_http_client_event_t *evt) +{ + if (evt->event_id == HTTP_EVENT_ON_HEADER && strcasecmp(evt->header_key, "ETag") == 0) { + fos_http_response_t *res = (fos_http_response_t *)evt->user_data; + snprintf(res->etag, sizeof(res->etag), "%s", evt->header_value); + } + return ESP_OK; +} + +bool fos_backend_url(const char *suffix, char *out, size_t out_len) +{ + const fos_config_t *config = fos_config(); + if (!config->backend_url[0] || config->frame_id == 0) { + out[0] = '\0'; + return false; + } + int n = snprintf(out, out_len, "%s/api/frames/%lu%s", config->backend_url, + (unsigned long)config->frame_id, suffix ? suffix : ""); + return n > 0 && (size_t)n < out_len; +} + +void fos_backend_auth(char *out, size_t out_len) +{ + snprintf(out, out_len, "Bearer %s", fos_config()->api_key); +} + +void fos_http_response_free(fos_http_response_t *res) +{ + free(res->body); + res->body = NULL; + res->body_len = 0; +} + +/* The body, however it is delimited: Content-Length sizes the buffer once; + * a chunked answer grows it up to the cap. */ +static void read_body(esp_http_client_handle_t client, size_t max_body, fos_http_response_t *res) +{ + if (res->content_length > (int64_t)max_body) { + res->too_large = true; + return; + } + size_t cap = res->content_length > 0 ? (size_t)res->content_length + : max_body < FOS_HTTP_FIRST_CHUNK ? max_body + : FOS_HTTP_FIRST_CHUNK; + char *buf = fos_big_malloc(cap + 1); + if (buf == NULL) return; + size_t total = 0; + while (true) { + if (total == cap) { + if (cap >= max_body) { + /* More than the cap and still coming. */ + res->too_large = true; + free(buf); + return; + } + size_t new_cap = cap * 2 > max_body ? max_body : cap * 2; + char *grown = fos_big_realloc(buf, new_cap + 1); + if (grown == NULL) { + free(buf); + return; + } + buf = grown; + cap = new_cap; + } + int r = esp_http_client_read(client, buf + total, (int)(cap - total)); + if (r < 0) { + free(buf); + return; + } + if (r == 0) break; + total += (size_t)r; + } + buf[total] = '\0'; + res->body = buf; + res->body_len = total; +} + +esp_err_t fos_http_fetch(const fos_http_request_t *req, size_t max_body, fos_http_response_t *res) +{ + memset(res, 0, sizeof(*res)); + esp_http_client_config_t http_config = { + .url = req->url, + .method = req->body ? HTTP_METHOD_POST : HTTP_METHOD_GET, + .timeout_ms = req->timeout_ms > 0 ? req->timeout_ms : 15000, + .crt_bundle_attach = esp_crt_bundle_attach, + .buffer_size = req->buffer_size > 0 ? req->buffer_size : 2048, + .event_handler = collect_etag_handler, + .user_data = res, + }; + esp_http_client_handle_t client = esp_http_client_init(&http_config); + if (client == NULL) return ESP_ERR_NO_MEM; + if (req->auth) esp_http_client_set_header(client, "Authorization", req->auth); + if (req->if_none_match && req->if_none_match[0]) { + esp_http_client_set_header(client, "If-None-Match", req->if_none_match); + } + if (req->no_store) esp_http_client_set_header(client, "Cache-Control", "no-store"); + if (req->body) esp_http_client_set_header(client, "Content-Type", "application/json"); + + int body_len = req->body ? (int)strlen(req->body) : 0; + esp_err_t err = esp_http_client_open(client, body_len); + if (err != ESP_OK) { + ESP_LOGW(TAG, "%s: connect failed: %s", req->url, esp_err_to_name(err)); + esp_http_client_cleanup(client); + return err; + } + if (body_len > 0 && esp_http_client_write(client, req->body, body_len) != body_len) { + esp_http_client_close(client); + esp_http_client_cleanup(client); + return ESP_FAIL; + } + res->content_length = esp_http_client_fetch_headers(client); + res->status = esp_http_client_get_status_code(client); + if (max_body > 0 && res->status != 304 && res->status != 204) { + read_body(client, max_body, res); + } + esp_http_client_close(client); + esp_http_client_cleanup(client); + return ESP_OK; +} diff --git a/embedded/esp32/main/fos_backend_http.h b/embedded/esp32/main/fos_backend_http.h new file mode 100644 index 000000000..a587c380b --- /dev/null +++ b/embedded/esp32/main/fos_backend_http.h @@ -0,0 +1,52 @@ +/* + * One HTTP client for the firmware's small pulls and posts. + * + * The settings pull, the scenes pull, the commands pull, the tz slice lookup + * and the OTA manifest all used to set up esp_http_client by hand: the same + * bearer header, the same optional If-None-Match, the same bounded read into + * a malloc'd buffer, the same close/cleanup on every exit — five copies, 1 to + * 2 KB of flash each. This is the one copy. Streams stay out of it: the + * remote-render bitmap, the OTA image and the image push read or write bodies + * as they go. + */ +#pragma once + +#include +#include +#include + +#include "esp_err.h" + +typedef struct { + const char *url; + const char *auth; /* the Authorization value ("Bearer …"); NULL = none */ + const char *if_none_match; /* NULL = none */ + const char *body; /* JSON to POST; NULL = GET */ + bool no_store; /* Cache-Control: no-store (a body that is credentials) */ + int timeout_ms; /* 0 = 15 s */ + int buffer_size; /* esp_http_client's receive buffer; 0 = 2 KB */ +} fos_http_request_t; + +typedef struct { + int status; /* the HTTP status; 0 when no answer came */ + int64_t content_length; /* as declared; -1 = chunked, read to the end */ + bool too_large; /* the body was over the cap and was not read */ + char etag[96]; /* the response ETag, "" when none */ + char *body; /* NUL-terminated; NULL when not read. fos_http_response_free() */ + size_t body_len; +} fos_http_response_t; + +/* Perform the request. ESP_OK means an answer came (any status, including + * 304 and 4xx — look at `res->status`); an error means it never did. The body + * is read into `res->body` when `max_body` > 0 and the answer carries one + * (not on 304), up to `max_body` bytes: past that it is dropped and + * `too_large` set. The buffer is PSRAM where the board has it (fos_mem.h), + * so a scene bundle does not land in internal RAM. */ +esp_err_t fos_http_fetch(const fos_http_request_t *req, size_t max_body, fos_http_response_t *res); + +void fos_http_response_free(fos_http_response_t *res); + +/* `{backend_url}/api/frames/{frame_id}{suffix}`; false when no backend is + * configured. And the bearer value for it. */ +bool fos_backend_url(const char *suffix, char *out, size_t out_len); +void fos_backend_auth(char *out, size_t out_len); diff --git a/embedded/esp32/main/fos_commands.c b/embedded/esp32/main/fos_commands.c index 2a834e7a1..98edd23fb 100644 --- a/embedded/esp32/main/fos_commands.c +++ b/embedded/esp32/main/fos_commands.c @@ -5,10 +5,9 @@ #include #include "cJSON.h" -#include "esp_crt_bundle.h" -#include "esp_http_client.h" #include "esp_log.h" +#include "fos_backend_http.h" #include "fos_client.h" #include "fos_cloud.h" #include "fos_config.h" @@ -39,48 +38,22 @@ typedef struct { * negative value when the request never completed. */ static int post_json(const char *suffix, const char *body, size_t limit, char **out) { - const fos_config_t *config = fos_config(); char url[FOS_URL_LEN + 192]; - snprintf(url, sizeof(url), "%s/api/frames/%lu/embedded/commands%s", config->backend_url, - (unsigned long)config->frame_id, suffix); + char path[160]; + snprintf(path, sizeof(path), "/embedded/commands%s", suffix); + fos_backend_url(path, url, sizeof(url)); char auth[FOS_STR_LEN + 16]; - snprintf(auth, sizeof(auth), "Bearer %s", config->api_key); + fos_backend_auth(auth, sizeof(auth)); + fos_http_request_t request = {.url = url, .auth = auth, .body = body}; + fos_http_response_t response; if (out) *out = NULL; - esp_http_client_config_t http_config = { - .url = url, - .method = HTTP_METHOD_POST, - .timeout_ms = FOS_COMMANDS_TIMEOUT_MS, - .crt_bundle_attach = esp_crt_bundle_attach, - .buffer_size = 2048, - }; - esp_http_client_handle_t client = esp_http_client_init(&http_config); - if (client == NULL) return -1; - esp_http_client_set_header(client, "Authorization", auth); - esp_http_client_set_header(client, "Content-Type", "application/json"); - int body_len = (int)strlen(body); - int status = -1; - if (esp_http_client_open(client, body_len) == ESP_OK && - esp_http_client_write(client, body, body_len) == body_len) { - int64_t content_length = esp_http_client_fetch_headers(client); - status = esp_http_client_get_status_code(client); - if (out && content_length <= (int64_t)limit) { - size_t cap = content_length > 0 ? (size_t)content_length : limit; - char *buf = malloc(cap + 1); - if (buf != NULL) { - size_t total = 0; - while (total < cap) { - int r = esp_http_client_read(client, buf + total, (int)(cap - total)); - if (r <= 0) break; - total += (size_t)r; - } - buf[total] = '\0'; - *out = buf; - } - } - esp_http_client_close(client); + if (fos_http_fetch(&request, out ? limit : 0, &response) != ESP_OK) return -1; + if (out) { + *out = response.body; /* ours now */ + } else { + fos_http_response_free(&response); } - esp_http_client_cleanup(client); - return status; + return response.status; } /* The ack, with what the frame shows afterwards (the cloud hello's state diff --git a/embedded/esp32/main/fos_ota.c b/embedded/esp32/main/fos_ota.c index f04a415ed..076ae5d80 100644 --- a/embedded/esp32/main/fos_ota.c +++ b/embedded/esp32/main/fos_ota.c @@ -26,6 +26,7 @@ #include "monocypher.h" #include "monocypher-ed25519.h" +#include "fos_backend_http.h" #include "fos_cloud.h" #include "fos_config.h" #include "fos_http.h" @@ -277,33 +278,25 @@ static esp_err_t ota_fetch_manifest(const ota_source_t *src, ota_manifest_t *out { *up_to_date = false; ESP_LOGI(TAG, "ota (%s): checking manifest %s", src->plane, src->manifest_url); - esp_http_client_config_t config = { + fos_http_request_t request = { .url = src->manifest_url, + .auth = src->auth, .timeout_ms = 20000, - .crt_bundle_attach = esp_crt_bundle_attach, .buffer_size = 4096, }; - esp_http_client_handle_t client = esp_http_client_init(&config); - if (client == NULL) { - ota_log(src, "error", "no-memory"); - return ESP_ERR_NO_MEM; - } - esp_http_client_set_header(client, "Authorization", src->auth); - esp_err_t err = esp_http_client_open(client, 0); + fos_http_response_t response; + esp_err_t err = fos_http_fetch(&request, FOS_OTA_MANIFEST_MAX, &response); if (err != ESP_OK) { - esp_http_client_cleanup(client); - ota_log(src, "error", "manifest-connect-failed"); + ota_log(src, "error", err == ESP_ERR_NO_MEM ? "no-memory" : "manifest-connect-failed"); return err; } - int64_t content_length = esp_http_client_fetch_headers(client); - int status = esp_http_client_get_status_code(client); + int status = response.status; /* Dev servers (and some proxies) send chunked responses with no * Content-Length — read to EOF under the manifest cap either way. */ - if (status != 200 || content_length > FOS_OTA_MANIFEST_MAX) { - esp_http_client_close(client); - esp_http_client_cleanup(client); + if (status != 200 || response.too_large) { + fos_http_response_free(&response); ESP_LOGW(TAG, "ota (%s): manifest HTTP %d (%lld bytes)", src->plane, status, - (long long)content_length); + (long long)response.content_length); /* 404 = the release carries no OTA app image for this platform; 409 = * it has one but no signature. Both are "nothing to install", and * telling them apart in the log is the difference between waiting for @@ -313,25 +306,13 @@ static esp_err_t ota_fetch_manifest(const ota_source_t *src, ota_manifest_t *out : "manifest-unavailable"); return status == 404 ? ESP_ERR_NOT_FOUND : ESP_FAIL; } - char *body = malloc(FOS_OTA_MANIFEST_MAX + 1); - if (body == NULL) { - esp_http_client_close(client); - esp_http_client_cleanup(client); + if (response.body == NULL) { ota_log(src, "error", "no-memory"); return ESP_ERR_NO_MEM; } - size_t total = 0; - while (total < FOS_OTA_MANIFEST_MAX) { - int r = esp_http_client_read(client, body + total, FOS_OTA_MANIFEST_MAX - total); - if (r <= 0) break; - total += (size_t)r; - } - esp_http_client_close(client); - esp_http_client_cleanup(client); - body[total] = '\0'; - cJSON *root = cJSON_ParseWithLength(body, total); - free(body); + cJSON *root = cJSON_ParseWithLength(response.body, response.body_len); + fos_http_response_free(&response); if (root == NULL) { ota_log(src, "error", "manifest-unparseable"); return ESP_FAIL; diff --git a/embedded/esp32/main/fos_scenes.c b/embedded/esp32/main/fos_scenes.c index ebcc6366c..17e227297 100644 --- a/embedded/esp32/main/fos_scenes.c +++ b/embedded/esp32/main/fos_scenes.c @@ -9,15 +9,14 @@ #include #include -#include "esp_crt_bundle.h" #include "esp_heap_caps.h" -#include "esp_http_client.h" #include "esp_log.h" #include "esp_spiffs.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "cJSON.h" +#include "fos_backend_http.h" #include "fos_client.h" #include "fos_config.h" #include "fos_mem.h" @@ -1244,16 +1243,6 @@ esp_err_t fos_scenes_set_json(const char *json, size_t len) /* ------------------------------------------------------------- backend */ -static esp_err_t collect_etag_handler(esp_http_client_event_t *evt) -{ - if (evt->event_id == HTTP_EVENT_ON_HEADER && - strcasecmp(evt->header_key, "ETag") == 0) { - char *etag_out = (char *)evt->user_data; - snprintf(etag_out, ETAG_LEN, "%s", evt->header_value); - } - return ESP_OK; -} - esp_err_t fos_scenes_sync(bool force) { fos_config_t *config = fos_config(); @@ -1274,115 +1263,55 @@ esp_err_t fos_scenes_sync(bool force) if (err != ESP_OK) return err; char url[FOS_URL_LEN + 64]; - snprintf(url, sizeof(url), "%s/api/frames/%lu/embedded/scenes", - config->backend_url, (unsigned long)config->frame_id); + fos_backend_url("/embedded/scenes", url, sizeof(url)); char auth[FOS_STR_LEN + 16]; - snprintf(auth, sizeof(auth), "Bearer %s", config->api_key); - - char response_etag[ETAG_LEN] = ""; - esp_http_client_config_t http_config = { + fos_backend_auth(auth, sizeof(auth)); + fos_http_request_t request = { .url = url, + .auth = auth, + .if_none_match = (!force && strcmp(s_etag, "local") != 0) ? s_etag : NULL, .timeout_ms = 30000, - .crt_bundle_attach = esp_crt_bundle_attach, .buffer_size = 4096, - .event_handler = collect_etag_handler, - .user_data = response_etag, }; - esp_http_client_handle_t client = esp_http_client_init(&http_config); - if (client == NULL) return ESP_FAIL; - esp_http_client_set_header(client, "Authorization", auth); - if (!force && s_etag[0] && strcmp(s_etag, "local") != 0) { - esp_http_client_set_header(client, "If-None-Match", s_etag); - } - - err = esp_http_client_open(client, 0); + fos_http_response_t response; + err = fos_http_fetch(&request, SCENES_MAX_BYTES, &response); if (err != ESP_OK) { ESP_LOGW(TAG, "scenes sync: connect failed: %s", esp_err_to_name(err)); log_scene_event("scenes:sync", "error", "backend", "", "connect-failed", 0, 0, 0, err); - esp_http_client_cleanup(client); return err; } - int64_t content_length = esp_http_client_fetch_headers(client); - int status = esp_http_client_get_status_code(client); - + int status = response.status; if (status == 304) { if (log_unchanged) { log_scene_event("scenes:sync", "ok", "backend", "", "unchanged", 0, s_loaded, status, ESP_OK); } - esp_http_client_close(client); - esp_http_client_cleanup(client); return ESP_OK; } if (status != 200) { ESP_LOGW(TAG, "scenes sync: HTTP %d from %s", status, url); log_scene_event("scenes:sync", "error", "backend", "", "http-error", 0, 0, status, ESP_FAIL); - esp_http_client_close(client); - esp_http_client_cleanup(client); + fos_http_response_free(&response); return ESP_FAIL; } - if (content_length > SCENES_MAX_BYTES) { - ESP_LOGE(TAG, "scenes sync: payload too large (%lld)", content_length); + if (response.too_large) { + ESP_LOGE(TAG, "scenes sync: payload too large (%lld)", response.content_length); log_scene_event("scenes:sync", "error", "backend", "", "payload-too-large", - (size_t)content_length, 0, status, ESP_ERR_NO_MEM); - esp_http_client_close(client); - esp_http_client_cleanup(client); - return ESP_ERR_NO_MEM; - } - - size_t cap = content_length > 0 ? (size_t)content_length : 16384; - char *buf = fos_big_malloc(cap + 1); - if (buf == NULL) buf = malloc(cap + 1); - if (buf == NULL) { - log_scene_event("scenes:sync", "error", "backend", "", "out-of-memory", - (size_t)(content_length > 0 ? content_length : 0), + (size_t)(response.content_length > 0 ? response.content_length : SCENES_MAX_BYTES), 0, status, ESP_ERR_NO_MEM); - esp_http_client_close(client); - esp_http_client_cleanup(client); return ESP_ERR_NO_MEM; } - size_t total = 0; - while (true) { - if (total == cap) { - if (cap >= SCENES_MAX_BYTES) { - log_scene_event("scenes:sync", "error", "backend", "", "payload-too-large", - cap, 0, status, ESP_ERR_NO_MEM); - free(buf); - esp_http_client_close(client); - esp_http_client_cleanup(client); - return ESP_ERR_NO_MEM; - } - size_t new_cap = cap * 2 > SCENES_MAX_BYTES ? SCENES_MAX_BYTES : cap * 2; - char *new_buf = fos_big_realloc(buf, new_cap + 1); - if (new_buf == NULL) new_buf = realloc(buf, new_cap + 1); - if (new_buf == NULL) { - log_scene_event("scenes:sync", "error", "backend", "", "out-of-memory", - new_cap, 0, status, ESP_ERR_NO_MEM); - free(buf); - esp_http_client_close(client); - esp_http_client_cleanup(client); - return ESP_ERR_NO_MEM; - } - buf = new_buf; - cap = new_cap; - } - int r = esp_http_client_read(client, buf + total, cap - total); - if (r < 0) { - log_scene_event("scenes:sync", "error", "backend", "", "read-failed", - total, 0, status, ESP_FAIL); - free(buf); - esp_http_client_close(client); - esp_http_client_cleanup(client); - return ESP_FAIL; - } - if (r == 0) break; - total += r; + if (response.body == NULL) { + log_scene_event("scenes:sync", "error", "backend", "", "read-failed", + 0, 0, status, ESP_FAIL); + return ESP_FAIL; } - esp_http_client_close(client); - esp_http_client_cleanup(client); - buf[total] = 0; + char *buf = response.body; + size_t total = response.body_len; + char response_etag[ETAG_LEN]; + strlcpy(response_etag, response.etag, sizeof(response_etag)); err = write_file_replace(SCENES_PATH, SCENES_TMP_PATH, buf, total); if (err == ESP_OK) { diff --git a/embedded/esp32/main/fos_settings.c b/embedded/esp32/main/fos_settings.c index f06f2ce6d..43b1a8842 100644 --- a/embedded/esp32/main/fos_settings.c +++ b/embedded/esp32/main/fos_settings.c @@ -9,14 +9,13 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" -#include "esp_crt_bundle.h" -#include "esp_http_client.h" #include "esp_log.h" #include "esp_system.h" #include "cJSON.h" #include "esp_timer.h" #include "nvs.h" +#include "fos_backend_http.h" #include "fos_cloud.h" #include "fos_config.h" #include "fos_mem.h" @@ -33,7 +32,6 @@ static const char *TAG = "fos_settings"; #define SETTINGS_MAX_BYTES (32 * 1024) #define SETTINGS_ETAG_LEN 96 /* Provider error bodies are a bare {"error": "…"}. */ -#define SETTINGS_ERROR_MAX_BYTES 512 #define SETTINGS_AUTH_LEN (FOS_CLOUD_TOKEN_LEN + 16) /* Steady-state and post-failure spacing for the cloud pull, matching the Pi * hub client (HubServiceSettingsIntervalSeconds / …RetrySeconds). Every other @@ -338,16 +336,6 @@ bool fos_settings_cloud_scope(void) return s_cloud_scope_granted; } -static esp_err_t collect_etag_handler(esp_http_client_event_t *evt) -{ - if (evt->event_id == HTTP_EVENT_ON_HEADER && - strcasecmp(evt->header_key, "ETag") == 0) { - char *etag_out = (char *)evt->user_data; - snprintf(etag_out, SETTINGS_ETAG_LEN, "%s", evt->header_value); - } - return ESP_OK; -} - static void log_settings_event(const char *status, const char *detail) { char line[256]; @@ -372,27 +360,6 @@ static void log_settings_applied(const char *detail, const char *changed) /* Read at most `limit` bytes of the response body. Returns a NUL-terminated * malloc'd buffer (caller frees) or NULL. */ -static char *read_body(esp_http_client_handle_t client, int64_t content_length, - size_t limit) -{ - if (content_length > (int64_t)limit) return NULL; - size_t cap = content_length > 0 ? (size_t)content_length : limit; - char *buf = malloc(cap + 1); - if (buf == NULL) return NULL; - size_t total = 0; - while (total < cap) { - int r = esp_http_client_read(client, buf + total, cap - total); - if (r < 0) { - free(buf); - return NULL; - } - if (r == 0) break; - total += (size_t)r; - } - buf[total] = '\0'; - return buf; -} - /* A pin the chip cannot spare (SPI flash / PSRAM pad, driver-claimed) is * refused and the previous value kept; -1 ("none") always passes. */ static bool settings_pin_usable(const char *key, int pin) @@ -840,57 +807,37 @@ esp_err_t fos_settings_sync(bool force) s_etag_source = source; } - char response_etag[SETTINGS_ETAG_LEN] = ""; - esp_http_client_config_t http_config = { + fos_http_request_t request = { .url = url, - .timeout_ms = 15000, - .crt_bundle_attach = esp_crt_bundle_attach, - .buffer_size = 2048, - .event_handler = collect_etag_handler, - .user_data = response_etag, - }; - esp_http_client_handle_t client = esp_http_client_init(&http_config); - if (client == NULL) { - log_settings_event("error", "client-init-failed"); - return ESP_FAIL; - } - esp_http_client_set_header(client, "Authorization", auth); - if (source == SETTINGS_SOURCE_CLOUD) { + .auth = auth, /* This body is the account's credentials: no intermediate cache may * keep a copy, matching the provider's own Cache-Control: no-store. */ - esp_http_client_set_header(client, "Cache-Control", "no-store"); - } - if (!force && s_etag[0]) { - esp_http_client_set_header(client, "If-None-Match", s_etag); - } - - esp_err_t err = esp_http_client_open(client, 0); - if (err != ESP_OK) { - ESP_LOGW(TAG, "settings sync: connect failed: %s", esp_err_to_name(err)); - esp_http_client_cleanup(client); - return err; + .no_store = source == SETTINGS_SOURCE_CLOUD, + .if_none_match = force ? NULL : s_etag, + }; + fos_http_response_t response; + if (fos_http_fetch(&request, SETTINGS_MAX_BYTES, &response) != ESP_OK) { + log_settings_event("error", "connect-failed"); + return ESP_FAIL; } - int64_t content_length = esp_http_client_fetch_headers(client); - int status = esp_http_client_get_status_code(client); + int status = response.status; if (status != 304) { char status_line[192]; snprintf(status_line, sizeof(status_line), "{\"event\":\"settings:sync\",\"source\":\"esp32\"," "\"status\":\"fetched\",\"httpStatus\":%d,\"bytes\":%lld}", - status, (long long)content_length); + status, (long long)response.content_length); frameos_nim_log_hook(status_line); } if (status == 304) { /* The copy we hold is the copy the source would send. Keep it. */ - esp_http_client_close(client); - esp_http_client_cleanup(client); if (source == SETTINGS_SOURCE_CLOUD) { s_cloud_next_pull_us = esp_timer_get_time() + SETTINGS_CLOUD_INTERVAL_US; } return ESP_OK; } - if (status != 200 || content_length > SETTINGS_MAX_BYTES) { + if (status != 200 || response.too_large || response.body == NULL) { /* `403 insufficient_scope` is the revocation boundary: the link no * longer holds settings:services, so every cloud-owned group comes off * the device and the source goes quiet until a `ready` re-announces @@ -898,19 +845,14 @@ esp_err_t fos_settings_sync(bool force) * frame_not_active, 401, 429, 5xx, a truncated read — keeps the * current copy. */ bool revoked = false; - if (source == SETTINGS_SOURCE_CLOUD && status == 403) { - char *error_body = read_body(client, content_length, SETTINGS_ERROR_MAX_BYTES); - if (error_body != NULL) { - cJSON *error_json = cJSON_Parse(error_body); - const cJSON *code = cJSON_GetObjectItem(error_json, "error"); - revoked = cJSON_IsString(code) && code->valuestring && - strcmp(code->valuestring, "insufficient_scope") == 0; - cJSON_Delete(error_json); - free(error_body); - } + if (source == SETTINGS_SOURCE_CLOUD && status == 403 && response.body != NULL) { + cJSON *error_json = cJSON_Parse(response.body); + const cJSON *code = cJSON_GetObjectItem(error_json, "error"); + revoked = cJSON_IsString(code) && code->valuestring && + strcmp(code->valuestring, "insufficient_scope") == 0; + cJSON_Delete(error_json); } - esp_http_client_close(client); - esp_http_client_cleanup(client); + fos_http_response_free(&response); if (revoked) { frameos_nim_apply_service_settings("{}"); fos_settings_forget_cache(); @@ -924,19 +866,14 @@ esp_err_t fos_settings_sync(bool force) if (status != 404) { /* older backends have no settings route */ ESP_LOGW(TAG, "settings sync: HTTP %d from %s", status, url); } - return ESP_FAIL; - } - - char *buf = read_body(client, content_length, SETTINGS_MAX_BYTES); - esp_http_client_close(client); - esp_http_client_cleanup(client); - if (buf == NULL) { - return ESP_ERR_NO_MEM; + return status == 200 ? ESP_ERR_NO_MEM : ESP_FAIL; } - cJSON *root = cJSON_ParseWithLength(buf, strlen(buf)); - memset(buf, 0, strlen(buf)); /* the body carries the account's API keys */ - free(buf); + char response_etag[SETTINGS_ETAG_LEN]; + strlcpy(response_etag, response.etag, sizeof(response_etag)); + cJSON *root = cJSON_ParseWithLength(response.body, response.body_len); + memset(response.body, 0, response.body_len); /* the body carries the account's API keys */ + fos_http_response_free(&response); if (root == NULL || !cJSON_IsObject(root)) { cJSON_Delete(root); log_settings_event("error", "unparseable"); diff --git a/embedded/esp32/main/fos_tz.c b/embedded/esp32/main/fos_tz.c index 183b03af3..3774af661 100644 --- a/embedded/esp32/main/fos_tz.c +++ b/embedded/esp32/main/fos_tz.c @@ -1,4 +1,5 @@ #include "fos_tz.h" +#include "fos_backend_http.h" #include "fos_config.h" #include "fos_scenes.h" #include "fos_wifi.h" @@ -8,8 +9,6 @@ #include #include -#include "esp_crt_bundle.h" -#include "esp_http_client.h" #include "esp_log.h" #include "esp_timer.h" @@ -142,36 +141,18 @@ static esp_err_t fetch_slice(const char *name, char *out, size_t out_len) { char url[sizeof(TZ_LOOKUP_URL_PREFIX) + FOS_STR_LEN + 8]; snprintf(url, sizeof(url), TZ_LOOKUP_URL_PREFIX "%s.json", name); - esp_http_client_config_t http_config = { - .url = url, - .timeout_ms = 10000, - .crt_bundle_attach = esp_crt_bundle_attach, - .buffer_size = 1024, - }; - esp_http_client_handle_t client = esp_http_client_init(&http_config); - if (client == NULL) return ESP_FAIL; - esp_err_t err = esp_http_client_open(client, 0); - if (err != ESP_OK) { - esp_http_client_cleanup(client); - return err; - } - int64_t content_length = esp_http_client_fetch_headers(client); - int status = esp_http_client_get_status_code(client); - if (status != 200 || content_length > (int64_t)out_len - 1) { - ESP_LOGW(TAG, "tz lookup for '%s': HTTP %d (%lld bytes)", name, status, (long long)content_length); - esp_http_client_close(client); - esp_http_client_cleanup(client); - return status == 404 ? ESP_ERR_NOT_FOUND : ESP_FAIL; - } - int total = 0; - while (total < (int)out_len - 1) { - int n = esp_http_client_read(client, out + total, (int)out_len - 1 - total); - if (n <= 0) break; - total += n; + fos_http_request_t request = {.url = url, .timeout_ms = 10000, .buffer_size = 1024}; + fos_http_response_t response; + esp_err_t err = fos_http_fetch(&request, out_len - 1, &response); + if (err != ESP_OK) return err; + if (response.status != 200 || response.body == NULL) { + ESP_LOGW(TAG, "tz lookup for '%s': HTTP %d (%lld bytes)", name, response.status, + (long long)response.content_length); + fos_http_response_free(&response); + return response.status == 404 ? ESP_ERR_NOT_FOUND : ESP_FAIL; } - out[total] = '\0'; - esp_http_client_close(client); - esp_http_client_cleanup(client); + strlcpy(out, response.body, out_len); + fos_http_response_free(&response); return out[0] ? ESP_OK : ESP_FAIL; } diff --git a/frameos/src/frameos/single_scene_host.nim b/frameos/src/frameos/single_scene_host.nim index 367ac3331..a001e6ef6 100644 --- a/frameos/src/frameos/single_scene_host.nim +++ b/frameos/src/frameos/single_scene_host.nim @@ -228,7 +228,12 @@ proc send*(host: SingleSceneHost, origin: EventOrigin, event: string, payload: J # The current scene is built on first use, and an event is a use: a # battery ESP32 reads the button that woke it before the first render, so # the press found no resident scene and was dropped ("dispatched": false). - discard host.ensureScene() + # Not for a scene switch: the dispatcher serves that without a scene, and + # a switch queued for the wake (the backend's set_current_scene) would + # otherwise build the scene it is leaving first — seconds of JavaScript + # transpiling for a scene nobody sees. + if event != "setCurrentScene": + discard host.ensureScene() discard host.events.tick() host.events.dispatchNow(origin, event, payload) diff --git a/frameos/src/frameos/tests/test_single_scene_host.nim b/frameos/src/frameos/tests/test_single_scene_host.nim index 17d680497..c75e3d7c9 100644 --- a/frameos/src/frameos/tests/test_single_scene_host.nim +++ b/frameos/src/frameos/tests/test_single_scene_host.nim @@ -42,4 +42,19 @@ doAssert host.send(eoDriver, "button", %*{"pin": 5, "label": "RIGHT", "level": 0 "a button sent before the first render is dispatched" doAssert not host.scene.isNil doAssert selected == @["fixtures/dispatcher/switchTo"], "selected " & $selected + +# A switch sent before the first render (the backend's set_current_scene, +# queued for the wake) must not build the scene it is leaving: the dispatcher +# serves a switch without one, and building it is seconds of work for a scene +# nobody sees. +let fresh = SingleSceneHost(frameConfig: config, logger: logger) +var freshSelected: seq[string] +fresh.requestSelect = proc (sceneId: string): bool = + freshSelected.add(sceneId) + true +fresh.start() +fresh.makeCurrent("fixtures/dispatcher/switchFrom".SceneId) +doAssert fresh.send(eoCloud, "setCurrentScene", %*{"sceneId": "fixtures/dispatcher/switchTo"}) +doAssert fresh.scene.isNil, "the scene being left was not built for the switch" +doAssert freshSelected == @["fixtures/dispatcher/switchTo"], "selected " & $freshSelected echo "ok"