diff options
| -rw-r--r-- | README.md | 217 | ||||
| -rw-r--r-- | Scrypted-Viewport-v2-Claude-Code-Implementation-Guide.md | 315 |
2 files changed, 367 insertions, 165 deletions
@@ -17,7 +17,7 @@ Design goals: Scrypted owns rendering, overlays, camera selection and interaction logic. -Scrypted Viewport owns Ethernet, JPEG decode, display, touch input and callback delivery. +Scrypted Viewport owns Ethernet, JPEG decode, display, touch input and outbound state-change POSTs. ## Hardware @@ -50,17 +50,31 @@ Scrypted must render JPEGs at the **effective** resolution. The device does not HTTP listen port: `80`. -mDNS service: `_scrypted-viewport._tcp.local` advertised on port 80. +Trust model: LAN-only, no auth, no TLS. Deploy on a trusted VLAN. -mDNS TXT records: -- `version=1.0.0` -- `resolution=<effective>` (e.g. `480x800` for portrait, `800x480` for landscape) -- `orientation=<portrait|landscape>` -- `name=<display name>` (empty until `/config`) +## Discovery -Hostname: `viewport.local` before configuration, `viewport-<display>.local` after. +The ESP32 publishes itself via **mDNS-SD (service discovery)**. Scrypted discovers viewports by browsing the service; it does not need OS-level `.local` hostname resolution. -Trust model: LAN-only, no auth, no TLS. Deploy on a trusted VLAN. +The device runs an mDNS responder (ESP-IDF `mdns` component) that serves all of the following from itself — no external DNS server is involved: + +- **Hostname / A record**: `viewport.local` before configuration, `viewport-<name>.local` after (e.g. `viewport-mudroom.local`). The `viewport-` prefix is a namespace that avoids collisions with other LAN devices. +- **Service advertisement**: `_scrypted-viewport._tcp.local` on port 80. +- **SRV record**: hostname + port. +- **TXT records**: + - `version=1.0.0` + - `resolution=<effective>` (e.g. `480x800` for portrait, `800x480` for landscape) + - `orientation=<portrait|landscape>` + - `name=<viewport name>` (empty until `/config`) + +Scrypted-side discovery flow: + +1. Browse `_scrypted-viewport._tcp.local` via a Node mDNS-SD library (`bonjour-service`, `mdns`, etc.). These libraries talk to the multicast layer directly — they do **not** need OS-level `.local` resolution. +2. Each browse result contains `{name, host, port, addresses, txt}`. Use the **IP** from `addresses`, not the hostname, for all subsequent calls. +3. Match TXT `name=` against the operator's `viewport → camera` config bindings. +4. Re-browse periodically (every few minutes) to catch DHCP renumbering. + +If mDNS-SD is unavailable in the deployment (some Docker setups, certain VLAN configurations), allow the operator to set an explicit `http://<ip>:<port>` per viewport in Scrypted-side config as a fallback. ## API @@ -82,7 +96,7 @@ Returns `200 OK` with JSON: "last_frame_ms_ago": 1234, "frames_received": 4271, "decode_errors": 0, - "callback_failures": 2, + "state_post_failures": 2, "resolution": "480x800", "ip": "192.168.1.42", "free_heap": 123456, @@ -102,7 +116,7 @@ Returns `200 OK` with JSON: - Idempotent. Already-in-that-state calls return `204` and do nothing. - `wake`: backlight on, render loading screen (until the next `/frame`), reset idle timer. - `sleep`: backlight off; framebuffer discarded. -- No callback echo (Scrypted initiated). +- The device does NOT POST back to Scrypted (Scrypted initiated). - Response: `204`. ### GET /config @@ -111,22 +125,22 @@ Returns the persisted config: ```json { - "display": "mudroom", - "callback": "http://scrypted.local:11080/api/viewport/touch", + "viewport": "mudroom", + "scrypted": "http://scrypted.local:11080/endpoint/scrypted-viewport", "idle_timeout_ms": 60000, "orientation": "portrait", "brightness": 80 } ``` -Before first `/config`: returns `200` with an object whose fields are all `null` except defaults (`brightness: 80`, `orientation: "portrait"`, `idle_timeout_ms: 60000`). +Before first `/config`: returns `200` with `viewport` and `scrypted` as `null`; the rest carry their first-boot defaults (`brightness: 80`, `orientation: "portrait"`, `idle_timeout_ms: 60000`). ### POST /config ```json { - "display": "mudroom", - "callback": "http://scrypted.local:11080/api/viewport/touch", + "viewport": "mudroom", + "scrypted": "http://scrypted.local:11080/endpoint/scrypted-viewport", "idle_timeout_ms": 60000, "orientation": "portrait", "brightness": 80 @@ -136,7 +150,7 @@ Before first `/config`: returns `200` with an object whose fields are all `null` - **Partial update**: only fields present in the body are changed; omitted fields keep their current values. The persisted config is replaced atomically with the merged result. - Persisted to NVS, survives reboot. - Idempotent; reposting the same body yields the same state. -- `display` must be non-empty; `callback` must be `http://...`. +- `viewport` must be non-empty; `scrypted` must be `http://...` and is the Scrypted plugin's base URL. The device POSTs state changes to `<scrypted>/state`. - `idle_timeout_ms`: `0` disables the idle timer; non-zero values must be ≥ `5000`. Otherwise `400`. Scrypted should use the same value for its own per-stream timeout so both ends agree, but they time independently — either can end the session. - `orientation`: `portrait` (480x800) or `landscape` (800x480). Default `portrait` on first boot. Changing orientation takes effect immediately, including for the IP and Loading screens. Scrypted must send JPEGs at the new effective resolution after a change. - `brightness`: integer `0`–`100`. Default `80` on first boot. Applied immediately if awake; takes effect on next wake if asleep. PWM is gamma-corrected so the scale is perceptual. @@ -155,7 +169,7 @@ Paints a frame. Does **not** change wake/sleep state. - `Content-Type: image/jpeg`, body is raw JPEG bytes. - Image must match the effective resolution (480x800 portrait, 800x480 landscape) as a baseline JPEG. Device does not scale, rotate, or letterbox JPEG content. - Max size: 1 MB. -- **Requires awake state.** While asleep, returns `409 Conflict` and does not paint. Scrypted must `POST /state {"state":"wake"}` first (or wait for a `wake` callback from a tap). +- **Requires awake state.** While asleep, returns `409 Conflict` and does not paint. Scrypted must `POST /state {"state":"wake"}` first (or wait for a tap-driven `wake` POST from the device). - Resets the idle timer on success. - Single in-flight frame; concurrent posts may be rejected with `503`. - Returns `204` once decoded and pushed to the panel. @@ -163,48 +177,124 @@ Paints a frame. Does **not** change wake/sleep state. ## Wake / Sleep -Wake and sleep couple the device backlight with Scrypted's frame stream. The device owns the state; Scrypted just receives idempotent imperatives in callbacks: +Wake and sleep couple the device backlight with Scrypted's frame stream. The device owns the state. -- `state=wake` → "start streaming to this display now" -- `state=sleep` → "stop streaming to this display now" +Both the device and Scrypted expose the same endpoint, `POST /state`, with the same body shape `{viewport, state}` — they're peers. Either side can push to the other to set state. Repeats are safe; both sides are idempotent. -Scrypted does not track per-viewport state; it acts on the callback and forgets. Repeats are safe. +- `{"viewport": "<name>", "state": "wake"}` → "start streaming to this viewport now" +- `{"viewport": "<name>", "state": "sleep"}` → "stop streaming to this viewport now" + +Each request carries the device's `viewport` name as the routing key. Scrypted does not track per-viewport state across requests; it acts on each and forgets. Transitions: -| Trigger | Resulting state | Callback to Scrypted | +| Trigger | Resulting state | Device → Scrypted `POST /state`? | | --- | --- | --- | -| Tap while asleep | Awake (loading screen) | `state=wake event=tap` | -| Tap while awake | Asleep | `state=sleep event=tap` | -| Idle timer expires | Asleep | `state=sleep event=timeout` | +| Tap while asleep | Awake (loading screen) | `state=wake` | +| Tap while awake | Asleep | `state=sleep` | +| Idle timer expires | Asleep | `state=sleep` | | `POST /state {"state":"wake"}` | Awake (loading screen) | none | | `POST /state {"state":"sleep"}` | Asleep | none | | `POST /frame` | (no state change) | — | -`/frame` never changes state. Scrypted must `POST /state {"state":"wake"}` (or wait for a tap-driven callback) before sending frames. This makes the protocol race-free: a `/frame` arriving after a tap-to-sleep is rejected with `409`, not silently re-woken. +`/frame` never changes state. Scrypted must `POST /state {"state":"wake"}` (or wait for a tap-driven `wake` POST from the device) before sending frames. This makes the protocol race-free: a `/frame` arriving after a tap-to-sleep is rejected with `409`, not silently re-woken. Only `tap` is detected on the touchscreen — long-press and swipes are out of scope for v1. `tap` itself is internal; what Scrypted sees is the resulting `state`. -Callback body: +### Device → Scrypted `POST /state` + +When the device changes state on its own (a tap, the idle timer firing), it POSTs to Scrypted's `/state` endpoint. Same shape as Scrypted POSTing to the device's `/state`; no `event`, no `type`, no callback semantics. The two endpoints are peers, not request/response. + +**Request** + +``` +POST <scrypted>/state HTTP/1.1 +Host: <derived from URL> +Content-Type: application/json +Content-Length: <body length> +User-Agent: ScryptedViewport/<version> +Connection: close +``` + +Body: ```json -{ - "display": "mudroom", - "state": "wake", - "event": "tap" -} +{ "viewport": "mudroom", "state": "wake" } +``` + +or + +```json +{ "viewport": "mudroom", "state": "sleep" } ``` -- `state`: `wake` or `sleep` — the resulting state, also the imperative for Scrypted. -- `event`: the cause. `tap` (any user tap) or `timeout` (idle expiry, sleep only). Future events (e.g. `swipe_left`) can be added without schema changes. Scrypted may ignore `event` in v1. +Same body as Scrypted → Device `POST /state`, plus the routing key. + +- `viewport` (string): the value of the `viewport` field in the device's `/config`. Scrypted's routing key. +- `state` (string): `wake` or `sleep`. The resulting state and the imperative for Scrypted. + +No other fields. No timestamp (no RTC, no SNTP); Scrypted timestamps on receipt. + +**No application-level ack** + +HTTP 2xx is transport-level only. There is no application-level ack: the device does not retry, does not block subsequent state changes on the response, and does not treat a 5xx response as anything more than a counter increment. Idempotency + `/frame` returning `409` + each side's independent idle timer recover every failure mode without an ack: + +- Scrypted misses the device's `sleep`: its own per-stream timer eventually stops the stream, or the next `/frame` it sends returns `409`. +- Device misses Scrypted's `wake`/`sleep`: same — the next state change on either side syncs them. -Delivery: best-effort, ~1s timeout, no retry. Callbacks before `/config` registers a URL are dropped. If the callback POST fails, the local state change still happens — Scrypted catches up via its own timeout or the next callback. +Don't design Scrypted-side logic that waits for the device to confirm a state change. There is no confirmation. -No wall-clock timestamp is included. The device has no RTC and no SNTP. Scrypted timestamps callbacks on receipt. +**Expected response (counter only)** + +- Any 2xx is success. +- Body is ignored. +- Anything else (non-2xx, connection refused, DNS failure, request timeout) increments `state_post_failures` and is otherwise ignored. + +**Timeouts** + +- Connect timeout: 1s. +- Total request timeout: 1s. +- After timeout the device aborts the connection and moves on. + +**Concurrency and ordering** + +- At most one outbound `/state` POST in flight at a time. +- POSTs are delivered in the order state changes occur on the device. +- If a state change happens while a POST is in flight, it goes into a depth-1 queue. If the queue already holds a POST, the queued entry is **replaced** by the newer one. The in-flight POST is never cancelled. +- Replacement is safe because POSTs are imperatives: only the latest desired state matters to Scrypted. Intermediate flips between wake and sleep within the queue window collapse to the final state. + +**Failure semantics** + +- The local state change always happens regardless of POST outcome. +- A dropped or failed POST is recovered by the next user tap, the device's idle timer firing `sleep`, or the next `/frame` from Scrypted returning `409`. +- No retry queue. No backoff. No persistence across reboots. + +**When the device does NOT POST** + +- Before `/config` has registered a `scrypted` URL (boot state, factory reset). Silently dropped. +- For state changes Scrypted initiated (`POST /state`, `POST /frame` while asleep). Scrypted already knows; echoing would loop. +- BOOT short-press IP overlay (not a state change). + +**Trust model** + +Plain HTTP, no TLS, no auth — same as the inbound API. LAN-only. + +### Race handling + +Wake/sleep changes can race: a user taps the device while Scrypted is mid-flight with a `POST /state` from a stale camera-event timeout, or the idle timer fires at the same instant Scrypted POSTs a fresh `wake`. The protocol does **not** carry epochs, session IDs, or priorities. Race resolution is purely about each side serializing its own writes and trusting idempotency to converge. + +Rules: + +1. **Device-side serialization.** The device guards its state-mutation function with a mutex. Tap, idle timer, and `POST /state` all funnel through it. Whichever lands second wins. +2. **Scrypted-side serialization.** Scrypted handles inbound `POST /state` requests for a given `viewport` one at a time (in-process queue per viewport). Whichever lands second wins. +3. **Last write wins.** No priorities. No "the device is where the user is so it always trumps Scrypted." If a stale `sleep` lands after a fresh `wake`, the device sleeps; the user taps again and we're back. One extra tap is cheap. +4. **Scrypted must cancel its own pending operations on each inbound POST.** When a `wake` arrives, Scrypted cancels any pending per-viewport sleep timer before starting a fresh stream. Same in reverse. This makes "stale Scrypted timer fires after the user tapped to wake" impossible without needing protocol-level epochs. + +Idempotency does the rest — `POST /state` on either side and `POST /frame` (relative to its `409`-vs-`204` behavior) are all no-ops when the recipient is already in the requested state. ## Idle -After `idle_timeout_ms` (default 60s) with no `/frame`, the device sleeps and POSTs `state=sleep` with `event=timeout`. Scrypted should use the same timeout so its per-stream cutoff matches, but they run independently — either side can end the session, whichever notices first. +After `idle_timeout_ms` (default 60s) with no `/frame`, the device sleeps and POSTs `state=sleep`. Scrypted should use the same timeout so its per-stream cutoff matches, but they run independently — either side can end the session, whichever notices first. ## Idempotency @@ -218,18 +308,18 @@ All endpoints are safe to retry. Every state-change path converges to the same f | `POST /state` | yes | no-op if already in that state | | `POST /frame` | yes (within state) | paints if awake; `409` if asleep — no partial state | -Callbacks (`state=wake`, `state=sleep`) are imperatives, not notifications. Scrypted acts and forgets; the device never expects an ack. A dropped callback is recovered by the next user action, by Scrypted's own timeout, or by a `/frame` returning `409`. +`POST /state` (in either direction) carries imperatives, not notifications. Both sides act and forget; neither expects an application-level ack. A dropped POST is recovered by the next user action, by Scrypted's own timeout, or by a `/frame` returning `409`. Failure modes do not corrupt state: -- Failed callback POST: local state still changes; counter increments. +- Failed outbound `/state` POST: local state still changes; `state_post_failures` increments. - Failed JPEG decode: previous frame stays; state unchanged. - Network drop mid-stream: device idle-sleeps when the timer expires. - Concurrent `/frame` posts: one wins, the other gets `503`; no half-painted frames. ## BOOT button -- **Short press**: overlay the IP screen for 15 seconds, then return to the prior state. Useful for identifying or re-registering a device that's already configured. Wakes the backlight temporarily; does not change the wake/sleep state or send a callback. An incoming `/frame` while the overlay is showing is rejected with `409` (state is still "sleep" underneath). +- **Short press**: overlay the IP screen for 15 seconds, then return to the prior state. Useful for identifying or re-registering a device that's already configured. Wakes the backlight temporarily; does not change the wake/sleep state and does not POST to Scrypted. An incoming `/frame` while the overlay is showing is rejected with `409` (state is still "sleep" underneath). - **Hold ≥5s**: factory reset — clear NVS, reboot. The device comes back unconfigured, showing the IP screen until Scrypted POSTs `/config`. ## Local rendering @@ -245,7 +335,7 @@ Both use a small embedded bitmap font. No LVGL, no general text engine. The Scrypted side is **code, not configuration** — Scrypted has no built-in concept of a network framebuffer. The code is small and lives inside Scrypted: -- **v1**: a Scrypted Script (in the Scripts plugin) — listens for camera events, calls `takePicture()`, POSTs the JPEG to `/frame`. Exposes the `/api/viewport/touch` endpoint via the EndpointManager. ~50 lines of TypeScript, no package install. +- **v1**: a Scrypted Script (in the Scripts plugin) — listens for camera events, calls `takePicture()`, POSTs the JPEG to `/frame`. Exposes a `POST /state` handler at the plugin's endpoint root (e.g. `http://scrypted.local:11080/endpoint/scrypted-viewport/state`) via the EndpointManager. ~50 lines of TypeScript, no package install. - **Next milestone after v1 (`/stream`)**: add `POST /stream` with `multipart/x-mixed-replace` chunked body for live frame rates. Scrypted side becomes a small custom plugin using FFmpeg via `MediaManager` to pipe MJPEG. `/frame` stays for snapshots and debug. Either way, no Scrypted core changes and no external service. @@ -255,12 +345,14 @@ Scrypted owns a static list of viewports, each **bound to one Scrypted camera de On startup, register every viewport: ```ts -await fetch(`${viewport}/config`, { +const SCRYPTED_BASE = "http://scrypted.local:11080/endpoint/scrypted-viewport"; + +await fetch(`${v.url}/config`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - display: "mudroom", - callback: "http://scrypted.local:11080/api/viewport/touch", + viewport: v.name, // e.g. "mudroom" + scrypted: SCRYPTED_BASE, idle_timeout_ms: 60000, orientation: "portrait", brightness: 80 @@ -271,35 +363,48 @@ await fetch(`${viewport}/config`, { To start a session (camera event like doorbell, motion, person): ```ts -await fetch(`${viewport}/state`, { +await fetch(`${v.url}/state`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ state: "wake" }) }); // device shows loading // then stream frames: -await fetch(`${viewport}/frame`, { +await fetch(`${v.url}/frame`, { method: "POST", headers: { "Content-Type": "image/jpeg" }, body: jpegBuffer // baseline JPEG at the viewport's effective resolution, <1 MB }); ``` -Handle callbacks at `POST /api/viewport/touch` (body: `{display, state, event}`): +Expose `POST /state` at `<SCRYPTED_BASE>/state` (peer of the device's `/state`). Body is `{viewport, state}`: + +```ts +const { viewport, state } = req.body; +const v = viewports.find(x => x.name === viewport); +if (!v) return res.status(404).end(); + +cancelPendingSleep(v); // race rule: cancel stale timers on every incoming POST + +if (state === "wake") startStream(v); // idempotent +if (state === "sleep") stopStream(v); // idempotent + +res.status(204).end(); +``` -- `state=wake` → look up the camera bound to `display`, start streaming frames. (You do not need to `POST /state` first — the device is already awake when it sends this.) -- `state=sleep` → stop streaming frames to `display`. +- `state=wake` → start streaming frames to the viewport's bound camera. You do not need to `POST /state` to the device first; the device is already awake when it sends this. +- `state=sleep` → stop streaming frames to that viewport. -Both are idempotent on Scrypted's side. Don't track viewport state; act on the callback and forget. Ignore the `event` field in v1. +Both are idempotent. Don't track viewport state across requests; act on each and forget. -Scrypted should use the same `idle_timeout_ms` value it sent in `/config` as its own per-stream cutoff. The two timers run independently — either side can cut a session, whichever notices first. If the viewport's sleep callback is lost, the Scrypted-side timeout still ends the stream; the next `/frame` posted after the device idle-slept simply returns `409` and Scrypted stops. +Scrypted should use the same `idle_timeout_ms` value it sent in `/config` as its own per-stream cutoff. The two timers run independently — either side can cut a session, whichever notices first. If the device's outbound `sleep` POST is lost, the Scrypted-side timeout still ends the stream; the next `/frame` posted after the device idle-slept simply returns `409` and Scrypted stops. ## Ops - Firmware updates: reflash over USB. No OTA in v1 (planned post-v1: HTTP OTA from Scrypted). - Provisioning: flash the same firmware to every device. On first boot the screen shows its IP; register it from Scrypted via `POST /config`. -- Display names must be unique across the LAN — mDNS hostnames are derived from `display` and two viewports configured with the same name will collide. -- Factory reset: hold BOOT for 5s during normal operation to clear NVS (display name, callback, brightness, idle timeout, orientation) and reboot. The device returns to the IP screen. +- Viewport names must be unique across the LAN — mDNS hostnames are derived from `viewport` and two devices configured with the same name will collide. +- Factory reset: hold BOOT for 5s during normal operation to clear NVS (viewport name, scrypted URL, brightness, idle timeout, orientation) and reboot. The device returns to the IP screen. - No DHCP lease: keep retrying; do not reboot. Screen shows "no network" if unconfigured. - Ethernet disconnect: reconnect automatically. If Scrypted is unreachable, displays go stale — nothing the device can do about it. - Watchdog: the ESP-IDF task watchdog reboots the device if a task hangs. Soft state is rebuilt from NVS on every boot. @@ -322,10 +427,10 @@ Scrypted Viewport is a thin network framebuffer appliance. ESP: - DHCP - mDNS -- HTTP server +- HTTP server (`/state`, `/config`, `/frame`) +- HTTP client (outbound `/state` POSTs to Scrypted) - JPEG decode - framebuffer - touch -- callback Everything else belongs in Scrypted. diff --git a/Scrypted-Viewport-v2-Claude-Code-Implementation-Guide.md b/Scrypted-Viewport-v2-Claude-Code-Implementation-Guide.md index 66b01c1..0149f02 100644 --- a/Scrypted-Viewport-v2-Claude-Code-Implementation-Guide.md +++ b/Scrypted-Viewport-v2-Claude-Code-Implementation-Guide.md @@ -91,7 +91,7 @@ Boot flow: power on -> initialize logging -> initialize NVS - -> load saved config (display name, callback, brightness) + -> load saved config (viewport name, scrypted URL, brightness, idle timeout, orientation) -> initialize Ethernet -> DHCP -> initialize mDNS @@ -114,16 +114,16 @@ asleep = backlight off, Scrypted not streaming State changes are strictly limited to these triggers: ```text -tap while asleep -> awake (callback: state=wake event=tap) -tap while awake -> asleep (callback: state=sleep event=tap) -idle timer expires -> asleep (callback: state=sleep event=timeout) -POST /state {"state":"wake"} asleep -> awake (no callback) -POST /state {"state":"sleep"} awake -> asleep (no callback) +tap while asleep -> awake (device POSTs <scrypted>/state {viewport,wake}) +tap while awake -> asleep (device POSTs <scrypted>/state {viewport,sleep}) +idle timer expires -> asleep (device POSTs <scrypted>/state {viewport,sleep}) +POST /state {"state":"wake"} asleep -> awake (no outbound POST) +POST /state {"state":"sleep"} awake -> asleep (no outbound POST) ``` `/frame` never changes state. It paints if awake, returns `409` if asleep. This eliminates the race where a frame in flight could re-wake a device that just slept on a tap. -Scrypted-initiated state changes via `POST /state` do not echo a callback. +Scrypted-initiated state changes via `POST /state` do not produce an outbound POST from the device. Frame flow (awake state only): @@ -136,18 +136,17 @@ Scrypted POST /frame -> respond 204 ``` -Callback body has two fields: +Callback body has one field: - `state`: `wake` or `sleep` — the resulting state and the imperative for Scrypted. -- `event`: the cause. `tap` (any user tap) or `timeout` (idle expiry, sleep only). Forward-compat for future inputs like swipes. -Callbacks are idempotent imperatives — "start streaming" / "stop streaming" — not state notifications. Scrypted does not track per-viewport state. Scrypted may ignore `event` in v1. +Callbacks are idempotent imperatives — "start streaming" / "stop streaming" — not state notifications. Scrypted does not track per-viewport state. Source identification is via request source IP, not body. Only `tap` is detected on the touchscreen. Long-press and swipes are out of scope for v1. No wall-clock timestamps. The device has no RTC and no SNTP. BOOT button: -- Short press: overlay IP screen for 15s, then return to prior state. Wakes backlight for the overlay but does not change wake/sleep state and does not POST a callback. Incoming `/frame` during the overlay is rejected `409`. +- Short press: overlay IP screen for 15s, then return to prior state. Wakes backlight for the overlay but does not change wake/sleep state and does not POST to Scrypted. Incoming `/frame` during the overlay is rejected `409`. - Hold ≥5s: clear NVS and reboot. Device comes back unconfigured. --- @@ -193,7 +192,7 @@ Example response: "last_frame_ms_ago": 1234, "frames_received": 4271, "decode_errors": 0, - "callback_failures": 2, + "state_post_failures": 2, "resolution": "480x800", "ip": "192.168.1.42", "free_heap": 123456, @@ -211,15 +210,15 @@ Return the persisted config: ```json { - "display": "mudroom", - "callback": "http://scrypted.local:11080/api/viewport/touch", + "viewport": "mudroom", + "scrypted": "http://scrypted.local:11080/endpoint/scrypted-viewport", "idle_timeout_ms": 60000, "orientation": "portrait", "brightness": 80 } ``` -Before first `/config`: `display` and `callback` are `null`; the rest carry their first-boot defaults (`idle_timeout_ms: 60000`, `orientation: "portrait"`, `brightness: 80`). +Before first `/config`: `viewport` and `scrypted` are `null`; the rest carry first-boot defaults (`idle_timeout_ms: 60000`, `orientation: "portrait"`, `brightness: 80`). Status code: `200 OK`. @@ -231,8 +230,8 @@ Request body (full form): ```json { - "display": "mudroom", - "callback": "http://scrypted.local:11080/api/viewport/touch", + "viewport": "mudroom", + "scrypted": "http://scrypted.local:11080/endpoint/scrypted-viewport", "idle_timeout_ms": 60000, "orientation": "portrait", "brightness": 80 @@ -254,8 +253,8 @@ Behavior: Validation: -- `display`: non-empty string. -- `callback`: must be `http://...`. +- `viewport`: non-empty string. Drives the mDNS hostname (`viewport-<name>.local`). +- `scrypted`: must be `http://...`. This is the Scrypted plugin's base URL. The device will POST state changes to `<scrypted>/state`. - `idle_timeout_ms`: `0` disables the idle timer; non-zero must be ≥ `5000`. Otherwise `400`. - `orientation`: `portrait` or `landscape`. Otherwise `400`. - `brightness`: integer `0`–`100`. Otherwise `400`. @@ -283,7 +282,7 @@ Behavior: - `wake`: backlight on, render loading screen, reset idle timer. No-op if already awake. - `sleep`: backlight off, framebuffer discarded. No-op if already asleep. -- Do not POST a callback (Scrypted initiated). +- Do not POST to Scrypted (Scrypted initiated this). - Idempotent. Validation: `state` must be `wake` or `sleep`. Otherwise `400`. @@ -315,47 +314,91 @@ Scrypted is responsible for scaling/cropping/composition. Scrypted Viewport does --- -## 6. Touch Callback Contract +## 6. Device → Scrypted `POST /state` -Scrypted Viewport initiates HTTP POSTs to the configured callback URL. +The device and Scrypted are HTTP peers; both expose `POST /state` with the same body shape. When the device changes state on its own (tap or idle timer), it POSTs to Scrypted's `/state` endpoint. There is no callback / ack semantic — just two REST servers calling each other. -Example request body: +The `scrypted` base URL is set via `/config`. Outbound URL is `<scrypted>/state`. + +### No application-level ack + +HTTP 2xx is transport-level only. The device does not retry, does not block subsequent state changes on the response, and does not treat 5xx as anything more than a counter increment. Idempotency + `/frame` returning `409` + each side's independent idle timer recover every failure mode. Do not write Scrypted-side logic that waits for the device to confirm a state change — there is no confirmation. + +### Request + +``` +POST <scrypted>/state HTTP/1.1 +Host: <derived from URL> +Content-Type: application/json +Content-Length: <body length> +User-Agent: ScryptedViewport/<version> +Connection: close +``` + +Body: ```json -{ - "display": "mudroom", - "state": "wake", - "event": "tap" -} +{ "viewport": "mudroom", "state": "wake" } ``` -Supported combinations: +or -```text -state=wake event=tap (touchscreen tap while asleep) -state=sleep event=tap (touchscreen tap while awake) -state=sleep event=timeout (idle timer expired) +```json +{ "viewport": "mudroom", "state": "sleep" } ``` -No wall-clock timestamp — the device has no RTC and no SNTP. Scrypted timestamps on receipt. +- `viewport` (string): the value of the `viewport` field in the device's `/config`. Scrypted's routing key. +- `state` (string): `wake` or `sleep`. The resulting state and the imperative for Scrypted. + +No other fields. No wall-clock timestamp (no RTC, no SNTP). + +### Expected response -`state` is the resulting wake/sleep state, also the imperative for Scrypted. `event` carries the cause and is forward-compatible with future inputs (swipes, long-press, hardware buttons). Scrypted may ignore `event` in v1. +- Any 2xx is success. +- Body is ignored. +- Anything else (non-2xx, connection refused, DNS failure, request timeout) increments `state_post_failures` and is otherwise ignored. -Rules: +### Timeouts + +- Connect timeout: 1 second. +- Total request timeout: 1 second. +- After timeout the device aborts the connection and moves on. + +### Concurrency and ordering + +- At most one outbound `/state` POST in flight at a time. Use a single dedicated worker task so HTTP I/O does not block the display path. +- POSTs are delivered in the order state changes occur on the device. +- If a state change happens while a POST is in flight, the new POST goes into a depth-1 queue. If the queue already holds a POST, the queued entry is **replaced** by the newer one. The in-flight POST is never cancelled. +- Replacement is safe because POSTs are imperatives — only the latest desired state matters to Scrypted. Intermediate flips collapse to the final state. + +### Failure semantics + +- The local state change always happens regardless of POST outcome. The POST is a hint, not a confirmation. +- A dropped or failed POST is recovered by one of: the next user tap, the device's idle timer firing `sleep`, or the next `/frame` from Scrypted returning `409`. +- No retry queue. No backoff. No persistence across reboots. + +### When the device does NOT POST + +- Before `/config` registers a `scrypted` URL (boot state, factory reset) — silently dropped. +- For state changes initiated via the API (`POST /state`, `POST /frame` while asleep). Scrypted already knows; echoing would loop. +- BOOT short-press IP overlay (not a state change). + +### Rules summary - The device owns wake/sleep state. Scrypted does not track it. - Scrypted owns the viewport→camera binding and decides which stream goes to which viewport. -- Callbacks are idempotent imperatives: `state=wake` means "start streaming", `state=sleep` means "stop streaming". Scrypted acts on receipt and forgets. +- `POST /state` (in either direction) is an idempotent imperative. The recipient acts and forgets. - Scrypted enforces its own per-stream timeout independently of the device's idle timer. Either can end a session, whichever notices first. -- Scrypted-initiated `POST /state` and `POST /frame` do not echo a callback. +- Plain HTTP, no TLS, no auth — same trust model as inbound. LAN-only. + +### Race handling -Callback delivery: +Wake/sleep changes can race: a user taps the device while Scrypted is mid-flight with a `POST /state` from a stale camera-event timeout, or the idle timer fires at the same instant Scrypted POSTs a fresh `wake`. The protocol does NOT carry epochs, session IDs, or priorities. Race resolution is purely about each side serializing its own writes and trusting idempotency to converge. -- Best effort. -- Timeout quickly, e.g. 1 second. -- Log failures. -- Do not block display operation for long. -- No retry queue required for v1/v2. +1. **Device-side serialization.** Guard the state-mutation function with a mutex. Tap, idle timer, and `POST /state` all funnel through it. Whichever lands second wins. +2. **Scrypted-side serialization.** Scrypted handles inbound `POST /state` requests for a given `viewport` one at a time (in-process queue per viewport). Whichever lands second wins. +3. **Last write wins.** No priorities. A stale `sleep` landing after a fresh `wake` sleeps the device; the user taps again and we're back. One extra tap is cheap. +4. **Scrypted must cancel its own pending operations on each inbound POST.** On `wake`, cancel any pending per-viewport sleep timer before starting a fresh stream. Same in reverse. This makes "stale Scrypted timer fires after the user tapped to wake" impossible without protocol-level epochs. --- @@ -395,8 +438,8 @@ scrypted-viewport/ touch.h touch.c - callback_client.h - callback_client.c + state_client.h + state_client.c idle_timer.h idle_timer.c @@ -465,20 +508,25 @@ No Wi-Fi support. ### 8.3 `mdns_service.c` +The device's mDNS responder. The ESP-IDF `mdns` component serves all `.local` records for this host directly — no external DNS server is involved. Scrypted discovers viewports by browsing the service; it does NOT depend on OS-level `.local` hostname resolution. + Responsibilities: -- Set hostname. -- Advertise `_scrypted-viewport._tcp.local` on port 80. -- Include TXT records: +- `mdns_init()` once at boot. +- `mdns_hostname_set("viewport")` pre-config, `mdns_hostname_set("viewport-<name>")` after `/config` sets `viewport`. The component answers A-record queries for `<hostname>.local`. +- `mdns_service_add(NULL, "_scrypted-viewport", "_tcp", 80, NULL, 0)` to advertise the service on port 80. The component answers the PTR + SRV queries for the browse. +- `mdns_service_txt_set("_scrypted-viewport", "_tcp", txt_items, n)` with: ```text version=1.0.0 resolution=<effective> (480x800 or 800x480) orientation=<portrait|landscape> -name=<display name> +name=<viewport name> ``` -Update TXT records when `orientation` changes via `/config`. +- Update the hostname and TXT records when `viewport` or `orientation` changes via `/config`. + +Scrypted-side discovery uses a Node mDNS-SD library (`bonjour-service`, `mdns`, etc.). Browse results carry the device's current IP from the SRV/A records — Scrypted uses that IP directly for all subsequent `/config`, `/state`, `/frame` calls and does not perform a separate hostname lookup. ### 8.4 `http_api.c` @@ -537,27 +585,34 @@ Responsibilities: - Detect `tap` only (touch-down + release within ~500ms, ignore movement). Long-press and swipes are out of scope. - Debounce. - On tap: - - If asleep: transition to awake — backlight on, render loading screen, POST `{"state":"wake","event":"tap"}`. - - If awake: transition to asleep — backlight off, POST `{"state":"sleep","event":"tap"}`. + - If asleep: transition to awake — backlight on, render loading screen, POST `{"viewport":"<name>","state":"wake"}`. + - If awake: transition to asleep — backlight off, POST `{"viewport":"<name>","state":"sleep"}`. - Also handle BOOT button: - - Short press: ask `local_screens` to overlay the IP screen for 15s, then restore prior state. No callback. + - Short press: ask `local_screens` to overlay the IP screen for 15s, then restore prior state. No outbound POST. - Hold ≥5s: clear NVS via `nvs_config_reset()` and reboot. -### 8.8 `callback_client.c` +### 8.8 `state_client.c` + +The HTTP client that POSTs the device's state changes to Scrypted's `/state`. Pair to `http_api.c` (which serves the device's own `/state`). Responsibilities: -- POST touch JSON to configured callback URL. -- Fast timeout. -- Best effort. -- Do not retry indefinitely. +- Single dedicated worker task. At most one HTTP POST in flight. +- Depth-1 queue for the next POST. If the slot is occupied when a new state change happens, replace the queued entry with the newer one (do not cancel the in-flight POST). +- POST JSON body `{"viewport": "<name>", "state": "wake"|"sleep"}` to `<scrypted>/state` (where `<scrypted>` is the configured base URL) with `Content-Type: application/json`, `User-Agent: ScryptedViewport/<version>`, `Connection: close`. +- Connect timeout 1s, total request timeout 1s. Abort on timeout. +- Treat any 2xx as success. Increment `state_post_failures` on anything else (non-2xx, connection refused, DNS failure, timeout) and continue. +- Never block the display, frame, or touch paths. +- No retries. No backoff. No persistence across reboot. + +Callers fire-and-forget to the worker. Local state changes proceed immediately regardless of POST outcome. ### 8.9 `idle_timer.c` Responsibilities: - Reset timer on `/frame`, `POST /state {state:wake}`, and tap-driven wake. -- On expiry: transition to asleep (backlight off, POST `{"state":"sleep","event":"timeout"}`). +- On expiry: transition to asleep (backlight off, POST `{"viewport":"<name>","state":"sleep"}`). - Idle timeout: read from NVS (`idle_timeout_ms`), default 60000 ms. `0` disables the timer; non-zero values must be ≥ 5000 ms (validated at `/config`). - Scrypted is expected to use the same value as its own per-stream cutoff, but timers run independently — either side can end a session, whichever notices first. @@ -568,7 +623,7 @@ The only application UI the firmware draws. Two screens, both via a small embedd Responsibilities: - `local_screens_show_ip(ip)` — centered IP address and `viewport.local`. Shown: - - on boot when no callback URL is in NVS (persistent until `/config` arrives), and + - on boot when no `scrypted` URL is in NVS (persistent until `/config` arrives), and - as a 15s overlay on BOOT short-press (then restore prior state). - `local_screens_show_loading()` — centered "Loading…" text. Shown on wake until the next `/frame` lands. - Render into the same RGB565 framebuffer the JPEG decoder targets, then push to the panel. @@ -581,8 +636,8 @@ Responsibilities: Persist: -- display name -- callback URL +- viewport name +- scrypted base URL - brightness - idle timeout (ms) - orientation @@ -597,11 +652,11 @@ Global state should be small: ```c typedef struct { - char display_name[64]; - char callback_url[256]; + char viewport_name[64]; + char scrypted_url[256]; bool configured; - bool display_awake; + bool awake; uint8_t brightness; // 0-100 uint32_t idle_timeout_ms; @@ -675,51 +730,78 @@ Acceptance criterion: ## 12. Scrypted Integration Assumptions -Scrypted owns a list of viewports, each bound to one camera device: +Scrypted owns a list of viewport-to-camera bindings: ```ts -const viewports = [ - { url: "http://viewport-mudroom.local", display: "mudroom", camera: "frontDoor" }, - { url: "http://viewport-kitchen.local", display: "kitchen", camera: "driveway" }, +const bindings = [ + { name: "mudroom", camera: "frontDoor" }, + { name: "kitchen", camera: "driveway" }, ]; ``` The binding lives in Scrypted config. The device knows nothing about cameras. -Registration on startup: +### Discovery + +Scrypted finds viewports via mDNS service discovery — not by hardcoded hostname. Use a Node mDNS-SD library that hits the multicast layer directly, so OS-level `.local` resolution is not required: ```ts -for (const v of viewports) { - await fetch(`${v.url}/config`, { +import { Bonjour } from "bonjour-service"; + +const bonjour = new Bonjour(); +const viewports = new Map<string, { ip: string; port: number }>(); // name -> address + +bonjour.find({ type: "scrypted-viewport" }, (svc) => { + const name = svc.txt?.name; + const ip = svc.addresses?.find(a => !a.includes(":")); // prefer IPv4 + if (name && ip) viewports.set(name, { ip, port: svc.port }); +}); + +// Re-browse periodically to catch DHCP renumbering. The library typically +// also emits 'down' events when a device disappears; honor those too. +setInterval(() => bonjour.find({ type: "scrypted-viewport" }), 5 * 60 * 1000); +``` + +Resolve an IP for each binding when calling the device: + +```ts +function urlFor(name: string): string { + const v = viewports.get(name); + if (!v) throw new Error(`viewport ${name} not discovered yet`); + return `http://${v.ip}:${v.port}`; +} +``` + +If mDNS-SD is not available in the deployment (some Docker setups, VLAN edge cases), allow the operator to set an explicit `http://<ip>:<port>` per viewport in plugin config as a fallback — same shape as the discovered entry. + +### Registration + +Registration on startup. The plugin's HTTP root is the `scrypted` base URL the device will POST to (with `/state` appended): + +```ts +const SCRYPTED_BASE = "http://scrypted.local:11080/endpoint/scrypted-viewport"; + +for (const b of bindings) { + await fetch(`${urlFor(b.name)}/config`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - display: v.display, - callback: "http://scrypted.local:11080/api/viewport/touch", - idle_timeout_ms: 60000, // device uses this; Scrypted uses the same value - orientation: "portrait", // override per viewport if a screen is wall-mounted sideways + viewport: b.name, // e.g. "mudroom" — routing key in device's outbound /state POSTs + scrypted: SCRYPTED_BASE, // device will POST <scrypted>/state + idle_timeout_ms: 60000, // both sides use this independently + orientation: "portrait", // override per viewport if wall-mounted sideways brightness: 80, }), }); } ``` -Stream a session of frames to a viewport — triggered by either a camera event (doorbell, person, motion) or a `state=wake` callback from the viewport: - -```ts -await fetch(`${v.url}/frame`, { - method: "POST", - headers: { "Content-Type": "image/jpeg" }, - body: jpegBuffer, -}); -``` - Each viewport is bound to exactly one camera (1:1 in v1). Multi-camera cycling is out of scope. Scrypted-initiated session (camera event): ```ts -await fetch(`${v.url}/state`, { +await fetch(`${urlFor(b.name)}/state`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ state: "wake" }), @@ -727,7 +809,7 @@ await fetch(`${v.url}/state`, { // then push frames until the per-stream timeout elapses: for (const jpeg of frames) { - const r = await fetch(`${v.url}/frame`, { + const r = await fetch(`${urlFor(b.name)}/frame`, { method: "POST", headers: { "Content-Type": "image/jpeg" }, body: jpeg, @@ -736,12 +818,22 @@ for (const jpeg of frames) { } ``` -Callback handler at `/api/viewport/touch` (body: `{display, state, event}`): +Handler at `POST <SCRYPTED_BASE>/state` (body: `{viewport, state}`): + +```ts +const { viewport, state } = req.body; +const b = bindings.find(x => x.name === viewport); +if (!b) return res.status(404).end(); -- `state=wake` → look up viewport→camera binding, start streaming. No need to `POST /state` first; device is already awake. -- `state=sleep` → stop streaming to that viewport. +cancelPendingSleep(b); // race rule: cancel stale timers on every inbound POST -Both handlers are idempotent. Don't track viewport state. Apply a Scrypted-side per-stream timeout using the same `idle_timeout_ms` sent in `/config`, so streams end even if the sleep callback is dropped. Timers run independently — and if the device sleeps first (idle or tap), the next `/frame` returns `409` and Scrypted stops on that signal alone. +if (state === "wake") startStream(b); // idempotent +if (state === "sleep") stopStream(b); // idempotent + +res.status(204).end(); +``` + +Apply a Scrypted-side per-stream timeout using the same `idle_timeout_ms` sent in `/config`, so streams end even if the device's outbound sleep POST is dropped. Timers run independently — and if the device sleeps first (idle or tap), the next `/frame` returns `409` and Scrypted stops on that signal alone. --- @@ -765,15 +857,20 @@ Device gets DHCP lease over Ethernet. - Start HTTP server. - Implement `GET /state`. -- Advertise `_scrypted-viewport._tcp.local` with TXT records. +- Initialize mDNS responder. Advertise `_scrypted-viewport._tcp.local` on port 80 with TXT records (`version`, `resolution`, `orientation`, `name`). Acceptance: ```bash +# OS-level mDNS resolution (works on macOS by default; requires nss-mdns on Linux): curl http://viewport.local/state + +# Or service-discovery browse, which does NOT require OS-level resolution: +dns-sd -B _scrypted-viewport._tcp local. # macOS +avahi-browse -r _scrypted-viewport._tcp # Linux ``` -returns JSON with `state: "unconfigured"` on a fresh device. +`GET /state` returns JSON with `state: "unconfigured"` on a fresh device. The service browse shows the device with its current IP. Scrypted-side discovery should use the browse, not the hostname. ### Milestone 3: Display Bring-Up @@ -791,9 +888,9 @@ Screen displays deterministic test pattern. Done before any state-bearing endpoints so they can read values from NVS instead of working around hardcoded defaults. -- Implement `GET /config` and `POST /config` (display, callback, idle_timeout_ms, orientation, brightness). +- Implement `GET /config` and `POST /config` (viewport, scrypted, idle_timeout_ms, orientation, brightness). - Partial-update semantics on `POST /config`: only included fields are written. -- Validate per spec (non-empty display, http callback, idle_timeout = 0 or ≥ 5000, orientation in {portrait, landscape}, brightness 0–100). +- Validate per spec (non-empty viewport, http scrypted URL, idle_timeout = 0 or ≥ 5000, orientation in {portrait, landscape}, brightness 0–100). - Persist all fields to NVS atomically. - Apply orientation and brightness immediately (mDNS TXT and `/state` reflect them). - Brightness defaults to 80 on first boot. @@ -802,7 +899,7 @@ Acceptance: ```bash curl -X POST -H "Content-Type: application/json" \ - -d '{"display":"mudroom","callback":"http://host/cb","orientation":"landscape"}' \ + -d '{"viewport":"mudroom","scrypted":"http://host/endpoint/scrypted-viewport","orientation":"landscape"}' \ http://viewport.local/config # Partial update — only brightness changes: @@ -813,7 +910,7 @@ curl -X POST -H "Content-Type: application/json" \ curl http://viewport.local/config ``` -After reboot, `GET /state` shows `configured=true` and name preserved; `GET /config` shows `orientation=landscape`, `brightness=50`, and the original callback. +After reboot, `GET /state` shows `configured=true` and name preserved; `GET /config` shows `orientation=landscape`, `brightness=50`, and the original `scrypted` URL. ### Milestone 5: JPEG Frame Push @@ -836,7 +933,7 @@ updates screen. Re-running with `landscape` set via `/config` requires an 800×4 - Implement `POST /state` (`wake` / `sleep`, idempotent). - Make `/frame` reject with `409` when asleep — no auto-wake. -- Add idle timer using `idle_timeout_ms` from NVS; on expiry, transition to sleep and POST `state=sleep event=timeout` callback (callback target is a no-op until M7). +- Add idle timer using `idle_timeout_ms` from NVS; on expiry, transition to sleep and POST `{"viewport":"<name>","state":"sleep"}` to `<scrypted>/state` (target is a no-op until M7). Acceptance: @@ -851,20 +948,20 @@ curl -X POST -d '{"state":"wake"}' http://viewport.local/state # backlight on, - Initialize touch controller. - Detect tap; toggle wake/sleep locally. -- POST callback JSON for `state=wake event=tap`, `state=sleep event=tap`, and `state=sleep event=timeout`. +- POST `{"viewport":"<name>","state":"wake"|"sleep"}` to `<scrypted>/state` via the `state_client` worker (depth-1 queue, 1s timeout). Acceptance: ```text -Tap on asleep device: backlight on, callback {state:wake,event:tap}. -Tap on awake device: backlight off, callback {state:sleep,event:tap}. -After idle_timeout_ms with no /frame: callback {state:sleep,event:timeout}. +Tap on asleep device: backlight on; outbound POST {viewport,state:wake} to <scrypted>/state. +Tap on awake device: backlight off; outbound POST {viewport,state:sleep} to <scrypted>/state. +After idle_timeout_ms with no /frame: outbound POST {viewport,state:sleep}. ``` ### Milestone 8: Local Screens + BOOT button - Embed minimal bitmap font. -- Render IP screen on boot when NVS has no callback (persistent). +- Render IP screen on boot when NVS has no `scrypted` URL (persistent). - Render loading screen on every wake (via tap or `POST /state`); replaced by next `/frame`. - Wire BOOT short-press to 15s IP-screen overlay (no state change). - Wire BOOT 5s-hold to NVS-clear + reboot. @@ -930,7 +1027,7 @@ Frame decode fails: Callback POST fails: - log -- increment `callback_failures` +- increment `state_post_failures` - continue — local state change still happens - do not retry; Scrypted catches up via its own timeout or the next event @@ -996,13 +1093,13 @@ The project is successful when: 3. Device advertises mDNS with TXT records (`version`, `resolution`, `orientation`, `name`). 4. `GET /state` returns runtime state, frame counters, and error counters. 5. `GET /config` returns the persisted config (with defaults filled in before first `/config`). -6. `POST /config` persists display, callback, idle timeout, orientation, and brightness across reboot, with partial-update semantics. +6. `POST /config` persists viewport, scrypted URL, idle timeout, orientation, and brightness across reboot, with partial-update semantics. 7. `POST /config` validates `idle_timeout_ms` (0 disables; non-zero ≥ 5000; else 400), `orientation` (`portrait` or `landscape`; else 400), and `brightness` (0–100; else 400). Defaults on first boot: `orientation=portrait`, `brightness=80`, `idle_timeout_ms=60000`. 8. `POST /state` transitions wake↔sleep idempotently and rejects unknown state values with 400. 9. `POST /frame` paints when awake, returns 409 when asleep, and never changes state. 10. Brightness PWM is gamma-corrected (perceptual 0–100). -11. Idle timer fires `state=sleep event=timeout` callback after `idle_timeout_ms` of no `/frame`. -12. Tap toggles wake/sleep locally and POSTs `state=wake event=tap` or `state=sleep event=tap`. +11. Idle timer POSTs `{viewport, state:sleep}` to `<scrypted>/state` after `idle_timeout_ms` of no `/frame`. +12. Tap toggles wake/sleep locally and POSTs `{state:wake}` or `{state:sleep}` to `<scrypted>/state`. 13. Unconfigured device shows its IP and `viewport.local` on screen. 14. Loading screen is shown on every wake until the next `/frame` arrives. 15. BOOT short-press overlays IP screen for 15s with no state change. BOOT 5s-hold factory-resets. |
