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 | |
| 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.
| -rw-r--r-- | README.md | 168 | ||||
| -rw-r--r-- | Scrypted-Viewport-v2-Claude-Code-Implementation-Guide.md | 389 |
2 files changed, 338 insertions, 219 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 diff --git a/Scrypted-Viewport-v2-Claude-Code-Implementation-Guide.md b/Scrypted-Viewport-v2-Claude-Code-Implementation-Guide.md index de34800..69f53ac 100644 --- a/Scrypted-Viewport-v2-Claude-Code-Implementation-Guide.md +++ b/Scrypted-Viewport-v2-Claude-Code-Implementation-Guide.md @@ -64,12 +64,21 @@ Expected capabilities: 5" 800×480 IPS capacitive-touch MIPI DSI display, Raspberry Pi-compatible Hosyond/Amazon model. -Native resolution: +Native panel resolution: ```text -800x480 +800x480 (landscape) ``` +Effective resolution depends on `orientation` set via `/config`: + +```text +portrait (default) -> 480x800 +landscape -> 800x480 +``` + +Orientation is applied during framebuffer-to-panel push. JPEGs from Scrypted must match the effective resolution; the device never rotates or scales JPEG content. + Firmware should target this exact hardware. No runtime display detection is required. --- @@ -93,53 +102,52 @@ power on -> wait for API calls ``` -Frame flow: +Wake/sleep model: + +Wake and sleep couple the backlight with Scrypted's frame stream. The two move together, never independently. The device owns the state. ```text -Scrypted renders 800x480 JPEG - -> HTTP POST /frame - -> Scrypted Viewport receives raw JPEG - -> decode JPEG into framebuffer/display buffer - -> push to DSI display - -> turn on backlight - -> reset idle timer +awake = backlight on, Scrypted streaming frames +asleep = backlight off, Scrypted not streaming ``` -Wake/sleep model: - -Wake and sleep couple the backlight with Scrypted's frame stream. The two move together, never independently. +State changes are strictly limited to these triggers: ```text -wake = backlight on + Scrypted streaming frames -sleep = backlight off + Scrypted not streaming +tap while asleep -> awake (POST wake/tap callback) +tap while awake -> asleep (POST sleep/tap callback) +idle timer expires -> asleep (POST sleep/timeout callback) +POST /wake (asleep) -> awake (no callback) +POST /sleep (awake) -> asleep (no callback) ``` -Touch flow: +`/frame` never changes state. It paints if awake, returns `409` if asleep. This eliminates the race where a frame in flight could re-wake a device that just slept on a tap. -```text -tap while asleep: - backlight on - render loading screen - POST {"event":"wake"} -> Scrypted starts streaming - next /frame replaces loading screen +Scrypted-initiated state changes (`/wake`, `/sleep`) do not echo a callback. -tap while awake: - backlight off - POST {"event":"sleep"} -> Scrypted stops streaming +Frame flow (awake state only): -idle timer fires (60s with no /frame): - backlight off - POST {"event":"sleep"} -> Scrypted stops streaming +```text +Scrypted POST /frame + -> Scrypted Viewport receives raw JPEG + -> decode JPEG into framebuffer + -> push to DSI display + -> reset idle timer + -> respond 204 ``` -The callback events are `wake` and `sleep`; `tap` itself is internal. The events are idempotent imperatives — "start streaming" / "stop streaming" — not state notifications. Scrypted does not track per-viewport state; it acts on the event and forgets. +The callback events are `wake` and `sleep`, each with a `type` field carrying the cause: +- `wake.type`: `tap` (future: `swipe_left`, etc.) +- `sleep.type`: `tap`, `timeout` (future: more) -Scrypted-initiated `/sleep` and `/frame` do not echo a callback. +Events are idempotent imperatives — "start streaming" / "stop streaming" — not state notifications. Scrypted does not track per-viewport state. Scrypted may ignore `type` in v1. Only `tap` is detected on the touchscreen. Long-press and swipes are out of scope for v1. +No wall-clock timestamps. The device has no RTC and no SNTP. + BOOT button: -- Short press: overlay IP screen for 15s, then return to prior state. Wakes backlight temporarily but does not change wake/sleep state and does not POST a callback. +- Short press: overlay IP screen for 15s, then return to prior state. Wakes backlight for the overlay but does not change wake/sleep state and does not POST a callback. Incoming `/frame` during the overlay is rejected `409`. - Hold ≥5s: clear NVS and reboot. Device comes back unconfigured. --- @@ -178,14 +186,24 @@ Example response: "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. + Status code: ```text @@ -202,7 +220,8 @@ Request body: { "display": "mudroom", "callback": "http://scrypted.local:11080/api/viewport/touch", - "idle_timeout_ms": 60000 + "idle_timeout_ms": 60000, + "orientation": "portrait" } ``` @@ -210,7 +229,8 @@ Behavior: - Store display name. - Store callback URL. -- Store idle timeout (optional in body, default 60000). +- Store idle timeout (optional, default 60000). +- Store orientation (optional, default `portrait`). Apply immediately, including to the IP/Loading screens and to the effective resolution reported in `/health` and mDNS TXT. - Persist all to NVS so it survives reboot. - May be called repeatedly. - Replaces old config atomically. @@ -225,109 +245,72 @@ Validation: - `display` must be non-empty. - `callback` must be HTTP URL. +- `idle_timeout_ms`: `0` disables the idle timer, non-zero must be ≥ `5000`. Anything else returns `400`. +- `orientation`: must be `portrait` or `landscape`. Anything else returns `400`. - HTTPS is not required for v1/v2. -### 5.3 POST /frame - -Displays a full-frame JPEG. - -Headers: - -```text -Content-Type: image/jpeg -``` - -Body: - -```text -raw JPEG bytes -``` - -Expected image: +### 5.3 POST /wake -```text -800x480 JPEG -``` +Transitions the device to the awake state. Behavior: -- Accept raw JPEG body. -- Decode. -- Display. -- Turn on backlight. -- Reset idle timer. -- Return once frame is accepted/displayed. +- If already awake: no-op, return `204`. +- If asleep: backlight on, render loading screen, reset idle timer. +- Do not POST a callback (Scrypted initiated). -Response: +Response: `204 No Content`. -```text -204 No Content -``` +### 5.4 POST /sleep -Failure responses: +Transitions the device to the asleep state. -```text -400 Bad Request invalid content-type or malformed JPEG -413 Payload Too Large JPEG exceeds configured maximum -500 Internal Error decode/display failure -``` +Behavior: -Recommended max JPEG size: +- If already asleep: no-op, return `204`. +- If awake: backlight off. Framebuffer is discarded (not preserved across sleep). +- Do not POST a callback. -```text -1 MB -``` +Response: `204 No Content`. -Implementation note: +### 5.5 POST /frame -Scrypted is responsible for scaling/cropping/composition. Scrypted Viewport should not scale images in v1/v2. - -### 5.4 POST /sleep +Paints a JPEG. Does not change wake/sleep state. -Turns off the backlight. +Headers: `Content-Type: image/jpeg`. Body: raw JPEG bytes. Expected image: baseline JPEG at the current effective resolution (480x800 portrait, 800x480 landscape). Max size: 1 MB. Behavior: -- Backlight off. -- Preserve framebuffer. -- Device remains online and ready for `/frame`. +- If asleep: return `409 Conflict`. Do not paint. Scrypted must `POST /wake` first. +- If awake: decode, push to display, reset idle timer, return `204`. -Response: +Failure responses: ```text -204 No Content +400 Bad Request invalid content-type or malformed JPEG +409 Conflict device is asleep; POST /wake first +413 Payload Too Large JPEG exceeds configured maximum +500 Internal Error decode/display failure (previous frame remains) ``` -### 5.5 POST /brightness +Single in-flight frame. Concurrent posts may be rejected `503`. -Sets backlight brightness. +Scrypted is responsible for scaling/cropping/composition. Scrypted Viewport does not scale. -Request: +### 5.6 POST /brightness -```json -{ - "brightness": 75 -} -``` +Sets backlight brightness. -Range: - -```text -0-100 -``` +Request: `{"brightness": 75}`. Range `0`–`100`. Default on first boot: `80`. Behavior: -- Clamp or reject out-of-range values. Prefer rejecting invalid values with 400. -- Persist latest brightness to NVS. -- If display is awake, apply immediately. -- If asleep, store value for next wake. +- Reject out-of-range values with `400` (do not clamp). +- Persist to NVS. +- Apply immediately if awake; otherwise on next wake. +- Idempotent. -Response: - -```text -204 No Content -``` +Response: `204 No Content`. --- @@ -340,19 +323,22 @@ Example request body: ```json { "display": "mudroom", - "event": "tap", - "timestamp": 1730000000 + "event": "wake", + "type": "tap" } ``` -Supported event names: +Supported events and types: ```text -wake -sleep +event=wake type=tap (touchscreen tap while asleep) +event=sleep type=tap (touchscreen tap while awake) +event=sleep type=timeout (idle timer expired) ``` -`tap` is the input the touchscreen detects; the callback event is the resulting state (`wake` or `sleep`). Idle-timer-driven sleep also sends `sleep`. +No wall-clock timestamp — the device has no RTC and no SNTP. Scrypted timestamps on receipt. + +`tap` is the touchscreen input; the callback event is the resulting state. The `type` field carries the cause and is forward-compatible with future inputs (swipes, long-press, hardware buttons). Scrypted may ignore `type` in v1. Rules: @@ -482,14 +468,17 @@ Responsibilities: - Set hostname. - Advertise `_scrypted-viewport._tcp.local` on port 80. -- Include TXT records if easy: +- Include TXT records: ```text version=1.0.0 -resolution=800x480 -name=mudroom +resolution=<effective> (480x800 or 800x480) +orientation=<portrait|landscape> +name=<display name> ``` +Update TXT records when `orientation` changes via `/config`. + ### 8.4 `http_api.c` Responsibilities: @@ -497,12 +486,14 @@ Responsibilities: - Register routes: - `GET /health` - `POST /config` - - `POST /frame` + - `POST /wake` - `POST /sleep` + - `POST /frame` - `POST /brightness` - Parse JSON for config/brightness. - Stream JPEG request body into buffer. -- Call display/JPEG functions. +- Reject `/frame` with `409` when state is `asleep` (do not auto-wake). +- All endpoints idempotent. - Return simple status codes. ### 8.5 `display.c` @@ -510,9 +501,9 @@ Responsibilities: Responsibilities: - Initialize MIPI DSI display. -- Initialize framebuffer or draw buffer. +- Initialize framebuffer or draw buffer (always allocate 800x480x2 in RGB565; orientation determines mapping during push). - Control backlight. -- Push decoded frames to display. +- Push decoded frames to the panel with the current orientation applied (portrait = 90° rotation). - Sleep/wake backlight. Do not draw application UI. @@ -546,8 +537,8 @@ Responsibilities: - Detect `tap` only (touch-down + release within ~500ms, ignore movement). Long-press and swipes are out of scope. - Debounce. - On tap: - - If asleep: transition to wake — backlight on, render loading screen, POST `{"event":"wake"}`. - - If awake: transition to sleep — backlight off, POST `{"event":"sleep"}`. + - If asleep: transition to awake — backlight on, render loading screen, POST `{"event":"wake","type":"tap"}`. + - If awake: transition to asleep — backlight off, POST `{"event":"sleep","type":"tap"}`. - Also handle BOOT button: - Short press: ask `local_screens` to overlay the IP screen for 15s, then restore prior state. No callback. - Hold ≥5s: clear NVS via `nvs_config_reset()` and reboot. @@ -565,9 +556,10 @@ Responsibilities: Responsibilities: -- Reset timer on `/frame` and on tap-driven wake. -- On expiry: transition to sleep (backlight off, POST `{"event":"sleep"}`). -- Idle timeout: read from NVS (`idle_timeout_ms`), default 60000 ms. Set via `/config`. Scrypted is expected to use the same value as its own per-stream cutoff, but timers run independently. +- Reset timer on `/frame`, `/wake`, and tap-driven wake. +- On expiry: transition to asleep (backlight off, POST `{"event":"sleep","type":"timeout"}`). +- Idle timeout: read from NVS (`idle_timeout_ms`), default 60000 ms. `0` disables the timer; non-zero values must be ≥ 5000 ms (validated at `/config`). +- Scrypted is expected to use the same value as its own per-stream cutoff, but timers run independently — either side can end a session, whichever notices first. ### 8.10 `local_screens.c` @@ -593,6 +585,7 @@ Persist: - callback URL - brightness - idle timeout (ms) +- orientation Do not persist frame data. @@ -612,6 +605,7 @@ typedef struct { uint8_t brightness; // 0-100 uint32_t idle_timeout_ms; + uint8_t orientation; // 0 = portrait, 1 = landscape char ip_addr[64]; @@ -665,7 +659,9 @@ Do not implement: - animations - transitions - LVGL UI -- text rendering +- general-purpose text rendering (only the tiny bitmap font in `local_screens.c`) + +Apply a gamma curve to the brightness PWM duty cycle. Linear 0–100 maps to non-linear perceptual brightness; without correction, `50` looks much brighter than half. A simple `duty = (level / 100) ^ 2.2` lookup table is enough. If the Waveshare ESP32-P4 examples include a working DSI display demo, start from that display initialization code and strip it down. @@ -700,7 +696,8 @@ for (const v of viewports) { body: JSON.stringify({ display: v.display, callback: "http://scrypted.local:11080/api/viewport/touch", - idle_timeout_ms: 60000, + idle_timeout_ms: 60000, // device uses this; Scrypted uses the same value + orientation: "portrait", // override per viewport if a screen is wall-mounted sideways }), }); } @@ -716,12 +713,29 @@ await fetch(`${v.url}/frame`, { }); ``` +Each viewport is bound to exactly one camera (1:1 in v1). Multi-camera cycling is out of scope. + +Scrypted-initiated session (camera event): + +```ts +await fetch(`${v.url}/wake`, { method: "POST" }); // device shows loading screen +// then push frames until the per-stream timeout elapses: +for (const jpeg of frames) { + const r = await fetch(`${v.url}/frame`, { + method: "POST", + headers: { "Content-Type": "image/jpeg" }, + body: jpeg, + }); + if (r.status === 409) break; // device went to sleep; stop the loop +} +``` + Callback handler at `/api/viewport/touch`: -- `wake` → look up viewport→camera binding, start streaming. +- `wake` → look up viewport→camera binding, start streaming. No need to POST `/wake` first; device is already awake. - `sleep` → stop streaming to that viewport. -Both are idempotent. Don't track viewport state. Apply a Scrypted-side per-stream timeout using the same `idle_timeout_ms` value sent in `/config`, so streams end even if the `sleep` callback is dropped. Timers run independently on each side. +Both handlers are idempotent. Don't track viewport state. Apply a Scrypted-side per-stream timeout using the same `idle_timeout_ms` sent in `/config`, so streams end even if the `sleep` callback is dropped. Timers run independently — and if the device sleeps first (idle or tap), the next `/frame` returns `409` and Scrypted stops on that signal alone. --- @@ -779,25 +793,26 @@ Acceptance: ```bash curl -X POST \ -H "Content-Type: image/jpeg" \ - --data-binary @test-800x480.jpg \ + --data-binary @test-480x800.jpg \ http://viewport.local/frame ``` updates screen. -### Milestone 5: Sleep/Brightness +### Milestone 5: Wake/Sleep/Brightness -- Implement `/sleep`. -- Implement `/brightness`. -- Add idle timer. +- Implement `/wake`, `/sleep`, `/brightness`. +- Make `/frame` reject with `409` when asleep (no auto-wake). +- Add idle timer with 60s default; fires sleep + `sleep:timeout` callback (callback target is a no-op until M7). Acceptance: ```bash -curl -X POST http://viewport.local/sleep +curl -X POST http://viewport.local/sleep # backlight off +curl -X POST http://viewport.local/wake # backlight on, loading screen ``` -turns off backlight. +`/frame` after `/sleep` returns 409. `/frame` after `/wake` paints. ### Milestone 6: Config Persistence @@ -814,33 +829,58 @@ After reboot, /health shows configured=true and name preserved. ### Milestone 7: Touch Callback - Initialize touch controller. -- Detect tap. -- POST callback JSON. +- Detect tap; toggle wake/sleep locally. +- POST `wake:tap`, `sleep:tap`, and `sleep:timeout` callback JSON. Acceptance: ```text -Tap generates callback to test HTTP endpoint. +Tap on asleep device: backlight on, callback {event:wake,type:tap}. +Tap on awake device: backlight off, callback {event:sleep,type:tap}. +After idle_timeout_ms with no /frame: callback {event:sleep,type:timeout}. ``` -### Milestone 8: Local Screens +### Milestone 8: Local Screens + BOOT button - Embed minimal bitmap font. -- Render unconfigured screen (IP + viewport.local) on boot when NVS has no callback. -- Render loading screen on tap-wake; replaced by next `/frame`. +- Render IP screen on boot when NVS has no callback (persistent). +- Render loading screen on every wake (via tap or `/wake`); replaced by next `/frame`. +- Wire BOOT short-press to 15s IP-screen overlay (no state change). +- Wire BOOT 5s-hold to NVS-clear + reboot. + +Acceptance: + +```text +Fresh device boots to IP screen. +Tap (or POST /wake) shows "Loading…" until next /frame. +BOOT short-press overlays IP for 15s, then restores prior state. +BOOT 5s-hold returns the device to the IP screen. +``` + +### Milestone 9: Live Stream (`POST /stream`) + +The first post-`/frame` enhancement. Once `/frame` works end-to-end, add a streaming endpoint to lift the per-frame HTTP overhead and reach live frame rates. + +- Implement `POST /stream`, `Content-Type: multipart/x-mixed-replace; boundary=…`. +- Read chunked body, parse multipart boundaries, hand each part to the JPEG decoder. +- Reset the idle timer on each part (not just connection open). +- On `/sleep`, tap-to-sleep, or idle timeout: close the stream connection (signals Scrypted to stop). +- Scrypted side moves from a Script polling `takePicture()` to a small plugin using `MediaManager` + FFmpeg to pipe MJPEG. + +`/frame` stays — useful forever for snapshots, doorbell stills, and curl-driven debug. Acceptance: ```text -Fresh device boots to an IP-display screen. -Tap on a sleeping configured device shows "Loading…" until next frame. +Live camera stream renders on viewport at >= 10 fps. +Closing the stream connection from either side stops the session cleanly. ``` --- ## 14. Test Images -Generate test images at exactly 800×480: +Generate test images at the effective resolution (480×800 for the default portrait orientation, 800×480 for landscape): - solid red - solid green @@ -857,30 +897,43 @@ If colors are wrong, likely RGB/BGR or RGB565 byte-order issue. ## 15. Error Handling Philosophy -Keep failure behavior simple. +Keep failure behavior simple. Every endpoint is idempotent and every failure leaves the device in a sane state. -If frame decode fails: +Frame decode fails: -- increment decode error counter +- increment `decode_errors` - return 400 or 500 - keep previous frame on screen +- do not change wake/sleep state -If callback fails: +Callback POST fails: - log -- increment counter -- continue +- increment `callback_failures` +- continue — local state change still happens +- do not retry; Scrypted catches up via its own timeout or the next event -If Ethernet disconnects: +Ethernet disconnects: - keep display running - reconnect automatically - no reboot loop -If display init fails: +Display init fails: - log loudly -- continue serving `/health` if possible with display error status +- continue serving `/health` with `state="unconfigured"` if possible + +Watchdog: + +- ESP-IDF task watchdog enabled on the main HTTP task and the touch task. If either hangs, the device reboots and rebuilds state from NVS. + +Status LED (on-board): + +- solid = configured & online +- slow blink = unconfigured, waiting for `/config` +- fast blink = no network +- Drives the LED on the Waveshare board; visible when the screen is asleep. --- @@ -919,20 +972,22 @@ The project is successful when: 1. Device powers from PoE. 2. Device gets DHCP over Ethernet. -3. Device advertises mDNS. -4. `/health` works. -5. `/config` persists display name and callback URL. -6. `/frame` accepts an 800×480 JPEG and updates screen. -7. `/frame` implicitly wakes display. -8. Idle timeout turns backlight off. -9. `/sleep` works. -10. `/brightness` works. -11. Tap sends callback JSON to Scrypted/test server. -12. Unconfigured device shows its IP on screen so it can be registered from Scrypted. -13. Tap toggles the device between wake (backlight on + Scrypted streaming) and sleep (backlight off + Scrypted not streaming), via `wake`/`sleep` callback events. -14. Idle timer fires `sleep` after `idle_timeout_ms` with no `/frame` (default 60s, set by Scrypted via `/config`). -15. BOOT short-press overlays the IP screen for 15s; BOOT hold ≥5s factory-resets. -16. Firmware has no Matter, HomeKit, polling, business logic, or config portal. The only locally-rendered UI is the IP screen and the loading screen. +3. Device advertises mDNS with TXT records (`version`, `resolution`, `name`). +4. `/health` returns state, frame counters, and error counters. +5. `/config` persists display, callback, idle timeout, brightness, and orientation across reboot. +6. `/config` validates `idle_timeout_ms` (0 disables; non-zero ≥ 5000; else 400) and `orientation` (`portrait` or `landscape`; else 400). Default orientation is `portrait`. +7. `/wake` transitions asleep→awake and is idempotent. +8. `/sleep` transitions awake→asleep and is idempotent. +9. `/frame` paints when awake, returns 409 when asleep, and never changes state. +10. `/brightness` accepts 0–100 with default 80; persisted; applied with a gamma curve. +11. Idle timer fires `sleep` with `type=timeout` after `idle_timeout_ms` of no `/frame`. +12. Tap toggles wake/sleep locally and POSTs `wake/tap` or `sleep/tap`. +13. Unconfigured device shows its IP and `viewport.local` on screen. +14. Loading screen is shown on every wake until the next `/frame` arrives. +15. BOOT short-press overlays IP screen for 15s with no state change. BOOT 5s-hold factory-resets. +16. Watchdog reboots a hung device; state recovers from NVS. +17. Status LED indicates network and configuration state. +18. Firmware has no Matter, HomeKit, business logic, or config portal. The only locally-rendered UI is the IP screen and the loading screen. --- |
