diff options
| author | Luke Hoersten <[email protected]> | 2026-06-13 22:52:41 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-06-13 22:52:41 -0500 |
| commit | 57c93fd2978ed5b5e64f4620803b7a2cd7767ed2 (patch) | |
| tree | 545d3a4ed3ee42cc87d4bb78ba9321eeadec19b2 | |
| parent | 6cbdd4ed7466da28e524c9e0804722428a4b9698 (diff) | |
M6: state machine — POST /state, idle timer, /frame 409 guard
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 <scrypted>/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.
| -rw-r--r-- | TESTING.md | 105 | ||||
| -rw-r--r-- | main/app_main.c | 27 | ||||
| -rw-r--r-- | main/http_api.c | 61 | ||||
| -rw-r--r-- | main/state_machine.c | 89 | ||||
| -rw-r--r-- | main/state_machine.h | 20 |
5 files changed, 285 insertions, 17 deletions
@@ -274,25 +274,112 @@ After every error: `decode_errors` in `GET /state` should increment. **How to verify** +First make sure the device is configured (M4 must be done): + +```bash +curl -X POST -H "Content-Type: application/json" \ + -d '{"viewport":"mudroom","scrypted":"http://host/cb","idle_timeout_ms":10000}' \ + http://<device-ip>/config +``` + +Wake / sleep: + ```bash -curl -X POST -d '{"state":"sleep"}' http://<device-ip>/state # backlight off -curl -X POST -d '{"state":"wake"}' http://<device-ip>/state # backlight on, loading +# Backlight off: +curl -i -X POST -H "Content-Type: application/json" \ + -d '{"state":"sleep"}' http://<device-ip>/state +# expect: 204 + +# Backlight on (loading-screen placeholder until M8): +curl -i -X POST -H "Content-Type: application/json" \ + -d '{"state":"wake"}' http://<device-ip>/state +# expect: 204 + +# Idempotency: repeating either is also 204 with no side effects: +curl -X POST -H "Content-Type: application/json" \ + -d '{"state":"wake"}' http://<device-ip>/state +curl -X POST -H "Content-Type: application/json" \ + -d '{"state":"wake"}' http://<device-ip>/state +# Both: 204 +``` + +`/frame` while asleep returns 409: -# /frame while asleep: -curl -X POST -d '{"state":"sleep"}' http://<device-ip>/state +```bash +curl -X POST -H "Content-Type: application/json" \ + -d '{"state":"sleep"}' http://<device-ip>/state curl -i -X POST -H "Content-Type: image/jpeg" \ --data-binary @test-480x800.jpg \ http://<device-ip>/frame -# expect: HTTP/1.1 409 Conflict +# expect: HTTP/1.1 409 Conflict, body "device asleep — POST /state ..." +``` -# Idle timer (assuming default 60000ms): -curl -X POST -d '{"state":"wake"}' http://<device-ip>/state -# wait 65s without sending /frame +Idle timer: + +```bash +curl -X POST -H "Content-Type: application/json" \ + -d '{"state":"wake"}' http://<device-ip>/state +sleep 12 # idle_timeout_ms was set to 10000 above curl http://<device-ip>/state | jq .state # expect: "asleep" ``` -**Status**: ⬜ pending. +When the timer fires, the serial log should print: + +``` +I (xxx) state: idle timer expired — sleeping +I (xxx) state: ASLEEP +``` + +Disabling the idle timer: + +```bash +curl -X POST -H "Content-Type: application/json" \ + -d '{"idle_timeout_ms":0}' http://<device-ip>/config +curl -X POST -H "Content-Type: application/json" \ + -d '{"state":"wake"}' http://<device-ip>/state +sleep 70 +curl http://<device-ip>/state | jq .state +# expect: still "awake" +``` + +Resetting on `/frame`: every successful paint restarts the idle timer. + +```bash +# Set short idle, then keep painting to keep awake: +curl -X POST -d '{"idle_timeout_ms":10000}' \ + -H "Content-Type: application/json" http://<device-ip>/config +curl -X POST -d '{"state":"wake"}' \ + -H "Content-Type: application/json" http://<device-ip>/state +for i in 1 2 3 4 5; do + sleep 8 + curl -X POST -H "Content-Type: image/jpeg" \ + --data-binary @test-480x800.jpg http://<device-ip>/frame +done +curl http://<device-ip>/state | jq .state # expect "awake" +``` + +Unconfigured device rejects `POST /state` with 409: + +```bash +# (After factory reset / fresh boot, no /config yet) +curl -i -X POST -H "Content-Type: application/json" \ + -d '{"state":"wake"}' http://<device-ip>/state +# expect: 409 Conflict "device unconfigured" +``` + +Bad input: + +```bash +curl -i -X POST -d '{"state":"middle"}' \ + -H "Content-Type: application/json" http://<device-ip>/state # 400 +curl -i -X POST -d 'not json' \ + -H "Content-Type: application/json" http://<device-ip>/state # 400 +``` + +**Known gap (M7 closes this)**: when the idle timer fires the device transitions to ASLEEP locally but does NOT yet POST `{viewport,state:sleep}` to `<scrypted>/state`. That outbound POST lands with `state_client` in M7. + +**Status**: 🟡 builds clean against ESP-IDF 5.4. Awaiting hardware. --- diff --git a/main/app_main.c b/main/app_main.c index 4ac925d..126bf15 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -4,6 +4,7 @@ #include "mdns_service.h" #include "net_eth.h" #include "nvs_config.h" +#include "state_machine.h" #include "viewport_state.h" #include "esp_event.h" @@ -30,25 +31,37 @@ void app_main(void) ESP_LOGW(TAG, "no DHCP lease after 30s, will keep retrying in the background"); } + ESP_ERROR_CHECK(state_machine_init()); ESP_ERROR_CHECK(mdns_service_start()); ESP_ERROR_CHECK(http_api_start()); // Display is best-effort — a missing/miswired panel must not kill - // networking + /state. M3 acceptance: show a test pattern. + // networking + /state. if (display_init() == ESP_OK) { - display_test_pattern(); - ESP_LOGI(TAG, "display up — test pattern on screen"); + // Reconcile panel with current run-state: + // UNCONFIGURED -> placeholder test pattern (M8 replaces with IP screen) + // ASLEEP -> backlight off (configured device booted asleep) + // AWAKE -> leave on (shouldn't happen on a fresh boot) + viewport_state_lock(); + viewport_run_state_t s = viewport_state_get()->state; + viewport_state_unlock(); + + if (s == VIEWPORT_STATE_ASLEEP) { + display_sleep(); + ESP_LOGI(TAG, "display up — configured, backlight off (asleep)"); + } else { + display_test_pattern(); + ESP_LOGI(TAG, "display up — test pattern (unconfigured)"); + } } else { ESP_LOGW(TAG, "display init failed — continuing without panel"); } - // JPEG decoder is the M5 dependency for POST /frame. Best-effort: - // if it fails (e.g. hardware not present in sim) /frame just returns 500. + // JPEG decoder is the M5 dependency for POST /frame. Best-effort. if (jpeg_decoder_init() != ESP_OK) { ESP_LOGW(TAG, "jpeg decoder init failed — /frame will be unavailable"); } - // TODO M6: POST /state + idle timer - // TODO M7: Capacitive touch -> outbound /state POST + // TODO M7: Capacitive touch -> outbound /state POST to <scrypted>/state // TODO M8: Local screens (IP, loading) + BOOT button } 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"; @@ -274,6 +275,51 @@ static esp_err_t config_post_handler(httpd_req_t *req) } // ============================================================================ +// 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 // ============================================================================ static esp_err_t respond_status(httpd_req_t *req, const char *status, const char *body) @@ -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; } diff --git a/main/state_machine.c b/main/state_machine.c new file mode 100644 index 0000000..22f3f56 --- /dev/null +++ b/main/state_machine.c @@ -0,0 +1,89 @@ +#include "state_machine.h" + +#include "esp_check.h" +#include "esp_log.h" +#include "esp_timer.h" + +#include "display.h" + +static const char *TAG = "state"; + +static esp_timer_handle_t s_idle_timer; + +static void arm_idle_timer_unlocked(void) +{ + viewport_state_lock(); + uint32_t ms = viewport_state_get()->idle_timeout_ms; + viewport_state_unlock(); + if (ms == 0) return; + esp_timer_stop(s_idle_timer); + esp_timer_start_once(s_idle_timer, (uint64_t)ms * 1000ULL); +} + +static void disarm_idle_timer(void) +{ + esp_timer_stop(s_idle_timer); +} + +static void idle_timer_fired(void *arg) +{ + ESP_LOGI(TAG, "idle timer expired — sleeping"); + state_machine_set(VIEWPORT_STATE_ASLEEP); + // TODO M7: state_client_post(VIEWPORT_STATE_ASLEEP); // tell Scrypted +} + +esp_err_t state_machine_init(void) +{ + esp_timer_create_args_t args = { + .callback = &idle_timer_fired, + .name = "viewport_idle", + }; + return esp_timer_create(&args, &s_idle_timer); +} + +esp_err_t state_machine_set(viewport_run_state_t target) +{ + if (target != VIEWPORT_STATE_AWAKE && target != VIEWPORT_STATE_ASLEEP) + return ESP_ERR_INVALID_ARG; + + viewport_state_lock(); + viewport_state_t *st = viewport_state_get(); + + if (!st->configured) { + viewport_state_unlock(); + return ESP_ERR_INVALID_STATE; + } + if (st->state == target) { + viewport_state_unlock(); + return ESP_OK; // idempotent no-op + } + st->state = target; + viewport_state_unlock(); + + if (target == VIEWPORT_STATE_AWAKE) { + if (display_is_up()) display_wake(); + arm_idle_timer_unlocked(); + ESP_LOGI(TAG, "AWAKE"); + } else { + disarm_idle_timer(); + if (display_is_up()) display_sleep(); + ESP_LOGI(TAG, "ASLEEP"); + } + return ESP_OK; +} + +void state_machine_frame_painted(void) +{ + viewport_state_lock(); + bool awake = (viewport_state_get()->state == VIEWPORT_STATE_AWAKE); + viewport_state_unlock(); + if (awake) arm_idle_timer_unlocked(); +} + +viewport_run_state_t state_machine_current(void) +{ + viewport_state_lock(); + viewport_run_state_t s = viewport_state_get()->state; + viewport_state_unlock(); + return s; +} diff --git a/main/state_machine.h b/main/state_machine.h new file mode 100644 index 0000000..176b93e --- /dev/null +++ b/main/state_machine.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esp_err.h" +#include "viewport_state.h" + +esp_err_t state_machine_init(void); + +// Transition to AWAKE or ASLEEP. Idempotent: no-op if already there. +// Returns ESP_ERR_INVALID_STATE if the device is unconfigured. +// Side-effects (under one critical section): +// AWAKE -> backlight on, idle timer (re)armed +// ASLEEP -> backlight off, idle timer cancelled, framebuffer discarded +esp_err_t state_machine_set(viewport_run_state_t target); + +// Called by /frame after a successful paint. Restarts the idle timer +// while awake; no-op otherwise. +void state_machine_frame_painted(void); + +// Snapshot of current state. +viewport_run_state_t state_machine_current(void); |
