diff options
| author | Luke Hoersten <[email protected]> | 2026-07-19 09:41:43 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-07-19 09:46:37 -0500 |
| commit | 642e5498dd886e7389169472909119f59635f8c4 (patch) | |
| tree | 4b602431fc21509b7ea5468ce02260dcd05dbe7e | |
| parent | ffd5b40586f6c1a8a0b245603c672afe521eb128 (diff) | |
readme: public-facing refresh — links up top, drop the v1-spec framing, 120-col reflowmain
Now that the repo is public: title becomes "Scrypted Viewport" with a
what-it-is intro (0.7s wake-to-video, 24fps, double-digit-ms g2g) and
up-front links to the write-up (nth.io/luke/projects/esp32-ethernet-
display), the canonical src.nth.io repo, and the GitHub mirror. The
"v1 Technical Design Specification" framing is gone (the v1/v2 script
references now read as "the script" + the plugin-conversion plan), the
Boot flow diagram is fenced, and all prose is reflowed to 120 columns
(markdown tables exempt — they can't wrap).
| -rw-r--r-- | README.md | 443 |
1 files changed, 307 insertions, 136 deletions
@@ -1,12 +1,19 @@ -# Scrypted Viewport v1 Technical Design Specification +# ESP32 PoE Scrypted Viewport -Version: 1.0 +A PoE-powered ambient camera display for [Scrypted](https://www.scrypted.app/): an ESP32-P4 driving a Raspberry Pi +7"-class DSI touch panel with live doorbell/camera video. Ring the doorbell and the panel is showing live video in ~0.7 +s, sustaining 24 fps with double-digit-millisecond glass-to-glass latency — then it sleeps itself until the next event +or tap. + +- **Write-up:** <https://nth.io/luke/projects/esp32-ethernet-display/> +- **Canonical source:** <https://src.nth.io/luke/esp32-poe-scrypted-viewport/> +- **Mirror:** <https://github.com/lukehoersten/esp32-poe-scrypted-viewport> ## Overview -Scrypted Viewport is an Ethernet-powered ambient display appliance optimized for Scrypted camera and doorbell -events. It's meant to be plug and play onto a trusted POE VLAN connection with Scrypted access so there's no -configuration done on the esp32 itself and instead is discoverd and configured within Scrypted. +Scrypted Viewport is an Ethernet-powered ambient display appliance optimized for Scrypted camera and doorbell events. +It's meant to be plug and play on a trusted PoE VLAN with Scrypted access: no configuration happens on the ESP32 itself +— devices are discovered over mDNS and configured entirely from within Scrypted. Design goals: - No Matter @@ -23,9 +30,13 @@ Scrypted Viewport owns Ethernet, JPEG decode, display, touch input and outbound ## Related docs -- [`TESTING.md`](TESTING.md) — **the verification reference.** New-unit bring-up playbook, regression `curl` recipes, outstanding tests, and the performance-review methodology. (All bring-up milestones are ✅ verified; the historical milestone log lives in git history.) -- [`scrypted/README.md`](scrypted/README.md) — Scrypted-side script install + per-viewport binding UI (camera picker, wake triggers, mDNS-discovered host dropdowns). -- [`scrypted/PLUGIN-CONVERSION.md`](scrypted/PLUGIN-CONVERSION.md) — TODO: plan for repackaging the script as a real installable Scrypted plugin (kills the Scripts-sandbox reload-leak machinery, makes deploys scriptable). +- [`TESTING.md`](TESTING.md) — **the verification reference.** New-unit bring-up playbook, regression `curl` recipes, + outstanding tests, and the performance-review methodology. (All bring-up milestones are ✅ verified; the historical + milestone log lives in git history.) +- [`scrypted/README.md`](scrypted/README.md) — Scrypted-side script install + per-viewport binding UI (camera picker, + wake triggers, mDNS-discovered host dropdowns). +- [`scrypted/PLUGIN-CONVERSION.md`](scrypted/PLUGIN-CONVERSION.md) — TODO: plan for repackaging the script as a real + installable Scrypted plugin (kills the Scripts-sandbox reload-leak machinery, makes deploys scriptable). ## Status @@ -39,19 +50,27 @@ See [`TESTING.md`](TESTING.md) for the new-unit bring-up playbook, regression re ## Hardware -### Controller -[Waveshare ESP32-P4-ETH](https://www.amazon.com/ESP32-P4-Ethernet-Development-MIPI-CSI-Microphone/dp/B0FN7JQ2V8/) — ESP32-P4 with IP101GRI PHY, 2-lane MIPI-DSI out, 32 MB PSRAM, 16/32 MB flash. PoE is an optional add-on module on the same SKU. +### Display driver +[Waveshare ESP32-P4-ETH](https://www.amazon.com/ESP32-P4-Ethernet-Development-MIPI-CSI-Microphone/dp/B0FN7JQ2V8/) — +ESP32-P4 with IP101GRI PHY, 2-lane MIPI-DSI out, 32 MB PSRAM, 16/32 MB flash. PoE is an optional add-on module on the +same SKU. (The *controller* of the system is Scrypted — the ESP32 is a thin display driver; see +[Philosophy](#philosophy).) ### Display -[Hosyond 5" 800x480 IPS Capacitive Touch MIPI DSI display](https://www.amazon.com/dp/B0CXTFN8K9) — Pi-compatible panel (TC358762 DSI-to-DPI bridge + ATTINY-class init MCU at I²C `0x45`, FT5426 touch at `0x38`). 15-pin Pi FPC for the DSI lanes + power; I²C runs as jumpers off the panel's auxiliary header (see [`TESTING.md`](TESTING.md) M3 for the wiring table). +[Hosyond 5" 800x480 IPS Capacitive Touch MIPI DSI display](https://www.amazon.com/dp/B0CXTFN8K9) — Pi-compatible panel +(TC358762 DSI-to-DPI bridge + ATTINY-class init MCU at I²C `0x45`, FT5426 touch at `0x38`). 15-pin Pi FPC for the DSI +lanes + power; I²C runs as jumpers off the panel's auxiliary header (see [`TESTING.md`](TESTING.md) M3 for the wiring +table). ## Boot +``` Power -> DHCP -> mDNS (_scrypted-viewport._tcp.local) --> If no `scrypted` URL set: backlight on, show info screen (persistent until `/config`) --> If configured: enter sleep state (backlight off, wait for `POST /state` or a tap) +-> If no `scrypted` URL set: backlight on, show info screen (persistent until /config) +-> If configured: enter sleep state (backlight off, wait for POST /state or a tap) +``` ## Resolution @@ -62,7 +81,8 @@ Effective resolution depends on `orientation` (set via `/config`): - `portrait` (default): 480x800 - `landscape`: 800x480 -Scrypted must render JPEGs at the **effective** resolution. The device does not scale or rotate JPEG content — orientation is applied during framebuffer-to-panel push. +Scrypted must render JPEGs at the **effective** resolution. The device does not scale or rotate JPEG content — +orientation is applied during framebuffer-to-panel push. ## Network @@ -72,11 +92,15 @@ Trust model: LAN-only, no auth, no TLS. Deploy on a trusted VLAN. ## Discovery -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. +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. -The device runs an mDNS responder (ESP-IDF `mdns` component) that serves all of the following from itself — no external DNS server is involved: +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-<name>.local`. The name always has a value — a MAC-derived default (colons stripped, e.g. `viewport-e8f60ae09094.local`) until `/config` sets a friendlier one (`viewport-mudroom.local`). The `viewport-` prefix is a namespace that avoids collisions with other LAN devices. +- **Hostname / A record**: `viewport-<name>.local`. The name always has a value — a MAC-derived default (colons + stripped, e.g. `viewport-e8f60ae09094.local`) until `/config` sets a friendlier one (`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**: @@ -86,14 +110,24 @@ The device runs an mDNS responder (ESP-IDF `mdns` component) that serves all of - `name=<viewport name>` (MAC-derived default until `/config`) - `mac=<aa:bb:cc:dd:ee:ff>` — stable identity for discovery; names are editable, the MAC is not -Scrypted-side discovery (implemented in the script — see `mdnsBrowse` in [`scrypted/scrypted-viewport.ts`](scrypted/scrypted-viewport.ts)): - -1. The Scripts sandbox has **no third-party mDNS libraries** (`bonjour-service`, `multicast-dns`, `mdns` all fail to resolve — verified), so the script browses with a plain `dgram` socket on an **ephemeral port**: per RFC 6762 §6.7 a query from a non-5353 source port is a *legacy unicast* query and responders reply unicast straight back to it. No `:5353` bind means no conflict with Scrypted's own HomeKit mDNS stack or a host `avahi-daemon`, and it works under Docker host-networking or a native install alike. -2. One PTR response packet carries PTR + SRV + TXT + A together (RFC 6763 §12.1), so parsing is per-packet with no follow-up queries. The **IP** from the A record is what's used for all subsequent calls. -3. Discovered viewports surface as dropdown choices on the host field (add-device form + each viewport's settings page), and a blank name on create inherits the discovered TXT `name`. -4. **Auto-heal instead of periodic re-browse**: when a `/config` registration fails (DHCP renumber), the script re-browses and matches by TXT `mac` first, then `name`; on a hit at a new address it rewrites the stored host and retries. This removes the need for a DHCP reservation. - -Manual entry (IP or hostname) always remains available on the host field — discovery is best-effort and degrades to typing. +Scrypted-side discovery (implemented in the script — see `mdnsBrowse` in +[`scrypted/scrypted-viewport.ts`](scrypted/scrypted-viewport.ts)): + +1. The Scripts sandbox has **no third-party mDNS libraries** (`bonjour-service`, `multicast-dns`, `mdns` all fail to + resolve — verified), so the script browses with a plain `dgram` socket on an **ephemeral port**: per RFC 6762 §6.7 a + query from a non-5353 source port is a *legacy unicast* query and responders reply unicast straight back to it. No + `:5353` bind means no conflict with Scrypted's own HomeKit mDNS stack or a host `avahi-daemon`, and it works under + Docker host-networking or a native install alike. +2. One PTR response packet carries PTR + SRV + TXT + A together (RFC 6763 §12.1), so parsing is per-packet with no + follow-up queries. The **IP** from the A record is what's used for all subsequent calls. +3. Discovered viewports surface as dropdown choices on the host field (add-device form + each viewport's settings page), + and a blank name on create inherits the discovered TXT `name`. +4. **Auto-heal instead of periodic re-browse**: when a `/config` registration fails (DHCP renumber), the script + re-browses and matches by TXT `mac` first, then `name`; on a hit at a new address it rewrites the stored host and + retries. This removes the need for a DHCP reservation. + +Manual entry (IP or hostname) always remains available on the host field — discovery is best-effort and degrades to +typing. ### Discover from the CLI @@ -110,11 +144,13 @@ dns-sd -G v4 viewport-kitchen.local. curl http://viewport-kitchen.local/state ``` -On Linux: `avahi-browse -rt _scrypted-viewport._tcp` for the same effect with addresses inline. The instance name (`viewport-kitchen`) **is** the hostname prefix — they're the same string. +On Linux: `avahi-browse -rt _scrypted-viewport._tcp` for the same effect with addresses inline. The instance name +(`viewport-kitchen`) **is** the hostname prefix — they're the same string. ## API -Four endpoints. `GET /state` and `GET /config` are the read surface; `POST /config`, `POST /state`, and `POST /frame` are the write surface. +Four endpoints. `GET /state` and `GET /config` are the read surface; `POST /config`, `POST /state`, and `POST /frame` +are the write surface. ### GET /state @@ -147,7 +183,14 @@ Returns `200 OK` with JSON: } ``` -`state` is `awake` or `asleep` (it reports the screen's current state only). `configured` reports whether a `scrypted` URL is registered (the name always has a MAC-derived default, so it doesn't factor in). `last_frame_ms_ago` is `null` if no frame has been received since boot. `ota_state` is the running image's OTA slot state (`pending-verify` right after an OTA until the 30 s healthy timer marks it `valid` — the rollback tell, see [POST /firmware](#post-firmware)). `temp_c` is the on-die junction temperature (~10–20 °C above ambient; omitted if the sensor is unavailable). `tear_guard_engaged` counts frames the triple-buffer guard saved from tearing. `stream` is the most recent 30-painted-frame window of data-plane stats (recv/decode/paint/idle min/avg/max, wire rate, header-gap and pending-age decomposition, drop counters — see `stream_server.h` for field semantics); all zeros before the first window rolls. +`state` is `awake` or `asleep` (it reports the screen's current state only). `configured` reports whether a `scrypted` +URL is registered (the name always has a MAC-derived default, so it doesn't factor in). `last_frame_ms_ago` is `null` if +no frame has been received since boot. `ota_state` is the running image's OTA slot state (`pending-verify` right after +an OTA until the 30 s healthy timer marks it `valid` — the rollback tell, see [POST /firmware](#post-firmware)). +`temp_c` is the on-die junction temperature (~10–20 °C above ambient; omitted if the sensor is unavailable). +`tear_guard_engaged` counts frames the triple-buffer guard saved from tearing. `stream` is the most recent +30-painted-frame window of data-plane stats (recv/decode/paint/idle min/avg/max, wire rate, header-gap and pending-age +decomposition, drop counters — see `stream_server.h` for field semantics); all zeros before the first window rolls. ### POST /state @@ -176,7 +219,8 @@ Returns the persisted config: } ``` -Before first `/config`: returns `200` with `scrypted` as `null`; `viewport` carries its MAC-derived default and the rest their first-boot defaults (`brightness: 80`, `orientation: "portrait"`, `idle_timeout_ms: 60000`). +Before first `/config`: returns `200` with `scrypted` as `null`; `viewport` carries its MAC-derived default and the rest +their first-boot defaults (`brightness: 80`, `orientation: "portrait"`, `idle_timeout_ms: 60000`). ### POST /config @@ -190,13 +234,21 @@ Before first `/config`: returns `200` with `scrypted` as `null`; `viewport` carr } ``` -- **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. +- **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. -- `viewport` must be non-empty and ≤ 54 chars (so the `viewport-<name>` mDNS hostname fits the 63-byte DNS label limit); `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. +- `viewport` must be non-empty and ≤ 54 chars (so the `viewport-<name>` mDNS hostname fits the 63-byte DNS label limit); + `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. - Response: `204 No Content`. Invalid body: `400`. To tweak only brightness: @@ -210,40 +262,37 @@ To tweak only brightness: 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. +- 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 tap-driven `wake` POST from the device). +- **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. -- `400` malformed JPEG, `409` device asleep, `413` over size, `500` decode/display failure. On failure the previous frame stays on screen. +- `400` malformed JPEG, `409` device asleep, `413` over size, `500` decode/display failure. On failure the previous + frame stays on screen. ### POST /firmware -Push a new application image and reboot into it. The device's HTTP OTA -endpoint — replaces USB reflash once the device is on the LAN. +Push a new application image and reboot into it. The device's HTTP OTA endpoint — replaces USB reflash once the device +is on the LAN. -- `Content-Type: application/octet-stream`, body is the raw built app - image (`build/scrypted-viewport.bin`, ~1.5 MB). `Content-Length` - required. +- `Content-Type: application/octet-stream`, body is the raw built app image (`build/scrypted-viewport.bin`, ~1.5 MB). + `Content-Length` required. - Single-shot: a second concurrent POST returns `409 Conflict`. -- Streams straight to the inactive OTA slot (no full-image RAM buffer), - validates header + checksum on `esp_ota_end`, flips `otadata` to point - at the new slot, replies `200` with - `{"status":"ok","previous":"<git>","next":"<git>","slot":"ota_1","reboot_in_ms":500}`, - then reboots ~500 ms later so the response can flush. -- On failure (`400` bad body / validate failed, `413` over partition size, - `500` flash error) the OTA handle is aborted and the running image stays - live. No partial-write half-state. -- **Rollback armed.** The new image boots `pending-verify`; firmware - flips it to `valid` after 30 s of healthy uptime - (`ota_arm_healthy_timer`). If the new image panics or the device is - power-cycled before the timer fires, the bootloader reverts to the - previous slot on next reset. `/state` reports the current state via - `ota_state`. - -The repo Makefile wraps the whole loop (recipes run in bash regardless -of your interactive shell; `idf.py` env is sourced per-recipe): +- Streams straight to the inactive OTA slot (no full-image RAM buffer), validates header + checksum on `esp_ota_end`, + flips `otadata` to point at the new slot, replies `200` with + `{"status":"ok","previous":"<git>","next":"<git>","slot":"ota_1","reboot_in_ms":500}`, then reboots ~500 ms later so + the response can flush. +- On failure (`400` bad body / validate failed, `413` over partition size, `500` flash error) the OTA handle is aborted + and the running image stays live. No partial-write half-state. +- **Rollback armed.** The new image boots `pending-verify`; firmware flips it to `valid` after 30 s of healthy uptime + (`ota_arm_healthy_timer`). If the new image panics or the device is power-cycled before the timer fires, the + bootloader reverts to the previous slot on next reset. `/state` reports the current state via `ota_state`. + +The repo Makefile wraps the whole loop (recipes run in bash regardless of your interactive shell; `idf.py` env is +sourced per-recipe): ```sh make ota # reconfigure (fresh git stamp) + build + push + verify @@ -254,14 +303,11 @@ make verify # post-push pending-verify -> valid check only make check # type-check the Scrypted plugin (tsc --noEmit) ``` -`make ota` (via `tools/ota.sh`) encodes the acceptance criterion from -the rollback bullet above: the fresh boot must report -`ota_state=pending-verify` before flipping to `valid` — a boot that -reads `valid` immediately means the bootloader silently reverted to the -old slot, and the script re-pushes once automatically (a known quirk of -first pushes). It also runs `idf.py reconfigure` first because the -embedded git hash is stamped at CMake configure time only; without it -the binary reports a stale SHA after new commits. +`make ota` (via `tools/ota.sh`) encodes the acceptance criterion from the rollback bullet above: the fresh boot must +report `ota_state=pending-verify` before flipping to `valid` — a boot that reads `valid` immediately means the +bootloader silently reverted to the old slot, and the script re-pushes once automatically (a known quirk of first +pushes). It also runs `idf.py reconfigure` first because the embedded git hash is stamped at CMake configure time only; +without it the binary reports a stale SHA after new commits. The raw mechanism underneath: @@ -276,12 +322,14 @@ curl -v --data-binary @build/scrypted-viewport.bin \ Wake and sleep couple the device backlight with Scrypted's frame stream. The device owns the state. -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. +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. - `{"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. +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: @@ -294,13 +342,20 @@ Transitions: | `POST /state {"state":"sleep"}` | Asleep | none | | `POST /frame` | (no state change) | — | -Frames never change state — neither the MJPEG stream on the data socket nor a `POST /frame`. Scrypted must `POST /state {"state":"wake"}` (or wait for a tap-driven `wake` POST from the device) before frames will paint. This makes the protocol race-free: once the device is asleep, frames arriving on the stream socket are discarded by the decode task and a `POST /frame` is rejected with `409` — neither silently re-wakes the panel. Scrypted learns the device slept from its `state=sleep` callback (and, as a backstop, its own per-stream safety timer). +Frames never change state — neither the MJPEG stream on the data socket nor a `POST /frame`. Scrypted must `POST /state +{"state":"wake"}` (or wait for a tap-driven `wake` POST from the device) before frames will paint. This makes the +protocol race-free: once the device is asleep, frames arriving on the stream socket are discarded by the decode task and +a `POST /frame` is rejected with `409` — neither silently re-wakes the panel. Scrypted learns the device slept from its +`state=sleep` callback (and, as a backstop, its own per-stream safety timer). -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`. +The touchscreen gestures are deliberately minimal — short tap (wake/sleep toggle) and long-press (info overlay); no +swipes. `tap` itself is internal; what Scrypted sees is the resulting `state`. ### 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. +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** @@ -334,9 +389,13 @@ 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 + frames not painting while asleep + each side's independent idle timer recover every failure mode without an 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 + frames not painting while asleep + each side's independent idle timer recover every failure mode without +an ack: -- Scrypted misses the device's `sleep`: its own per-stream safety timer eventually tears down the ffmpeg stream, and in the meantime any frames still on the wire are discarded by the (now-asleep) device, so nothing paints. +- Scrypted misses the device's `sleep`: its own per-stream safety timer eventually tears down the ffmpeg stream, and in + the meantime any frames still on the wire are discarded by the (now-asleep) device, so nothing paints. - Device misses Scrypted's `wake`/`sleep`: same — the next state change on either side syncs them. Don't design Scrypted-side logic that waits for the device to confirm a state change. There is no confirmation. @@ -345,7 +404,8 @@ Don't design Scrypted-side logic that waits for the device to confirm a state ch - 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. +- Anything else (non-2xx, connection refused, DNS failure, request timeout) increments `state_post_failures` and is + otherwise ignored. **Timeouts** @@ -357,19 +417,23 @@ Don't design Scrypted-side logic that waits for the device to confirm a state ch - 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. +- 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 Scrypted's per-stream safety timer ending the stream. +- A dropped or failed POST is recovered by the next user tap, the device's idle timer firing `sleep`, or Scrypted's + per-stream safety timer ending the stream. - No retry queue. No backoff. No persistence across reboots. **When the device does NOT POST** - Before `/config` has registered a `scrypted` URL (boot state, NVS-erased). Silently dropped. -- For state changes Scrypted initiated (`POST /state`, `POST /frame` while asleep). Scrypted already knows; echoing would loop. +- For state changes Scrypted initiated (`POST /state`, `POST /frame` while asleep). Scrypted already knows; echoing + would loop. - Long-press info overlay (not a state change). **Trust model** @@ -378,24 +442,37 @@ 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. +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. +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 is a no-op when the recipient is already in the requested state, and frames (streamed on the data socket, or a `POST /frame`) never paint while the device is asleep, so a stale stream can't re-wake it. +Idempotency does the rest — `POST /state` on either side is a no-op when the recipient is already in the requested +state, and frames (streamed on the data socket, or a `POST /frame`) never paint while the device is asleep, so a stale +stream can't re-wake it. ## Idle -After `idle_timeout_ms` (default 60s) with no painted frame (a streamed frame over the data socket, or a `/frame` POST), 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. +After `idle_timeout_ms` (default 60s) with no painted frame (a streamed frame over the data socket, or a `/frame` POST), +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 -All endpoints are safe to retry. Every state-change path converges to the same final state regardless of how many times it's repeated or in what order: +All endpoints are safe to retry. Every state-change path converges to the same final state regardless of how many times +it's repeated or in what order: | Endpoint | Idempotent? | Notes | | --- | --- | --- | @@ -406,7 +483,9 @@ All endpoints are safe to retry. Every state-change path converges to the same f | `POST /frame` | yes (within state) | paints if awake; `409` if asleep — no partial state | | Stream socket (`:81`) | yes (within state) | frames paint if awake, are discarded if asleep — no partial state; skip-oldest keeps only the freshest | -`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 or by Scrypted's own per-stream safety timer. +`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 or by Scrypted's own per-stream safety +timer. Failure modes do not corrupt state: @@ -417,10 +496,14 @@ Failure modes do not corrupt state: ## Touch gestures -The board has no usable user button (GPIO 35 is owned by EMAC TXD1 at runtime), so both behaviours live on the touch panel: +The board has no usable user button (GPIO 35 is owned by EMAC TXD1 at runtime), so both behaviours live on the touch +panel: - **Short tap** (<500 ms): toggle wake / sleep. POSTs `/state` to Scrypted when configured. -- **Long-press** (≥1.5 s): overlay the info 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). +- **Long-press** (≥1.5 s): overlay the info 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). There is no factory-reset gesture. To wipe NVS, plug USB and run `idf.py erase-flash` followed by a normal reflash. @@ -428,23 +511,44 @@ There is no factory-reset gesture. To wipe NVS, plug USB and run `idf.py erase-f The device renders exactly two things itself; everything else is a JPEG from Scrypted: -- **Info screen**: ~17 lines of `label value` pairs (white on black, auto-scaled) covering the full `GET /config` + `GET /state` dump — name, mac, host, ip, state, configured, scrypted, orientation, brightness, idle, firmware, uptime, frames, errors, free heap, free PSRAM, chip temperature. Shown on first boot until `/config`, on NVS erase, and as a 15 s overlay on a touch long-press. -- **Loading screen**: shown from a wake — and re-shown on each new stream connection — until the first frame paints, so a stale prior frame never flashes during the connect→first-frame gap. Plain "Loading…" text. Rendered in the current orientation. +- **Info screen**: ~17 lines of `label value` pairs (white on black, auto-scaled) covering the full `GET /config` + + `GET /state` dump — name, mac, host, ip, state, configured, scrypted, orientation, brightness, idle, firmware, uptime, + frames, errors, free heap, free PSRAM, chip temperature. Shown on first boot until `/config`, on NVS erase, and as a + 15 s overlay on a touch long-press. +- **Loading screen**: shown from a wake — and re-shown on each new stream connection — until the first frame paints, so + a stale prior frame never flashes during the connect→first-frame gap. Plain "Loading…" text. Rendered in the current + orientation. -Both use a small embedded bitmap font — full lowercase a–z, digits, period, colon, dash, slash, plus uppercase `L` for "Loading...". No LVGL, no general text engine. +Both use a small embedded bitmap font — full lowercase a–z, digits, period, colon, dash, slash, plus uppercase `L` for +"Loading...". No LVGL, no general text engine. ## Scrypted Integration -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** (in this repo at [`scrypted/scrypted-viewport.ts`](scrypted/scrypted-viewport.ts), install instructions in [`scrypted/README.md`](scrypted/README.md)): a Scrypted Script (in the Scripts plugin) — subscribes to the bound camera's events (only the interfaces the selected wake triggers need) and, on wake, spawns one `ffmpeg` child (via `MediaManager`) that pulls the camera's substream, scales/rotates to panel-native 800×480, and streams MJPEG frames to the firmware over a **raw TCP data socket (port 81)** at ~24 fps. It also exposes a `POST /state` handler at the plugin's endpoint root (e.g. `http://scrypted.local:11080/endpoint/scrypted-viewport/state`) via the EndpointManager. Single-file TypeScript, no package install. -- **Fast start via prebuffer**: the script requests the camera's prebuffered substream so ffmpeg opens on an already-buffered keyframe, cutting wake-to-first-frame from ~5–6 s to ~0.7 s. Requires a rebroadcast prebuffer on the streamed substream — see [`scrypted/README.md`](scrypted/README.md#fast-wake--camera-prebuffer-required). -- **Built-in discovery**: the script browses `_scrypted-viewport._tcp` itself (dependency-free legacy-unicast `dgram` query — see [Discovery](#discovery)), offers discovered viewports as host-field choices, names new viewports from the advertised TXT `name`, and auto-heals a viewport's stored host when a registration fails after a DHCP renumber. -- **Next (v2)**: repackage the single-file script as a proper installable plugin — planned in [`scrypted/PLUGIN-CONVERSION.md`](scrypted/PLUGIN-CONVERSION.md). The streaming path itself (ffmpeg → framed MJPEG over the TCP data socket) is already in place; the firmware's `POST /frame` remains for one-shot snapshots and debug. +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: + +- **The script** (in this repo at [`scrypted/scrypted-viewport.ts`](scrypted/scrypted-viewport.ts), install instructions + in [`scrypted/README.md`](scrypted/README.md)): a Scrypted Script (in the Scripts plugin) — subscribes to the bound + camera's events (only the interfaces the selected wake triggers need) and, on wake, spawns one `ffmpeg` child (via + `MediaManager`) that pulls the camera's substream, scales/rotates to panel-native 800×480, and streams MJPEG frames to + the firmware over a **raw TCP data socket (port 81)** at ~24 fps. It also exposes a `POST /state` handler at the + plugin's endpoint root (e.g. `http://scrypted.local:11080/endpoint/scrypted-viewport/state`) via the EndpointManager. + Single-file TypeScript, no package install. +- **Fast start via prebuffer**: the script requests the camera's prebuffered substream so ffmpeg opens on an + already-buffered keyframe, cutting wake-to-first-frame from ~5–6 s to ~0.7 s. Requires a rebroadcast prebuffer on the + streamed substream — see [`scrypted/README.md`](scrypted/README.md#fast-wake--camera-prebuffer-required). +- **Built-in discovery**: the script browses `_scrypted-viewport._tcp` itself (dependency-free legacy-unicast `dgram` + query — see [Discovery](#discovery)), offers discovered viewports as host-field choices, names new viewports from the + advertised TXT `name`, and auto-heals a viewport's stored host when a registration fails after a DHCP renumber. +- **Next (v2)**: repackage the single-file script as a proper installable plugin — planned in + [`scrypted/PLUGIN-CONVERSION.md`](scrypted/PLUGIN-CONVERSION.md). The streaming path itself (ffmpeg → framed MJPEG + over the TCP data socket) is already in place; the firmware's `POST /frame` remains for one-shot snapshots and debug. Either way, no Scrypted core changes and no external service. -Scrypted owns a static list of viewports, each **bound to one Scrypted camera device** in the script/plugin. The binding tells Scrypted which camera's events drive that viewport (doorbell press, person/motion detection, etc.) and which camera's frames to push to it. +Scrypted owns a static list of viewports, each **bound to one Scrypted camera device** in the script/plugin. The binding +tells Scrypted which camera's events drive that viewport (doorbell press, person/motion detection, etc.) and which +camera's frames to push to it. On startup, register every viewport: @@ -464,7 +568,8 @@ await fetch(`${v.url}/config`, { }); ``` -To start a session (camera event like doorbell, motion, person): POST `wake`, then open the TCP data socket and pipe MJPEG frames straight to it — no per-frame HTTP. +To start a session (camera event like doorbell, motion, person): POST `wake`, then open the TCP data socket and pipe +MJPEG frames straight to it — no per-frame HTTP. ```ts await fetch(`${v.url}/state`, { @@ -496,23 +601,35 @@ if (state === "sleep") stopStream(v); // idempotent res.status(204).end(); ``` -- `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=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. 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. When the device idle-sleeps itself it POSTs `{state: "sleep"}` back, and Scrypted tears down the stream; if that callback is lost, the Scrypted-side safety timer ends the stream anyway. +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. When the device idle-sleeps itself it POSTs +`{state: "sleep"}` back, and Scrypted tears down the stream; if that callback is lost, the Scrypted-side safety timer +ends the stream anyway. ## Ops -- Firmware updates: `POST /firmware` with the raw built `.bin` (see [POST /firmware](#post-firmware)). First flash of any new device still needs USB to install the bootloader + initial image; every update after that is over the LAN. Rollback is armed — a panicking new image reverts to the previous slot on next reset. -- Provisioning: flash the same firmware to every device. On first boot it advertises itself via mDNS and shows the info screen; in Scrypted, "+ Add Device" lists it in the host dropdown (name auto-fills from the advertisement) — no IP hunting needed. -- Viewport names must be unique across the LAN — mDNS hostnames are derived from `viewport` and two devices configured with the same name will collide. -- NVS wipe: plug USB and run `idf.py erase-flash` followed by `idf.py flash`. The device boots clean and shows the info screen until `/config` is POSTed. +- Firmware updates: `POST /firmware` with the raw built `.bin` (see [POST /firmware](#post-firmware)). First flash of + any new device still needs USB to install the bootloader + initial image; every update after that is over the LAN. + Rollback is armed — a panicking new image reverts to the previous slot on next reset. +- Provisioning: flash the same firmware to every device. On first boot it advertises itself via mDNS and shows the info + screen; in Scrypted, "+ Add Device" lists it in the host dropdown (name auto-fills from the advertisement) — no IP + hunting needed. +- Viewport names must be unique across the LAN — mDNS hostnames are derived from `viewport` and two devices configured + with the same name will collide. +- NVS wipe: plug USB and run `idf.py erase-flash` followed by `idf.py flash`. The device boots clean and shows the info + screen until `/config` is POSTed. - No DHCP lease: keep retrying; do not reboot. The info screen shows "ip no network" until a lease arrives. -- Ethernet disconnect: reconnect automatically. If Scrypted is unreachable, displays go stale — nothing the device can do about it. +- 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. -- No usable on-board status LED on the Waveshare ESP32-P4-ETH. The info screen tells the boot story instead — short-tap to wake, long-press to overlay it. +- No usable on-board status LED on the Waveshare ESP32-P4-ETH. The info screen tells the boot story instead — short-tap + to wake, long-press to overlay it. ## Build @@ -526,9 +643,8 @@ idf.py build idf.py -p /dev/cu.usbmodem* flash monitor ``` -Every update after that is one command over the LAN — build, OTA push, -and rollback-checked verification (see [POST /firmware](#post-firmware) -for what it does and the underlying curl): +Every update after that is one command over the LAN — build, OTA push, and rollback-checked verification (see [POST +/firmware](#post-firmware) for what it does and the underlying curl): ```sh make ota [VIEWPORT=<host>] @@ -558,23 +674,43 @@ make ota [VIEWPORT=<host>] ### Memory strategy -The Waveshare board ships with 32 MB PSRAM. Everything large lives there: the stream body ring (3 × ~1 MB JPEG input buffers), the three `esp_lcd_dpi` BGR888 framebuffers (3 × 1.15 MB — the JPEG decoder writes directly into these, there is no separate decoder output buffer), and the local-screens scratch (~1.15 MB). Internal SRAM is reserved for FreeRTOS task stacks, EMAC DMA buffers, and small allocations — `CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=16384` keeps allocations ≤ 16 KB in SRAM by default. +The Waveshare board ships with 32 MB PSRAM. Everything large lives there: the stream body ring (3 × ~1 MB JPEG input +buffers), the three `esp_lcd_dpi` BGR888 framebuffers (3 × 1.15 MB — the JPEG decoder writes directly into these, there +is no separate decoder output buffer), and the local-screens scratch (~1.15 MB). Internal SRAM is reserved for FreeRTOS +task stacks, EMAC DMA buffers, and small allocations — `CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=16384` keeps allocations ≤ +16 KB in SRAM by default. ### Display strategy -JPEG → BGR888 → DSI panel, **triple-buffered**, zero-copy and tear-free. No LVGL, no general text engine. The only locally-drawn UI is the IP screen, the Loading screen, and the info overlay (all via the 8×8 bitmap font in `local_screens.c`). Brightness PWM is gamma-corrected — `duty = (level/100)^2.2 * 255` — so 0–100 maps to perceptual brightness instead of linear duty cycle. - -**Why three framebuffers.** The DPI driver owns the framebuffers (`num_fbs = 3`); the hardware JPEG decoder writes straight into one of them, so `esp_lcd_panel_draw_bitmap` takes the IDF fast path — a cache writeback plus an index swap (~40 µs), no memcpy anywhere. But that index swap is *deferred*: the DSI DMA only reloads the new index at the **end of the frame scan in progress** (~21 ms period at ~47 Hz). With two buffers, flipping and immediately decoding the next frame writes into the buffer the DMA is still scanning out — a torn frame. That regime is common once frames arrive back-to-back (measured: ~6% of painted frames at full stream rate). - -Waiting for vsync before decoding would fix it at the cost of up to one refresh period of latency per frame. Instead, the three buffers hold three roles — **scanning** (DMA is reading it), **pending** (flipped, displays at the next boundary), **free** — and `display_back_buffer()` always hands the decoder the free one. The scanning buffer is tracked from the driver's `on_refresh_done` ISR (fires exactly when the DMA reloads its index). Three buffers minus at most two excluded roles = always a safe decode target: tear-free by construction, with zero waiting. `/state` reports `tear_guard_engaged` — picks made while the previous buffer was still mid-scan, i.e. frames that would have torn under double buffering. Cost: one extra 1.15 MB PSRAM framebuffer. +JPEG → BGR888 → DSI panel, **triple-buffered**, zero-copy and tear-free. No LVGL, no general text engine. The only +locally-drawn UI is the IP screen, the Loading screen, and the info overlay (all via the 8×8 bitmap font in +`local_screens.c`). Brightness PWM is gamma-corrected — `duty = (level/100)^2.2 * 255` — so 0–100 maps to perceptual +brightness instead of linear duty cycle. + +**Why three framebuffers.** The DPI driver owns the framebuffers (`num_fbs = 3`); the hardware JPEG decoder writes +straight into one of them, so `esp_lcd_panel_draw_bitmap` takes the IDF fast path — a cache writeback plus an index swap +(~40 µs), no memcpy anywhere. But that index swap is *deferred*: the DSI DMA only reloads the new index at the **end of +the frame scan in progress** (~21 ms period at ~47 Hz). With two buffers, flipping and immediately decoding the next +frame writes into the buffer the DMA is still scanning out — a torn frame. That regime is common once frames arrive +back-to-back (measured: ~6% of painted frames at full stream rate). + +Waiting for vsync before decoding would fix it at the cost of up to one refresh period of latency per frame. Instead, +the three buffers hold three roles — **scanning** (DMA is reading it), **pending** (flipped, displays at the next +boundary), **free** — and `display_back_buffer()` always hands the decoder the free one. The scanning buffer is tracked +from the driver's `on_refresh_done` ISR (fires exactly when the DMA reloads its index). Three buffers minus at most two +excluded roles = always a safe decode target: tear-free by construction, with zero waiting. `/state` reports +`tear_guard_engaged` — picks made while the previous buffer was still mid-scan, i.e. frames that would have torn under +double buffering. Cost: one extra 1.15 MB PSRAM framebuffer. ### Error handling Every endpoint is idempotent; every failure leaves the device in a sane state. - JPEG decode fails → `decode_errors++`, return 400 or 500, keep previous frame on screen, wake/sleep state unchanged. -- Outbound `/state` POST fails → `state_post_failures++`, continue. Local state change still happened. No retry queue; Scrypted catches up via its own per-stream safety timer or the next event. -- Ethernet disconnects → driver reconnects automatically. No reboot loop. `GET /state` keeps serving over loopback for diagnostics. +- Outbound `/state` POST fails → `state_post_failures++`, continue. Local state change still happened. No retry queue; + Scrypted catches up via its own per-stream safety timer or the next event. +- Ethernet disconnects → driver reconnects automatically. No reboot loop. `GET /state` keeps serving over loopback for + diagnostics. - Display init fails → log loudly, keep serving the rest of the API. The protocol is still usable for re-registration. - Task hangs → ESP-IDF watchdog reboots. NVS rebuilds soft state on the next boot. @@ -589,11 +725,16 @@ Every endpoint is idempotent; every failure leaves the device in a sane state. ## What's next -M1 – M9 are all ✅ on hardware (see [`TESTING.md`](TESTING.md) for verification details). End-to-end Scrypted streaming via ffmpeg + the zero-copy `JPEG → BGR888 → DSI` hot path now sustains **painted = sent = 24 fps** at the Unifi medium substream rate, sub-50 ms glass-to-glass, no source-side backpressure. +M1 – M9 are all ✅ on hardware (see [`TESTING.md`](TESTING.md) for verification details). End-to-end Scrypted streaming +via ffmpeg + the zero-copy `JPEG → BGR888 → DSI` hot path now sustains **painted = sent = 24 fps** at the Unifi medium +substream rate, sub-50 ms glass-to-glass, no source-side backpressure. ### Measured per-frame budget -The firmware streams over a long-lived raw TCP socket on port 81 (replacing the per-frame HTTP `POST /frame` pattern from the early milestones). The stream server runs `recv` on its own FreeRTOS task and hands frames off to a separate decode/paint task through a 3-buffer PSRAM ping-pong ring. Steady-state on the bench (Waveshare ESP32-P4-ETH + Hosyond 5" panel, Unifi medium substream → ~80–130 KB JPEGs at ffmpeg `-q:v 1`): +The firmware streams over a long-lived raw TCP socket on port 81 (replacing the per-frame HTTP `POST /frame` pattern +from the early milestones). The stream server runs `recv` on its own FreeRTOS task and hands frames off to a separate +decode/paint task through a 3-buffer PSRAM ping-pong ring. Steady-state on the bench (Waveshare ESP32-P4-ETH + Hosyond +5" panel, Unifi medium substream → ~80–130 KB JPEGs at ffmpeg `-q:v 1`): | Phase | Time | Share | What it is | |---|---|---|---| @@ -602,34 +743,64 @@ The firmware streams over a long-lived raw TCP socket on port 81 (replacing the | `paint` | 16–60 µs | < 0.2% | `esp_lcd_panel_draw_bitmap` — cache writeback + index swap thanks to `num_fbs = 3` and zero-copy decode into the free fb (see *Display strategy* for the tear-free triple-buffer model) | | `decode_idle` | **27–40 ms** | n/a | Time decode-task spent waiting on the recv→decode signal. Means the decode/paint stage is *idle* most of the time at the source rate — the wire is the cap. | -Scrypted side: `sent=24.0fps painted=24.0fps backpressured=false`, `g2g=31–41 ms`. No `fw-skipped`, no `drops`, no `flushes`. +Scrypted side: `sent=24.0fps painted=24.0fps backpressured=false`, `g2g=31–41 ms`. No `fw-skipped`, no `drops`, no +`flushes`. ### The critical change that unlocked this -For a long stretch the device painted ~17 fps against a 24 fps source. The single-task `recv → decode → paint` loop in `stream_server.c` blocked the socket recv for ~6 ms every frame during decode+paint, which forced the sender into a tight stop-go cycle against the IDF-default 5760-byte TCP window. Raising the window to 65535 made it *worse* — the sender could pump 45+ segments before stopping but the lwIP RX path couldn't drain that into the single task, so the kernel buffer accumulated stale frames and `g2g` grew unbounded (one experiment hit 17 *seconds* before we reverted). +For a long stretch the device painted ~17 fps against a 24 fps source. The single-task `recv → decode → paint` loop in +`stream_server.c` blocked the socket recv for ~6 ms every frame during decode+paint, which forced the sender into a +tight stop-go cycle against the IDF-default 5760-byte TCP window. Raising the window to 65535 made it *worse* — the +sender could pump 45+ segments before stopping but the lwIP RX path couldn't drain that into the single task, so the +kernel buffer accumulated stale frames and `g2g` grew unbounded (one experiment hit 17 *seconds* before we reverted). -The fix wasn't a TCP knob, it was the task architecture. `d1c8d45` split `handle_client` into a **dedicated recv-task** (owns the socket, drains continuously) and a **decode-task** (waits on a binary semaphore), with a **3-buffer PSRAM ring**: recv-task fills one buffer, decode-task processes a second, the third is either free or holds a pending frame between them. The ring guarantees recv-task never blocks waiting on decode, and a 1-deep latest-frame slot lets the receiver skip-oldest if decode ever falls behind (mirror of the Scrypted-side skip-oldest in `e5acf93`). After the split, recv-task is busy ~30% of the time and decode-task is idle ~80% — the wire is now the only thing setting the rate, and at 24 fps source it is genuinely keeping up. +The fix wasn't a TCP knob, it was the task architecture. `d1c8d45` split `handle_client` into a **dedicated recv-task** +(owns the socket, drains continuously) and a **decode-task** (waits on a binary semaphore), with a **3-buffer PSRAM +ring**: recv-task fills one buffer, decode-task processes a second, the third is either free or holds a pending frame +between them. The ring guarantees recv-task never blocks waiting on decode, and a 1-deep latest-frame slot lets the +receiver skip-oldest if decode ever falls behind (mirror of the Scrypted-side skip-oldest in `e5acf93`). After the +split, recv-task is busy ~30% of the time and decode-task is idle ~80% — the wire is now the only thing setting the +rate, and at 24 fps source it is genuinely keeping up. -The full instrumentation that drove the diagnosis (`queued_at_body_start`, `recv_calls`, `recv_chunk_min/avg/max`, `recv_dropped_oldest`, `decode_idle_*`, `so_rcvbuf`) is still in `/state` and the windowed log. +The full instrumentation that drove the diagnosis (`queued_at_body_start`, `recv_calls`, `recv_chunk_min/avg/max`, +`recv_dropped_oldest`, `decode_idle_*`, `so_rcvbuf`) is still in `/state` and the windowed log. ### TCP window + EMAC tuning (the follow-up the task split unlocked) -With recv on its own task and a skip-oldest slot, the window became safe to raise — but "safe" had to be proven, so first the stream gained a decomposition that accounts for the whole frame interval: `interval ≈ hdr_gap (sender idle) + recv (wire) + pend_age (handoff wait) + dec + paint`, plus `wire_*_kbps` — the instantaneous throughput while a body drains, whose ceiling is `TCP_WND / RTT`, making it the definitive "is the window the limiter" metric. +With recv on its own task and a skip-oldest slot, the window became safe to raise — but "safe" had to be proven, so +first the stream gained a decomposition that accounts for the whole frame interval: `interval ≈ hdr_gap (sender idle) + +recv (wire) + pend_age (handoff wait) + dec + paint`, plus `wire_*_kbps` — the instantaneous throughput while a body +drains, whose ceiling is `TCP_WND / RTT`, making it the definitive "is the window the limiter" metric. -Measured at the default `TCP_WND=5760` (~190 KB frames): wire pinned at 53 Mbps vs ~94 Mbps line rate on the 10/100 PHY, `recv_chunk_max` at exactly 5760, sender backpressured on half its frames — window-bound, three ways. +Measured at the default `TCP_WND=5760` (~190 KB frames): wire pinned at 53 Mbps vs ~94 Mbps line rate on the 10/100 PHY, +`recv_chunk_max` at exactly 5760, sender backpressured on half its frames — window-bound, three ways. -Raising to `TCP_WND=23040` alone **regressed** (fps 20 → 14, 200–450 ms recv stalls): the EMAC RX DMA pool (20 × 512 B = 10 KB) was smaller than the in-flight window, so a full-window burst overran the RX descriptors, the burst tail dropped with no dup-ACKs behind it, and the sender waited out ~200 ms min-RTO recoveries. **Invariant: the EMAC RX pool must exceed `TCP_WND`.** With `ETH_DMA_BUFFER_SIZE=1600` (one MSS frame per buffer/descriptor) × 24 = 38.4 KB, the stall tail vanished: wire 74 avg / 84 max Mbps, recv 21 ms, painted fps +19%, g2g 73 → ~40–60 ms. +Raising to `TCP_WND=23040` alone **regressed** (fps 20 → 14, 200–450 ms recv stalls): the EMAC RX DMA pool (20 × 512 B = +10 KB) was smaller than the in-flight window, so a full-window burst overran the RX descriptors, the burst tail dropped +with no dup-ACKs behind it, and the sender waited out ~200 ms min-RTO recoveries. **Invariant: the EMAC RX pool must +exceed `TCP_WND`.** With `ETH_DMA_BUFFER_SIZE=1600` (one MSS frame per buffer/descriptor) × 24 = 38.4 KB, the stall tail +vanished: wire 74 avg / 84 max Mbps, recv 21 ms, painted fps +19%, g2g 73 → ~40–60 ms. -Stopped there deliberately: the interval is now ~half `hdr_gap` (sender has nothing ready), so bigger windows buy little until the source rate rises. `pend_age_*` remains the tripwire — it must stay in microseconds; growth means the kernel-queue latency backlog that killed the pre-split 65535 experiment is back. +Stopped there deliberately: the interval is now ~half `hdr_gap` (sender has nothing ready), so bigger windows buy little +until the source rate rises. `pend_age_*` remains the tripwire — it must stay in microseconds; growth means the +kernel-queue latency backlog that killed the pre-split 65535 experiment is back. ### Current backlog, in rough priority order -1. **(was) Network body shrink — done.** The receiver now drains near line rate without blocking decode: TCP window raised to 23040 with the EMAC RX pool sized above it (see *TCP window + EMAC tuning*), wire 74 Mbps avg. Source rate (Unifi substream `medium-resolution` ≈ 24 fps) is the cap. -2. **(was) OTA firmware updates — done.** `POST /firmware` ships in `175dd50`, ota_0/ota_1 alternation with `BOOTLOADER_APP_ROLLBACK_ENABLE` for first-boot revert. `curl --data-binary @build/scrypted-viewport.bin http://<viewport>/firmware` reboots into the new image. -3. **Task watchdog + crash counters** — *low effort*. Enable the ESP-IDF task watchdog, surface its bite count in `/state` alongside the existing `decode_errors` / `state_post_failures`. Good hygiene. -4. **Multi-camera per viewport** — *medium effort*. Let one viewport listen to events from N cameras, picking which one to stream based on which fired. Useful for "show whichever doorbell rang" or zone monitoring. -5. **Boot info-screen flash polish** — *low effort*. Keep the brief wake-on-boot (the FT5426 needs it to start reporting touches) but smoothen the ~600 ms flash so it doesn't visibly flicker on power-up. -6. **Production sealing** — *eventual*. Configurable LAN scope (cross-VLAN, mDNS-via-Unicast), Scrypted-side mutual auth, replay protection for `/state` callbacks. +1. **(was) Network body shrink — done.** The receiver now drains near line rate without blocking decode: TCP window + raised to 23040 with the EMAC RX pool sized above it (see *TCP window + EMAC tuning*), wire 74 Mbps avg. Source rate + (Unifi substream `medium-resolution` ≈ 24 fps) is the cap. +2. **(was) OTA firmware updates — done.** `POST /firmware` ships in `175dd50`, ota_0/ota_1 alternation with + `BOOTLOADER_APP_ROLLBACK_ENABLE` for first-boot revert. `curl --data-binary @build/scrypted-viewport.bin + http://<viewport>/firmware` reboots into the new image. +3. **Task watchdog + crash counters** — *low effort*. Enable the ESP-IDF task watchdog, surface its bite count in + `/state` alongside the existing `decode_errors` / `state_post_failures`. Good hygiene. +4. **Multi-camera per viewport** — *medium effort*. Let one viewport listen to events from N cameras, picking which one + to stream based on which fired. Useful for "show whichever doorbell rang" or zone monitoring. +5. **Boot info-screen flash polish** — *low effort*. Keep the brief wake-on-boot (the FT5426 needs it to start reporting + touches) but smoothen the ~600 ms flash so it doesn't visibly flicker on power-up. +6. **Production sealing** — *eventual*. Configurable LAN scope (cross-VLAN, mDNS-via-Unicast), Scrypted-side mutual + auth, replay protection for `/state` callbacks. ## Philosophy |
