From 57c93fd2978ed5b5e64f4620803b7a2cd7767ed2 Mon Sep 17 00:00:00 2001 From: Luke Hoersten Date: Sat, 13 Jun 2026 22:52:41 -0500 Subject: M6: state machine — POST /state, idle timer, /frame 409 guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit state_machine.{h,c} — central wake/sleep transitions: - state_machine_init() creates the esp_timer one-shot for the idle timer. - state_machine_set(target) is idempotent and atomic. On AWAKE: backlight on, idle timer (re)armed. On ASLEEP: idle timer cancelled, backlight off. Rejects with INVALID_STATE when the device is unconfigured. - state_machine_frame_painted() restarts the idle timer if awake; called by /frame after each successful paint. - Idle-timer callback transitions to ASLEEP. TODO M7 hook: outbound POST {viewport, state:sleep} to /state. http_api.c: - POST /state: parse {state}, accept "wake"/"sleep", reject others 400. Unconfigured device → 409 "device unconfigured". Already-in-state → 204 (idempotent no-op). Successful transition → 204. - POST /frame: 409 Conflict when state != AWAKE. After successful paint, call state_machine_frame_painted() so the idle clock keeps resetting while frames stream. app_main: - Initialize state_machine before http_api so the route handler can drive it from request 0. - After display_init(), reconcile the panel with the boot state: UNCONFIGURED → test pattern (placeholder until M8 IP screen) ASLEEP → display_sleep() so a configured device boots dark AWAKE → leave on (not reached on fresh boot) Disable path: idle_timeout_ms=0 in /config means the timer is never armed and a wake state persists until /state {sleep} or a power cycle. Build clean against ESP-IDF 5.4 (binary ~645 KB). TESTING.md M6 expands with idempotency checks, 409-when-asleep, 409-when- unconfigured, idle-timer firing within idle_timeout_ms+slack, /frame restarting the idle timer, idle-timer disable via idle_timeout_ms=0, and the M7 dependency note about the missing outbound sleep POST. --- main/http_api.c | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) (limited to 'main/http_api.c') diff --git a/main/http_api.c b/main/http_api.c index 8bf5f81..b3378b5 100644 --- a/main/http_api.c +++ b/main/http_api.c @@ -16,6 +16,7 @@ #include "mdns_service.h" #include "net_eth.h" #include "nvs_config.h" +#include "state_machine.h" #include "viewport_state.h" static const char *TAG = "http_api"; @@ -273,6 +274,51 @@ static esp_err_t config_post_handler(httpd_req_t *req) return httpd_resp_send(req, NULL, 0); } +// ============================================================================ +// POST /state +// ============================================================================ +static esp_err_t state_post_handler(httpd_req_t *req) +{ + char buf[64]; + if (read_body(req, buf, sizeof(buf)) != ESP_OK) + return respond_400(req, "missing or oversized body"); + + cJSON *root = cJSON_Parse(buf); + if (!root) return respond_400(req, "invalid JSON"); + + cJSON *j = cJSON_GetObjectItemCaseSensitive(root, "state"); + if (!cJSON_IsString(j)) { + cJSON_Delete(root); + return respond_400(req, "state must be 'wake' or 'sleep'"); + } + + viewport_run_state_t target; + if (strcmp(j->valuestring, "wake") == 0) { + target = VIEWPORT_STATE_AWAKE; + } else if (strcmp(j->valuestring, "sleep") == 0) { + target = VIEWPORT_STATE_ASLEEP; + } else { + cJSON_Delete(root); + return respond_400(req, "state must be 'wake' or 'sleep'"); + } + cJSON_Delete(root); + + esp_err_t err = state_machine_set(target); + if (err == ESP_ERR_INVALID_STATE) { + httpd_resp_set_status(req, "409 Conflict"); + httpd_resp_set_type(req, "text/plain"); + return httpd_resp_send(req, "device unconfigured", HTTPD_RESP_USE_STRLEN); + } + if (err != ESP_OK) { + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_set_type(req, "text/plain"); + return httpd_resp_send(req, esp_err_to_name(err), HTTPD_RESP_USE_STRLEN); + } + + httpd_resp_set_status(req, "204 No Content"); + return httpd_resp_send(req, NULL, 0); +} + // ============================================================================ // POST /frame // ============================================================================ @@ -312,6 +358,12 @@ static esp_err_t frame_post_handler(httpd_req_t *req) return respond_status(req, "500 Internal Server Error", "display not initialized"); } + // /frame requires AWAKE. Asleep / unconfigured → 409. + if (state_machine_current() != VIEWPORT_STATE_AWAKE) { + return respond_status(req, "409 Conflict", + "device asleep — POST /state {\"state\":\"wake\"} first"); + } + // Single in-flight frame. Concurrent posts get 503 (spec). if (!jpeg_decoder_try_lock(0)) { return respond_status(req, "503 Service Unavailable", "frame in flight"); @@ -368,6 +420,8 @@ static esp_err_t frame_post_handler(httpd_req_t *req) jpeg_decoder_unlock(); + state_machine_frame_painted(); // reset idle timer + httpd_resp_set_status(req, "204 No Content"); return httpd_resp_send(req, NULL, 0); } @@ -384,6 +438,9 @@ static const httpd_uri_t s_config_get = { static const httpd_uri_t s_config_post = { .uri = "/config", .method = HTTP_POST, .handler = config_post_handler, }; +static const httpd_uri_t s_state_post = { + .uri = "/state", .method = HTTP_POST, .handler = state_post_handler, +}; static const httpd_uri_t s_frame_post = { .uri = "/frame", .method = HTTP_POST, .handler = frame_post_handler, }; @@ -403,10 +460,12 @@ esp_err_t http_api_start(void) TAG, "register GET /config"); ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &s_config_post), TAG, "register POST /config"); + ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &s_state_post), + TAG, "register POST /state"); ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &s_frame_post), TAG, "register POST /frame"); ESP_LOGI(TAG, "http server listening on :80 " - "(GET /state, GET/POST /config, POST /frame)"); + "(GET/POST /state, GET/POST /config, POST /frame)"); return ESP_OK; } -- cgit v1.2.3