src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--TESTING.md284
-rw-r--r--main/CMakeLists.txt2
-rw-r--r--main/app_main.c15
-rw-r--r--main/http_api.c93
-rw-r--r--main/http_api.h5
-rw-r--r--main/idf_component.yml17
-rw-r--r--main/mdns_service.c65
-rw-r--r--main/mdns_service.h9
-rw-r--r--main/viewport_state.c46
-rw-r--r--main/viewport_state.h49
10 files changed, 579 insertions, 6 deletions
diff --git a/TESTING.md b/TESTING.md
new file mode 100644
index 0000000..dde0cdc
--- /dev/null
+++ b/TESTING.md
@@ -0,0 +1,284 @@
+# Testing & Verification
+
+This file tracks which milestones have been verified on hardware and how. Per-milestone entries below each list:
+
+- **Acceptance** β€” what "done" means (mirrors the impl guide).
+- **How to verify** β€” exact commands or actions.
+- **Status** β€” ⬜ not verified / 🟑 partial / βœ… verified, with a one-line note (date, board rev, anything weird).
+
+Hardware-required milestones can't be verified by a clean build alone β€” a checked-in build success counts as 🟑 (compiles, not yet run).
+
+---
+
+## M1 β€” Board Bring-Up
+
+**Acceptance**: device gets a DHCP lease over Ethernet and logs its IP.
+
+**How to verify**
+
+```bash
+source ~/Dev/code/git/esp32/env.sh
+idf.py -p /dev/cu.usbmodem* flash monitor
+```
+
+Expected log within ~5–10s of boot:
+
+```
+I (xxx) viewport: Scrypted Viewport boot
+I (xxx) net_eth: ethernet driver started, waiting for link + DHCP
+I (xxx) net_eth: link up, mac xx:xx:xx:xx:xx:xx
+I (xxx) net_eth: got ip 192.168.x.x gw 192.168.x.1 netmask 255.255.255.0
+I (xxx) viewport: online at 192.168.x.x
+```
+
+Cross-check from another host:
+
+```bash
+ping 192.168.x.x
+```
+
+**Status**: 🟑 builds clean against ESP-IDF 5.4 (commit e2ac22e). Not yet flashed to hardware β€” awaiting first board run.
+
+> Combined hardware verification of M1+M2 is fine β€” they both run from the same flash session.
+
+---
+
+## M2 β€” HTTP + mDNS
+
+**Acceptance**: `GET /state` returns JSON; mDNS service is discoverable.
+
+**How to verify**
+
+After flash, from a host on the same LAN:
+
+```bash
+# Direct call by IP (always works):
+curl http://<device-ip>/state | jq .
+
+# mDNS hostname (requires OS-level .local resolution: macOS Bonjour, Linux nss-mdns):
+curl http://viewport.local/state | jq .
+
+# Service discovery browse (preferred β€” what Scrypted will do, no OS .local resolver needed):
+dns-sd -B _scrypted-viewport._tcp local. # macOS
+avahi-browse -r _scrypted-viewport._tcp # Linux
+```
+
+Expected `/state` body on a fresh device (unconfigured):
+
+```json
+{
+ "name": null,
+ "version": "0.1.0",
+ "configured": false,
+ "state": "unconfigured",
+ "uptime_ms": 12345,
+ "last_frame_ms_ago": null,
+ "frames_received": 0,
+ "decode_errors": 0,
+ "state_post_failures": 0,
+ "resolution": "480x800",
+ "ip": "192.168.x.x",
+ "free_heap": 200000,
+ "free_psram": 30000000
+}
+```
+
+Expected mDNS browse output should show a `_scrypted-viewport._tcp` instance with TXT records `version=`, `resolution=`, `orientation=`, `name=` (empty for `name`).
+
+**Status**: 🟑 builds clean against ESP-IDF 5.4 with espressif/mdns managed component. Awaiting first board run.
+
+---
+
+## M3 β€” Display Bring-Up
+
+**Acceptance**: panel shows a test pattern; backlight toggles via API.
+
+**How to verify**: TBD (write after M3 implementation).
+
+**Status**: ⬜ pending.
+
+---
+
+## M4 β€” Config Persistence
+
+**Acceptance**: `POST /config` persists across reboot; partial-update semantics work.
+
+**How to verify**
+
+```bash
+# Full config:
+curl -X POST -H "Content-Type: application/json" \
+ -d '{"viewport":"mudroom","scrypted":"http://host/endpoint/scrypted-viewport","orientation":"landscape"}' \
+ http://<device-ip>/config
+
+# Read back:
+curl http://<device-ip>/config | jq .
+
+# Partial update (only brightness):
+curl -X POST -H "Content-Type: application/json" \
+ -d '{"brightness":50}' \
+ http://<device-ip>/config
+
+# Reboot, then re-read β€” values should survive:
+curl http://<device-ip>/config | jq .
+```
+
+Also verify validation: `idle_timeout_ms: 1000` (below 5000) returns 400; `orientation: "sideways"` returns 400; etc.
+
+**Status**: ⬜ pending.
+
+---
+
+## M5 β€” JPEG Frame Push
+
+**Acceptance**: `POST /frame` paints an 800x480 (or 480x800 portrait) JPEG.
+
+**How to verify**
+
+```bash
+# After /config sets state=wake (M6 dep β€” for now test post-/wake or hardcode awake):
+curl -X POST -H "Content-Type: image/jpeg" \
+ --data-binary @test-480x800.jpg \
+ http://<device-ip>/frame
+```
+
+Visual: matching pattern appears on the panel. Re-test in landscape orientation with `test-800x480.jpg`.
+
+Negative tests:
+- Non-JPEG body β†’ 400.
+- 1.1 MB body β†’ 413.
+- 640x480 body (wrong size) β†’ 400 or visible distortion (depends on decoder strictness).
+
+**Status**: ⬜ pending.
+
+---
+
+## M6 β€” State + Idle Timer
+
+**Acceptance**: `POST /state` toggles wake/sleep; `/frame` is 409 when asleep; idle timer fires after `idle_timeout_ms`.
+
+**How to verify**
+
+```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
+
+# /frame while asleep:
+curl -X POST -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
+
+# Idle timer (assuming default 60000ms):
+curl -X POST -d '{"state":"wake"}' http://<device-ip>/state
+# wait 65s without sending /frame
+curl http://<device-ip>/state | jq .state
+# expect: "asleep"
+```
+
+**Status**: ⬜ pending.
+
+---
+
+## M7 β€” Touch + Outbound `/state` POST
+
+**Acceptance**: tap toggles wake/sleep locally; device POSTs `{viewport,state}` to `<scrypted>/state`.
+
+**How to verify**
+
+Stand up a test HTTP receiver:
+
+```bash
+# In a separate terminal on Scrypted host:
+python3 -m http.server 11080 # or a small flask app that logs body
+```
+
+Configure the device to point at it:
+
+```bash
+curl -X POST -H "Content-Type: application/json" \
+ -d '{"viewport":"mudroom","scrypted":"http://<host>:11080"}' \
+ http://<device-ip>/config
+```
+
+Then:
+- Tap while asleep β†’ device wakes, receiver logs `POST /state {"viewport":"mudroom","state":"wake"}`.
+- Tap while awake β†’ device sleeps, receiver logs `POST /state {"viewport":"mudroom","state":"sleep"}`.
+- Wait `idle_timeout_ms` with no `/frame` β†’ receiver logs same sleep POST.
+- Kill receiver, tap β†’ `/state` `state_post_failures` increments.
+
+**Status**: ⬜ pending.
+
+---
+
+## M8 β€” Local Screens + BOOT button
+
+**Acceptance**: IP screen on first boot; loading screen on every wake; BOOT button works.
+
+**How to verify**
+
+- Fresh flash β†’ screen shows `viewport.local` and the device IP centered.
+- `POST /config` β†’ IP screen clears; backlight off (device enters sleep).
+- Tap while asleep β†’ "Loading…" screen until next `/frame`.
+- BOOT short-press (any state) β†’ IP screen overlays for 15s, then prior state restored.
+- BOOT 5s-hold β†’ NVS clears, device reboots, IP screen reappears.
+
+**Status**: ⬜ pending.
+
+---
+
+## M9 β€” Live Stream (`POST /stream`)
+
+**Acceptance**: β‰₯ 10 fps multipart MJPEG over a single chunked POST.
+
+**How to verify**: ffmpeg-driven test stream from a host, measure fps from the device's frame counter.
+
+**Status**: ⬜ pending.
+
+---
+
+## Integration & system tests (post-M9)
+
+Run these once all milestones are implemented and individually verified. The point is to exercise races, edge cases, and longevity that single-milestone tests miss.
+
+### A. End-to-end with Scrypted
+
+- Real Scrypted-side Script (M7's scope): doorbell-event β†’ `/state {wake}` β†’ frame stream β†’ idle timer fires β†’ `/state {sleep}` callback β†’ Scrypted stops.
+- Bound camera switching: change the binding in Scrypted, confirm next wake routes to the new camera.
+
+### B. Race conditions
+
+- Concurrent tap-while-Scrypted-POSTs-`/state`: spam both, confirm the device converges (mutex serializes; last-write-wins). Use the wake/sleep counters in `/state` to track transitions.
+- Mid-stream tap-to-sleep: while Scrypted is streaming frames, tap to sleep. Inflight `/frame` should return 409. Confirm no half-painted frames, no re-wake.
+- Idle-timeout coincides with a fresh tap-wake: the second event wins. Verify by repeating with timing jitter.
+- Stale Scrypted sleep timer races a fresh wake callback: Scrypted-side `cancelPendingSleep` must work β€” if a stale sleep lands after a wake, viewport sleeps; user taps again; recovery in one extra tap.
+
+### C. Failure modes
+
+- Cable pull mid-frame: device idle-sleeps after `idle_timeout_ms`. On reconnect, mDNS re-advertises; Scrypted re-finds and continues.
+- Scrypted unreachable on tap: device still toggles backlight, `state_post_failures` increments. Recovery: Scrypted comes back, next tap syncs.
+- DHCP lease change: device gets new IP, re-advertises via mDNS. Scrypted's periodic browse picks up the new address.
+- `/state_post_failures` count should be observable via `GET /state`.
+
+### D. Longevity
+
+- 24h soak with a slow camera stream (~1 frame every 10s): verify no memory leaks (`free_heap` / `free_psram` stable in `/state`), no decode_errors growing.
+- Frame storm: 30 minutes at max sustainable fps. Verify watchdog never fires, no resets.
+
+### E. Power & boot
+
+- PoE injector cycle: device boots clean, gets DHCP, re-registers via mDNS, no manual intervention needed.
+- Brown-out (PoE on edge of spec): device should reboot cleanly, not corrupt NVS.
+
+### F. Negative protocol
+
+- Garbage JSON to `/config` β†’ 400.
+- `/frame` with `Content-Type: image/png` β†’ 400.
+- `/state` with `{"state":"middle"}` β†’ 400.
+- `/frame` with body > 1 MB β†’ 413.
+- Two concurrent `/frame` posts β†’ one wins, the other 503.
+
+### G. Multi-viewport
+
+Once at least two physical units are configured: confirm each routes its callback to Scrypted with its own `viewport` name; confirm Scrypted's discovery map has both IPs; confirm a camera event on the wrong-named viewport is correctly ignored on the right one.
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt
index ee5cb24..ad43a53 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -1,5 +1,5 @@
idf_component_register(
SRC_DIRS "."
INCLUDE_DIRS "."
- REQUIRES esp_eth esp_event esp_netif nvs_flash
+ REQUIRES esp_eth esp_event esp_netif esp_http_server esp_timer json mdns nvs_flash
)
diff --git a/main/app_main.c b/main/app_main.c
index 8b91537..9389642 100644
--- a/main/app_main.c
+++ b/main/app_main.c
@@ -1,4 +1,7 @@
+#include "http_api.h"
+#include "mdns_service.h"
#include "net_eth.h"
+#include "viewport_state.h"
#include "esp_event.h"
#include "esp_log.h"
@@ -13,7 +16,8 @@ void app_main(void)
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
- ESP_LOGI(TAG, "Scrypted Viewport boot");
+ viewport_state_init();
+ ESP_LOGI(TAG, "Scrypted Viewport boot (v%s)", VIEWPORT_VERSION);
ESP_ERROR_CHECK(net_eth_init());
if (net_eth_wait_for_ip(30 * 1000) == ESP_OK) {
@@ -22,12 +26,13 @@ void app_main(void)
ESP_LOGW(TAG, "no DHCP lease after 30s, will keep retrying in the background");
}
- // TODO M2: mDNS _scrypted-viewport._tcp.local
- // TODO M2: HTTP server: GET /state
+ ESP_ERROR_CHECK(mdns_service_start());
+ ESP_ERROR_CHECK(http_api_start());
+
// TODO M3: MIPI-DSI panel init (800x480 IPS, default portrait 480x800)
- // TODO M4: /config persistence (NVS)
+ // TODO M4: /config persistence (NVS) + GET /config + POST /config
// TODO M5: /frame JPEG decode -> framebuffer
- // TODO M6: /state POST + idle timer
+ // TODO M6: POST /state + idle timer
// TODO M7: Capacitive touch -> outbound /state POST
// TODO M8: Local screens (IP, loading) + BOOT button
}
diff --git a/main/http_api.c b/main/http_api.c
new file mode 100644
index 0000000..4c0008d
--- /dev/null
+++ b/main/http_api.c
@@ -0,0 +1,93 @@
+#include "http_api.h"
+
+#include <inttypes.h>
+#include <stdio.h>
+#include <string.h>
+
+#include "cJSON.h"
+#include "esp_check.h"
+#include "esp_heap_caps.h"
+#include "esp_http_server.h"
+#include "esp_log.h"
+#include "esp_timer.h"
+
+#include "net_eth.h"
+#include "viewport_state.h"
+
+static const char *TAG = "http_api";
+
+static const char *state_name(viewport_run_state_t s)
+{
+ switch (s) {
+ case VIEWPORT_STATE_AWAKE: return "awake";
+ case VIEWPORT_STATE_ASLEEP: return "asleep";
+ default: return "unconfigured";
+ }
+}
+
+static esp_err_t state_get_handler(httpd_req_t *req)
+{
+ viewport_state_lock();
+ viewport_state_t *st = viewport_state_get();
+
+ uint64_t now_us = (uint64_t)esp_timer_get_time();
+ uint64_t up_ms = (now_us - st->boot_us) / 1000;
+ int64_t last_age_ms = (st->last_frame_us < 0)
+ ? -1
+ : (int64_t)((now_us - (uint64_t)st->last_frame_us) / 1000);
+
+ cJSON *root = cJSON_CreateObject();
+ cJSON_AddItemToObject(root, "name",
+ st->viewport_name[0] ? cJSON_CreateString(st->viewport_name)
+ : cJSON_CreateNull());
+ cJSON_AddStringToObject(root, "version", VIEWPORT_VERSION);
+ cJSON_AddBoolToObject(root, "configured", st->configured);
+ cJSON_AddStringToObject(root, "state", state_name(st->state));
+ cJSON_AddNumberToObject(root, "uptime_ms", (double)up_ms);
+ cJSON_AddItemToObject(root, "last_frame_ms_ago",
+ last_age_ms < 0 ? cJSON_CreateNull()
+ : cJSON_CreateNumber((double)last_age_ms));
+ cJSON_AddNumberToObject(root, "frames_received", (double)st->frames_received);
+ cJSON_AddNumberToObject(root, "decode_errors", (double)st->decode_errors);
+ cJSON_AddNumberToObject(root, "state_post_failures", (double)st->state_post_failures);
+ cJSON_AddStringToObject(root, "resolution", viewport_state_resolution_str());
+ cJSON_AddStringToObject(root, "ip", net_eth_get_ip_str());
+ cJSON_AddNumberToObject(root, "free_heap",
+ (double)heap_caps_get_free_size(MALLOC_CAP_INTERNAL));
+ cJSON_AddNumberToObject(root, "free_psram",
+ (double)heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
+
+ viewport_state_unlock();
+
+ char *body = cJSON_PrintUnformatted(root);
+ cJSON_Delete(root);
+ if (!body) return ESP_ERR_NO_MEM;
+
+ httpd_resp_set_type(req, "application/json");
+ esp_err_t err = httpd_resp_send(req, body, HTTPD_RESP_USE_STRLEN);
+ cJSON_free(body);
+ return err;
+}
+
+static const httpd_uri_t s_state_get = {
+ .uri = "/state",
+ .method = HTTP_GET,
+ .handler = state_get_handler,
+ .user_ctx = NULL,
+};
+
+esp_err_t http_api_start(void)
+{
+ httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
+ cfg.server_port = 80;
+ cfg.max_uri_handlers = 8;
+ cfg.lru_purge_enable = true;
+
+ httpd_handle_t server = NULL;
+ ESP_RETURN_ON_ERROR(httpd_start(&server, &cfg), TAG, "httpd_start");
+ ESP_RETURN_ON_ERROR(httpd_register_uri_handler(server, &s_state_get),
+ TAG, "register /state");
+
+ ESP_LOGI(TAG, "http server listening on :80 (GET /state)");
+ return ESP_OK;
+}
diff --git a/main/http_api.h b/main/http_api.h
new file mode 100644
index 0000000..8d1a9ae
--- /dev/null
+++ b/main/http_api.h
@@ -0,0 +1,5 @@
+#pragma once
+
+#include "esp_err.h"
+
+esp_err_t http_api_start(void);
diff --git a/main/idf_component.yml b/main/idf_component.yml
new file mode 100644
index 0000000..e5dcd75
--- /dev/null
+++ b/main/idf_component.yml
@@ -0,0 +1,17 @@
+## IDF Component Manager Manifest File
+dependencies:
+ ## Required IDF version
+ idf:
+ version: '>=4.1.0'
+ # # Put list of dependencies here
+ # # For components maintained by Espressif:
+ # component: "~1.0.0"
+ # # For 3rd party components:
+ # username/component: ">=1.0.0,<2.0.0"
+ # username2/component2:
+ # version: "~1.0.0"
+ # # For transient dependencies `public` flag can be set.
+ # # `public` flag doesn't have an effect dependencies of the `main` component.
+ # # All dependencies of `main` are public by default.
+ # public: true
+ espressif/mdns: '*'
diff --git a/main/mdns_service.c b/main/mdns_service.c
new file mode 100644
index 0000000..761ade5
--- /dev/null
+++ b/main/mdns_service.c
@@ -0,0 +1,65 @@
+#include "mdns_service.h"
+
+#include <stdio.h>
+#include <string.h>
+
+#include "esp_check.h"
+#include "esp_log.h"
+#include "mdns.h"
+
+#include "viewport_state.h"
+
+static const char *TAG = "mdns";
+static const char *SERVICE = "_scrypted-viewport";
+static const char *PROTO = "_tcp";
+static const uint16_t PORT = 80;
+
+static esp_err_t apply_records(void)
+{
+ viewport_state_lock();
+ viewport_state_t *st = viewport_state_get();
+
+ char hostname[80];
+ if (st->viewport_name[0]) {
+ snprintf(hostname, sizeof(hostname), "viewport-%s", st->viewport_name);
+ } else {
+ snprintf(hostname, sizeof(hostname), "viewport");
+ }
+
+ const char *resolution = viewport_state_resolution_str();
+ const char *orientation = (st->orientation == VIEWPORT_ORIENTATION_PORTRAIT)
+ ? "portrait" : "landscape";
+ const char *name_value = st->viewport_name[0] ? st->viewport_name : "";
+
+ viewport_state_unlock();
+
+ mdns_txt_item_t txt[] = {
+ { "version", VIEWPORT_VERSION },
+ { "resolution", resolution },
+ { "orientation", orientation },
+ { "name", name_value },
+ };
+ const size_t txt_count = sizeof(txt) / sizeof(txt[0]);
+
+ ESP_RETURN_ON_ERROR(mdns_hostname_set(hostname), TAG, "hostname_set");
+ ESP_RETURN_ON_ERROR(mdns_service_txt_set(SERVICE, PROTO, txt, txt_count),
+ TAG, "txt_set");
+
+ ESP_LOGI(TAG, "advertising %s.local on %s.%s.local:%u",
+ hostname, SERVICE, PROTO, PORT);
+ return ESP_OK;
+}
+
+esp_err_t mdns_service_start(void)
+{
+ ESP_RETURN_ON_ERROR(mdns_init(), TAG, "mdns_init");
+ ESP_RETURN_ON_ERROR(
+ mdns_service_add(NULL, SERVICE, PROTO, PORT, NULL, 0),
+ TAG, "service_add");
+ return apply_records();
+}
+
+esp_err_t mdns_service_refresh(void)
+{
+ return apply_records();
+}
diff --git a/main/mdns_service.h b/main/mdns_service.h
new file mode 100644
index 0000000..1e7eb12
--- /dev/null
+++ b/main/mdns_service.h
@@ -0,0 +1,9 @@
+#pragma once
+
+#include "esp_err.h"
+
+esp_err_t mdns_service_start(void);
+
+// Update the mDNS hostname and TXT records to reflect the current
+// viewport name / orientation. Call after /config writes either field.
+esp_err_t mdns_service_refresh(void);
diff --git a/main/viewport_state.c b/main/viewport_state.c
new file mode 100644
index 0000000..9115ce3
--- /dev/null
+++ b/main/viewport_state.c
@@ -0,0 +1,46 @@
+#include "viewport_state.h"
+
+#include <string.h>
+
+#include "esp_timer.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/semphr.h"
+
+static viewport_state_t s_state;
+static SemaphoreHandle_t s_mutex;
+
+void viewport_state_init(void)
+{
+ memset(&s_state, 0, sizeof(s_state));
+ s_state.configured = false;
+ s_state.state = VIEWPORT_STATE_UNCONFIGURED;
+ s_state.brightness = 80;
+ s_state.idle_timeout_ms = 60000;
+ s_state.orientation = VIEWPORT_ORIENTATION_PORTRAIT;
+ s_state.boot_us = (uint64_t)esp_timer_get_time();
+ s_state.last_frame_us = -1;
+
+ s_mutex = xSemaphoreCreateMutex();
+}
+
+viewport_state_t *viewport_state_get(void)
+{
+ return &s_state;
+}
+
+void viewport_state_lock(void)
+{
+ xSemaphoreTake(s_mutex, portMAX_DELAY);
+}
+
+void viewport_state_unlock(void)
+{
+ xSemaphoreGive(s_mutex);
+}
+
+const char *viewport_state_resolution_str(void)
+{
+ return (s_state.orientation == VIEWPORT_ORIENTATION_PORTRAIT)
+ ? "480x800"
+ : "800x480";
+}
diff --git a/main/viewport_state.h b/main/viewport_state.h
new file mode 100644
index 0000000..b580ff7
--- /dev/null
+++ b/main/viewport_state.h
@@ -0,0 +1,49 @@
+#pragma once
+
+#include <stdbool.h>
+#include <stdint.h>
+
+#define VIEWPORT_VERSION "0.1.0"
+#define VIEWPORT_PANEL_WIDTH 800
+#define VIEWPORT_PANEL_HEIGHT 480
+
+typedef enum {
+ VIEWPORT_ORIENTATION_PORTRAIT = 0, // effective: 480x800
+ VIEWPORT_ORIENTATION_LANDSCAPE, // effective: 800x480
+} viewport_orientation_t;
+
+typedef enum {
+ VIEWPORT_STATE_UNCONFIGURED = 0,
+ VIEWPORT_STATE_AWAKE,
+ VIEWPORT_STATE_ASLEEP,
+} viewport_run_state_t;
+
+// Read-mostly snapshot of device state used by /state. Counters are
+// updated under a mutex by the modules that own them.
+typedef struct {
+ char viewport_name[64]; // empty before /config
+ char scrypted_url[256]; // empty before /config
+ bool configured;
+ viewport_run_state_t state;
+
+ uint8_t brightness; // 0–100, default 80
+ uint32_t idle_timeout_ms; // 0 = disabled, else >= 5000, default 60000
+ viewport_orientation_t orientation; // default portrait
+
+ uint64_t boot_us; // esp_timer_get_time() at boot
+ int64_t last_frame_us; // -1 if no frame received yet
+
+ uint64_t frames_received;
+ uint64_t decode_errors;
+ uint64_t state_post_failures;
+} viewport_state_t;
+
+void viewport_state_init(void);
+viewport_state_t *viewport_state_get(void);
+void viewport_state_lock(void);
+void viewport_state_unlock(void);
+
+// Returns the effective resolution string ("480x800" or "800x480") based
+// on the current orientation. Caller-owned static storage; safe to read
+// without the lock (atomic snapshot).
+const char *viewport_state_resolution_str(void);