src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md217
-rw-r--r--Scrypted-Viewport-v2-Claude-Code-Implementation-Guide.md315
2 files changed, 367 insertions, 165 deletions
diff --git a/README.md b/README.md
index c8c1af9..cf883e0 100644
--- a/README.md
+++ b/README.md
@@ -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.