diff options
| author | Luke Hoersten <[email protected]> | 2026-06-13 20:22:11 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-06-13 20:22:11 -0500 |
| commit | 0e315ea5069277a89a3330fedf702bd40f39ab09 (patch) | |
| tree | 2041b788f3b6000816870a710f6882cd7b66d04b /README.md | |
| parent | 869b47d1feb3f2f03c95d785d7aef270f31fb054 (diff) | |
Tighten protocol; add /wake, callback types, and orientation
State model is now strictly wake/sleep, owned by the device:
- Local triggers: tap (toggle), idle timeout (sleep only).
- Remote triggers: POST /wake, POST /sleep. Scrypted decides why
(doorbell, motion, person, schedule) — the device does not know.
- /frame is pure paint: 204 when awake, 409 when asleep, no implicit
wake. Eliminates the race where an in-flight frame could re-wake a
device that just slept on a tap.
Orientation:
- Configurable via /config; default portrait.
- Effective resolution: 480x800 portrait, 800x480 landscape.
- Scrypted must render at the effective resolution. Device does not
rotate or scale JPEG content.
- Orientation flows through mDNS TXT, /health, IP/Loading screens.
Callbacks:
- Add `type` field carrying the cause: tap or timeout (forward-compat
for swipes etc).
- Drop wall-clock timestamp (no RTC, no SNTP).
Other:
- Boot: configured device boots to sleep (backlight off).
- /health adds state, last_frame_ms_ago, frame counters, error
counters, brightness, orientation, idle_timeout_ms.
- idle_timeout_ms: 0 disables; non-zero must be >= 5000; else 400.
- First-boot brightness default 80; apply gamma curve to PWM.
- Watchdog reboots hung tasks; recovers state from NVS.
- On-board status LED indicates network/config state.
- mDNS hostname-collision warning; ops notes.
- Idempotency table summarizing endpoint safety.
- Each viewport is bound 1:1 to a camera in Scrypted-side code.
- Reframe "no rendering engine" honestly as "no general-purpose UI
framework"; IP and Loading screens are the only locally-drawn UI.
Doc sync: v2 implementation guide endpoints, modules, milestones,
acceptance criteria, error handling, and tests all updated to match.
Diffstat (limited to 'README.md')
| -rw-r--r-- | README.md | 168 |
1 files changed, 116 insertions, 52 deletions
@@ -9,10 +9,10 @@ Scrypted Viewport is an Ethernet-powered ambient display appliance optimized for Design goals: - No Matter - No HomeKit -- No polling +- No device-side polling (the device never asks; Scrypted pushes) - No configuration UI - No authentication -- No rendering engine +- No general-purpose UI framework — the only locally-drawn screens are the IP screen and the Loading screen - No business logic on the device Scrypted owns rendering, overlays, camera selection and interaction logic. @@ -32,16 +32,19 @@ Waveshare ESP32-P4-ETH-POE Power -> DHCP -> mDNS (_scrypted-viewport._tcp.local) --> If unconfigured: show IP + hostname on screen --> Wait for `/config` and `/frame` - -Unconfigured display shows the device IP and `viewport.local` so the operator can register it in Scrypted via `POST /config`. Once configured, the screen goes blank and waits for the first `/frame`. +-> If unconfigured: backlight on, show IP + hostname (persistent until `/config`) +-> If configured: enter sleep state (backlight off, wait for `/wake` or a tap) ## Resolution -800x480 native. +Native panel: 800x480 landscape. + +Effective resolution depends on `orientation` (set via `/config`): -Scrypted always renders 800x480 JPEGs. +- `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. ## Network @@ -51,7 +54,8 @@ mDNS service: `_scrypted-viewport._tcp.local` advertised on port 80. mDNS TXT records: - `version=1.0.0` -- `resolution=800x480` +- `resolution=<effective>` (e.g. `480x800` for portrait, `800x480` for landscape) +- `orientation=<portrait|landscape>` - `name=<display name>` (empty until `/config`) Hostname: `viewport.local` before configuration, `viewport-<display>.local` after. @@ -69,45 +73,72 @@ Returns `200 OK` with JSON: "name": "mudroom", "version": "1.0.0", "configured": true, + "state": "awake", "uptime_ms": 12345678, - "resolution": "800x480", + "last_frame_ms_ago": 1234, + "frames_received": 4271, + "decode_errors": 0, + "callback_failures": 2, + "idle_timeout_ms": 60000, + "brightness": 80, + "orientation": "portrait", + "resolution": "480x800", "ip": "192.168.1.42", "free_heap": 123456, "free_psram": 12345678 } ``` +`state` is `awake`, `asleep`, or `unconfigured`. `last_frame_ms_ago` is `null` if no frame has been received since boot. + ### POST /config ```json { "display": "mudroom", "callback": "http://scrypted.local:11080/api/viewport/touch", - "idle_timeout_ms": 60000 + "idle_timeout_ms": 60000, + "orientation": "portrait" } ``` - Persisted to NVS, survives reboot. - Idempotent; subsequent calls atomically replace prior config. - `display` must be non-empty; `callback` must be `http://...`. -- `idle_timeout_ms` optional, default `60000`. Sets the device-side idle timeout. 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). +- `idle_timeout_ms` optional, default `60000`. `0` disables the idle timer entirely. 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` optional, default `portrait`. Values: `portrait` (480x800) or `landscape` (800x480). Changing orientation takes effect immediately, including for the IP and Loading screens. Scrypted must send JPEGs at the new effective resolution after a change. - Response: `204 No Content`. Invalid body: `400`. +### POST /wake + +Transitions the device to the wake state (backlight on, loading screen) without painting a frame. + +- Idempotent. Already-awake calls return `204` and do nothing. +- Resets the idle timer. +- No callback to Scrypted (Scrypted already knows — it initiated). +- Response: `204`. + +### POST /sleep + +Transitions the device to the sleep state. + +- Idempotent. Already-asleep calls return `204` and do nothing. +- Backlight off; framebuffer is discarded (not preserved across sleep). +- No callback to Scrypted. +- Response: `204`. + ### POST /frame +Paints a frame. Does **not** change wake/sleep state. + - `Content-Type: image/jpeg`, body is raw JPEG bytes. -- Image must be 800x480 baseline JPEG. Device does not scale or letterbox. +- 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. -- Wakes display (backlight on) and resets the 60s idle timer. +- **Requires awake state.** While asleep, returns `409 Conflict` and does not paint. Scrypted must `POST /wake` first (or wait for a `wake` callback from a tap). +- 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, `413` over size, `500` decode/display failure. On failure the previous frame stays on screen. - -### POST /sleep - -- Backlight off; framebuffer preserved. -- Device stays online. Next `/frame` wakes the display. There is no `/wake`. -- Response: `204`. +- `400` malformed JPEG, `409` device asleep, `413` over size, `500` decode/display failure. On failure the previous frame stays on screen. ### POST /brightness @@ -116,34 +147,33 @@ Returns `200 OK` with JSON: ``` - Range `0`–`100`. Out-of-range: `400`. +- Default on first boot: `80`. - Persisted to NVS. Applied immediately if awake, otherwise on next wake. -- Response: `204`. +- Idempotent. Response: `204`. ## Wake / Sleep -Wake and sleep couple the device backlight with the JPEG stream from Scrypted. - -- **Wake** = backlight on, frames being received from Scrypted. -- **Sleep** = backlight off, no frames expected. - -The device owns wake/sleep state. Scrypted does not — it just receives imperatives: +Wake and sleep couple the device backlight with Scrypted's frame stream. The device owns the state; Scrypted just receives idempotent imperatives: - `wake` → "start streaming to this display now" - `sleep` → "stop streaming to this display now" -Both are idempotent on Scrypted's side. Scrypted does not track per-viewport state; it acts on the event and forgets. +Scrypted does not track per-viewport state; it acts on the event and forgets. Repeat events are safe. Transitions: -| Trigger | Action | Callback to Scrypted | +| Trigger | Resulting state | Callback to Scrypted | | --- | --- | --- | -| Tap while asleep | Backlight on, show loading screen | `wake` | -| Tap while awake | Backlight off | `sleep` | -| Idle timer (`idle_timeout_ms` no `/frame`) | Backlight off | `sleep` | -| Scrypted `POST /sleep` | Backlight off | none | -| Scrypted `POST /frame` while asleep | Backlight on, paint frame | none | +| Tap while asleep | Awake (loading screen) | `wake` `type=tap` | +| Tap while awake | Asleep | `sleep` `type=tap` | +| Idle timer expires | Asleep | `sleep` `type=timeout` | +| `POST /wake` | Awake (loading screen) | none | +| `POST /sleep` | Asleep | none | +| `POST /frame` | (no state change) | — | + +`/frame` never changes state. Scrypted must `POST /wake` (or wait for a tap-driven `wake` 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. -Only `tap` is detected on the touchscreen — long-press and swipes are out of scope. `tap` is internal; the callback carries the resulting imperative. +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 `wake` or `sleep`. Callback body: @@ -151,27 +181,54 @@ Callback body: { "display": "mudroom", "event": "wake", - "timestamp": 1730000000 + "type": "tap" } ``` -`event` is `wake` or `sleep`. Delivery: best-effort, ~1s timeout, no retry. Events before `/config` registers a callback are dropped. +- `event` is `wake` or `sleep`. +- `type` carries the cause: `tap` (any user tap), `timeout` (idle expiry — `sleep` only). Future types (e.g. `swipe_left`) can be added without changing the schema. Scrypted may ignore `type` in v1. + +Delivery: best-effort, ~1s timeout, no retry. Events before `/config` registers a callback are dropped. If the callback POST fails, the local state change still happens — Scrypted catches up via its own timeout or the next event. + +No wall-clock timestamp is included. The device has no RTC and no SNTP. Scrypted timestamps events on receipt. ## Idle -After `idle_timeout_ms` (default 60s) with no `/frame`, the device sleeps and POSTs `sleep`. Scrypted should be configured with 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 `sleep` with `type=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. + +## 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: + +| Endpoint | Idempotent? | Notes | +| --- | --- | --- | +| `GET /health` | yes | side-effect free | +| `POST /config` | yes | atomically replaces prior config | +| `POST /wake` | yes | no-op if already awake | +| `POST /sleep` | yes | no-op if already asleep | +| `POST /frame` | yes (within state) | paints if awake; `409` if asleep — no partial state | +| `POST /brightness` | yes | value is overwritten | + +Callbacks (`wake`, `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`. + +Failure modes do not corrupt state: + +- Failed callback POST: local state still changes; counter 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. +- **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). - **Hold ≥5s**: factory reset — clear NVS, reboot. The device comes back unconfigured, showing the IP screen until Scrypted POSTs `/config`. ## Local rendering The device renders exactly two things itself; everything else is a JPEG from Scrypted: -- **IP screen**: IP address and `viewport.local` as plain centered text. Shown on first boot until `/config`, after factory reset, and as a 15s overlay when BOOT is short-pressed. -- **Loading screen**: shown between a wake and the next `/frame` arriving. Plain "Loading…" text. +- **IP screen**: IP address and `viewport.local` as plain centered text. Shown on first boot until `/config`, after factory reset, and as a 15s overlay when BOOT is short-pressed. Rendered in the current orientation (portrait by default). +- **Loading screen**: shown between a wake and the next `/frame` arriving. Plain "Loading…" text. Rendered in the current orientation. Both use a small embedded bitmap font. No LVGL, no general text engine. @@ -180,7 +237,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. -- **v2** (planned, not in this spec): a small custom plugin using FFmpeg via `MediaManager` to pipe MJPEG into a chunked `POST /stream`. Adds live-feel frame rates. +- **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. @@ -194,37 +251,44 @@ await fetch(`${viewport}/config`, { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ display: "mudroom", - callback: "http://scrypted.local:11080/api/viewport/touch" + callback: "http://scrypted.local:11080/api/viewport/touch", + idle_timeout_ms: 60000, + orientation: "portrait" }) }); ``` -Stream a session of frames to a viewport (in response to a camera event or a `wake` callback): +To start a session (camera event like doorbell, motion, person): ```ts +await fetch(`${viewport}/wake`, { method: "POST" }); // device shows loading +// then stream frames: await fetch(`${viewport}/frame`, { method: "POST", headers: { "Content-Type": "image/jpeg" }, - body: jpegBuffer // 800x480 baseline JPEG, <1 MB + body: jpegBuffer // baseline JPEG at the viewport's effective resolution, <1 MB }); ``` -Handle wake/sleep at `POST /api/viewport/touch`: +Handle wake/sleep callbacks at `POST /api/viewport/touch`: -- `wake` → look up the camera bound to `display`, start streaming frames. +- `wake` → look up the camera bound to `display`, start streaming frames. (You do not need to POST `/wake` first — the device is already awake when it sends this.) - `sleep` → stop streaming frames to `display`. -Both are idempotent. Don't track viewport state on the Scrypted side; act on the event and forget. +Both are idempotent on Scrypted's side. Don't track viewport state; act on the event and forget. Ignore the `type` field in v1. -Scrypted should use the same timeout 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. +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. ## Ops -- Firmware updates: reflash over USB. No OTA in v1. +- 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`. -- Factory reset: hold BOOT for 5s during normal operation to clear NVS (display name, callback, brightness) and reboot. The device returns to the IP screen. +- 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) 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. +- Status LED (on-board): solid = configured & online, slow blink = unconfigured (waiting for `/config`), fast blink = no network. The screen tells the same story but the LED is visible when the screen is asleep. ## Build |
