src.nth.io/

summaryrefslogtreecommitdiff
path: root/main
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-13 22:52:41 -0500
committerLuke Hoersten <[email protected]>2026-06-13 22:52:41 -0500
commit57c93fd2978ed5b5e64f4620803b7a2cd7767ed2 (patch)
tree545d3a4ed3ee42cc87d4bb78ba9321eeadec19b2 /main
parent6cbdd4ed7466da28e524c9e0804722428a4b9698 (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.
Diffstat (limited to 'main')
-rw-r--r--main/app_main.c27
-rw-r--r--main/http_api.c61
-rw-r--r--main/state_machine.c89
-rw-r--r--main/state_machine.h20
4 files changed, 189 insertions, 8 deletions
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);