src.nth.io/

summaryrefslogtreecommitdiff
path: root/README.md
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-13 21:31:28 -0500
committerLuke Hoersten <[email protected]>2026-06-13 21:31:28 -0500
commitf5b9f2f81fa71361418a475d801eb4adefcbc027 (patch)
tree7f0f35e9c9be5895cf71a03bd24532596e94879f /README.md
parent072504c2dfa19e4311e16e5c445d9cc19680dcf5 (diff)
Reframe protocol as REST peers; mDNS-SD discovery; explicit no-ack
Three structural shifts: 1. Discovery section spells out mDNS-SD. The ESP32 runs the ESP-IDF mdns responder and serves all .local records itself — A, SRV, PTR, TXT. Scrypted browses _scrypted-viewport._tcp.local with a Node mDNS-SD library that hits the multicast layer directly. Scrypted uses the IP from the SRV/A record, NOT OS-level .local hostname resolution. Re-browse every few minutes for DHCP renumbering. Manual host:port fallback for non-mDNS deployments. 2. Drop "callback" framing. The device and Scrypted are HTTP peers, both exposing POST /state with the same body shape {viewport,state}. Either side can push to the other to set state. Section is renamed "Device -> Scrypted POST /state". No new vocabulary or semantics — just the truth about what the protocol is. - state_post_failures (was: callback_failures) - state_client.c (was: callback_client.c) - "callback" replaced with "outbound POST" / "inbound POST" / just "POST /state" throughout. 3. Explicit no-application-level-ack clause. HTTP 2xx is transport- only. Device does not retry, does not block subsequent state changes on the response, does not treat 5xx as anything beyond a counter increment. Idempotency + /frame 409 + each side's idle timer cover every failure mode. Scrypted-side logic must not wait for the device to confirm a state change. Also: Scrypted Integration code in v2 guide rewritten to discover via bonjour-service browse rather than hardcoded viewport-<name>.local URLs. urlFor(name) function resolves the discovered IP per call. mdns_service.c module spec now explicitly notes Scrypted uses the browse, not the hostname.
Diffstat (limited to 'README.md')
-rw-r--r--README.md217
1 files changed, 161 insertions, 56 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.