From 072504c2dfa19e4311e16e5c445d9cc19680dcf5 Mon Sep 17 00:00:00 2001 From: Luke Hoersten Date: Sat, 13 Jun 2026 20:36:34 -0500 Subject: Consolidate REST API: 4 endpoints, partial /config, /state, GET /config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API surface is now: GET /state runtime status (replaces /health) GET /config persisted config POST /config partial update; brightness is now a field here POST /state {state: wake|sleep} (replaces /wake and /sleep) POST /frame unchanged POST /config now does a partial merge — only present fields are written. To tweak only brightness, POST {"brightness": 50}. POST /state replaces POST /wake and POST /sleep with a single endpoint that takes {state: "wake"|"sleep"}. Idempotent: no-op if already in the requested state. Brightness is no longer its own endpoint; it lives in /config with all the other persisted settings. PWM remains gamma-corrected. Callback body renamed: was: {event: wake|sleep, type: tap|timeout} now: {state: wake|sleep, event: tap|timeout} The new naming matches POST /state: "state" is the resulting wake/ sleep state and the imperative for Scrypted; "event" is the cause (tap, timeout, future swipes). Doc sync: README API/Wake-Sleep/Idempotency/Integration sections and v2 impl guide API contract, modules, milestones, acceptance criteria all updated to the new model. --- README.md | 153 ++++++------ ...Viewport-v2-Claude-Code-Implementation-Guide.md | 266 +++++++++++---------- 2 files changed, 224 insertions(+), 195 deletions(-) diff --git a/README.md b/README.md index c19e5e1..c8c1af9 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Power -> DHCP -> mDNS (_scrypted-viewport._tcp.local) -> If unconfigured: backlight on, show IP + hostname (persistent until `/config`) --> If configured: enter sleep state (backlight off, wait for `/wake` or a tap) +-> If configured: enter sleep state (backlight off, wait for `POST /state` or a tap) ## Resolution @@ -64,7 +64,11 @@ Trust model: LAN-only, no auth, no TLS. Deploy on a trusted VLAN. ## API -### GET /health +Four endpoints. `GET /state` and `GET /config` are the read surface; `POST /config`, `POST /state`, and `POST /frame` are the write surface. + +### GET /state + +Returns the device's runtime status. Replaces the old `/health` endpoint. Returns `200 OK` with JSON: @@ -79,9 +83,6 @@ Returns `200 OK` with JSON: "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, @@ -91,6 +92,35 @@ Returns `200 OK` with JSON: `state` is `awake`, `asleep`, or `unconfigured`. `last_frame_ms_ago` is `null` if no frame has been received since boot. +### POST /state + +```json +{ "state": "wake" } +``` + +- `state` is `wake` or `sleep`. Anything else: `400`. +- Idempotent. Already-in-that-state calls return `204` and do nothing. +- `wake`: backlight on, render loading screen (until the next `/frame`), reset idle timer. +- `sleep`: backlight off; framebuffer discarded. +- No callback echo (Scrypted initiated). +- Response: `204`. + +### GET /config + +Returns the persisted config: + +```json +{ + "display": "mudroom", + "callback": "http://scrypted.local:11080/api/viewport/touch", + "idle_timeout_ms": 60000, + "orientation": "portrait", + "brightness": 80 +} +``` + +Before first `/config`: returns `200` with an object whose fields are all `null` except defaults (`brightness: 80`, `orientation: "portrait"`, `idle_timeout_ms: 60000`). + ### POST /config ```json @@ -98,34 +128,25 @@ Returns `200 OK` with JSON: "display": "mudroom", "callback": "http://scrypted.local:11080/api/viewport/touch", "idle_timeout_ms": 60000, - "orientation": "portrait" + "orientation": "portrait", + "brightness": 80 } ``` +- **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; subsequent calls atomically replace prior config. +- Idempotent; reposting the same body yields the same state. - `display` must be non-empty; `callback` must be `http://...`. -- `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. +- `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`. -### 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. +To tweak only brightness: -- Idempotent. Already-asleep calls return `204` and do nothing. -- Backlight off; framebuffer is discarded (not preserved across sleep). -- No callback to Scrypted. -- Response: `204`. +```json +{ "brightness": 50 } +``` ### POST /frame @@ -134,67 +155,56 @@ Paints a frame. Does **not** change wake/sleep state. - `Content-Type: image/jpeg`, body is raw JPEG bytes. - Image must match the effective resolution (480x800 portrait, 800x480 landscape) as a baseline JPEG. Device does not scale, rotate, or letterbox JPEG content. - Max size: 1 MB. -- **Requires awake state.** While asleep, returns `409 Conflict` and does not paint. Scrypted must `POST /wake` first (or wait for a `wake` callback from a tap). +- **Requires awake state.** While asleep, returns `409 Conflict` and does not paint. Scrypted must `POST /state {"state":"wake"}` first (or wait for a `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, `409` device asleep, `413` over size, `500` decode/display failure. On failure the previous frame stays on screen. -### POST /brightness - -```json -{ "brightness": 75 } -``` - -- Range `0`–`100`. Out-of-range: `400`. -- Default on first boot: `80`. -- Persisted to NVS. Applied immediately if awake, otherwise on next wake. -- Idempotent. Response: `204`. - ## Wake / Sleep -Wake and sleep couple the device backlight with Scrypted's frame stream. The device owns the state; Scrypted just receives idempotent imperatives: +Wake and sleep couple the device backlight with Scrypted's frame stream. The device owns the state; Scrypted just receives idempotent imperatives in callbacks: -- `wake` → "start streaming to this display now" -- `sleep` → "stop streaming to this display now" +- `state=wake` → "start streaming to this display now" +- `state=sleep` → "stop streaming to this display now" -Scrypted does not track per-viewport state; it acts on the event and forgets. Repeat events are safe. +Scrypted does not track per-viewport state; it acts on the callback and forgets. Repeats are safe. Transitions: | Trigger | Resulting state | Callback to Scrypted | | --- | --- | --- | -| 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 | +| Tap while asleep | Awake (loading screen) | `state=wake event=tap` | +| Tap while awake | Asleep | `state=sleep event=tap` | +| Idle timer expires | Asleep | `state=sleep event=timeout` | +| `POST /state {"state":"wake"}` | Awake (loading screen) | none | +| `POST /state {"state":"sleep"}` | Asleep | none | | `POST /frame` | (no state change) | — | -`/frame` never changes state. Scrypted must `POST /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. +`/frame` never changes state. Scrypted must `POST /state {"state":"wake"}` (or wait for a tap-driven callback) before sending frames. This makes the protocol race-free: a `/frame` arriving after a tap-to-sleep is rejected with `409`, not silently re-woken. -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`. +Only `tap` is detected on the touchscreen — long-press and swipes are out of scope for v1. `tap` itself is internal; what Scrypted sees is the resulting `state`. Callback body: ```json { "display": "mudroom", - "event": "wake", - "type": "tap" + "state": "wake", + "event": "tap" } ``` -- `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. +- `state`: `wake` or `sleep` — the resulting state, also the imperative for Scrypted. +- `event`: the cause. `tap` (any user tap) or `timeout` (idle expiry, sleep only). Future events (e.g. `swipe_left`) can be added without schema changes. Scrypted may ignore `event` in v1. -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. +Delivery: best-effort, ~1s timeout, no retry. Callbacks before `/config` registers a URL are dropped. If the callback POST fails, the local state change still happens — Scrypted catches up via its own timeout or the next callback. -No wall-clock timestamp is included. The device has no RTC and no SNTP. Scrypted timestamps events on receipt. +No wall-clock timestamp is included. The device has no RTC and no SNTP. Scrypted timestamps callbacks on receipt. ## Idle -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. +After `idle_timeout_ms` (default 60s) with no `/frame`, the device sleeps and POSTs `state=sleep` with `event=timeout`. Scrypted should use the same timeout so its per-stream cutoff matches, but they run independently — either side can end the session, whichever notices first. ## Idempotency @@ -202,14 +212,13 @@ All endpoints are safe to retry. Every state-change path converges to the same f | 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 | +| `GET /state` | yes | side-effect free | +| `GET /config` | yes | side-effect free | +| `POST /config` | yes | partial merge into persisted config, atomic | +| `POST /state` | yes | no-op if already in that state | | `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`. +Callbacks (`state=wake`, `state=sleep`) are imperatives, not notifications. Scrypted acts and forgets; the device never expects an ack. A dropped callback is recovered by the next user action, by Scrypted's own timeout, or by a `/frame` returning `409`. Failure modes do not corrupt state: @@ -253,7 +262,8 @@ await fetch(`${viewport}/config`, { display: "mudroom", callback: "http://scrypted.local:11080/api/viewport/touch", idle_timeout_ms: 60000, - orientation: "portrait" + orientation: "portrait", + brightness: 80 }) }); ``` @@ -261,7 +271,12 @@ await fetch(`${viewport}/config`, { To start a session (camera event like doorbell, motion, person): ```ts -await fetch(`${viewport}/wake`, { method: "POST" }); // device shows loading +await fetch(`${viewport}/state`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state: "wake" }) +}); // device shows loading + // then stream frames: await fetch(`${viewport}/frame`, { method: "POST", @@ -270,21 +285,21 @@ await fetch(`${viewport}/frame`, { }); ``` -Handle wake/sleep callbacks at `POST /api/viewport/touch`: +Handle callbacks at `POST /api/viewport/touch` (body: `{display, state, event}`): -- `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`. +- `state=wake` → look up the camera bound to `display`, start streaming frames. (You do not need to `POST /state` first — the device is already awake when it sends this.) +- `state=sleep` → stop streaming frames to `display`. -Both are idempotent on Scrypted's side. Don't track viewport state; act on the event and forget. Ignore the `type` field in v1. +Both are idempotent on Scrypted's side. Don't track viewport state; act on the callback and forget. Ignore the `event` field in v1. -Scrypted should use the same `idle_timeout_ms` value it sent in `/config` as its own per-stream cutoff. The two timers run independently — either side can cut a session, whichever notices first. If the viewport's `sleep` callback is lost, the Scrypted-side timeout still ends the stream; the next `/frame` posted after the device idle-slept simply returns `409` and Scrypted stops. +Scrypted should use the same `idle_timeout_ms` value it sent in `/config` as its own per-stream cutoff. The two timers run independently — either side can cut a session, whichever notices first. If the 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 (planned post-v1: HTTP OTA from Scrypted). - Provisioning: flash the same firmware to every device. On first boot the screen shows its IP; register it from Scrypted via `POST /config`. - Display names must be unique across the LAN — mDNS hostnames are derived from `display` and two viewports configured with the same name will collide. -- Factory reset: hold BOOT for 5s during normal operation to clear NVS (display name, callback, brightness, idle timeout) and reboot. The device returns to the IP screen. +- Factory reset: hold BOOT for 5s during normal operation to clear NVS (display name, callback, brightness, idle timeout, orientation) and reboot. The device returns to the IP screen. - 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. diff --git a/Scrypted-Viewport-v2-Claude-Code-Implementation-Guide.md b/Scrypted-Viewport-v2-Claude-Code-Implementation-Guide.md index ce6c178..66b01c1 100644 --- a/Scrypted-Viewport-v2-Claude-Code-Implementation-Guide.md +++ b/Scrypted-Viewport-v2-Claude-Code-Implementation-Guide.md @@ -114,16 +114,16 @@ asleep = backlight off, Scrypted not streaming State changes are strictly limited to these triggers: ```text -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) +tap while asleep -> awake (callback: state=wake event=tap) +tap while awake -> asleep (callback: state=sleep event=tap) +idle timer expires -> asleep (callback: state=sleep event=timeout) +POST /state {"state":"wake"} asleep -> awake (no callback) +POST /state {"state":"sleep"} awake -> asleep (no callback) ``` `/frame` never changes state. It paints if awake, returns `409` if asleep. This eliminates the race where a frame in flight could re-wake a device that just slept on a tap. -Scrypted-initiated state changes (`/wake`, `/sleep`) do not echo a callback. +Scrypted-initiated state changes via `POST /state` do not echo a callback. Frame flow (awake state only): @@ -136,11 +136,11 @@ Scrypted POST /frame -> respond 204 ``` -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) +Callback body has two fields: +- `state`: `wake` or `sleep` — the resulting state and the imperative for Scrypted. +- `event`: the cause. `tap` (any user tap) or `timeout` (idle expiry, sleep only). Forward-compat for future inputs like swipes. -Events are idempotent imperatives — "start streaming" / "stop streaming" — not state notifications. Scrypted does not track per-viewport state. Scrypted may ignore `type` in v1. +Callbacks are idempotent imperatives — "start streaming" / "stop streaming" — not state notifications. Scrypted does not track per-viewport state. Scrypted may ignore `event` in v1. Only `tap` is detected on the touchscreen. Long-press and swipes are out of scope for v1. @@ -175,9 +175,11 @@ viewport.local viewport-mudroom.local ``` -### 5.1 GET /health +Four endpoints total: `GET /state`, `GET /config`, `POST /config`, `POST /state`, `POST /frame`. -Return JSON status. +### 5.1 GET /state + +Return JSON status (replaces the old `/health`). Example response: @@ -192,9 +194,6 @@ Example response: "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, @@ -204,72 +203,90 @@ Example response: `state` is `awake`, `asleep`, or `unconfigured`. `last_frame_ms_ago` is `null` if no frame has been received since boot. -Status code: +Status code: `200 OK`. -```text -200 OK +### 5.2 GET /config + +Return the persisted config: + +```json +{ + "display": "mudroom", + "callback": "http://scrypted.local:11080/api/viewport/touch", + "idle_timeout_ms": 60000, + "orientation": "portrait", + "brightness": 80 +} ``` -### 5.2 POST /config +Before first `/config`: `display` and `callback` are `null`; the rest carry their first-boot defaults (`idle_timeout_ms: 60000`, `orientation: "portrait"`, `brightness: 80`). -Registers the controlling Scrypted callback. +Status code: `200 OK`. -Request body: +### 5.3 POST /config + +Sets or updates the device config. Partial-update semantics — only fields present in the body are changed; omitted fields keep their current values. The merged config is persisted to NVS atomically. + +Request body (full form): ```json { "display": "mudroom", "callback": "http://scrypted.local:11080/api/viewport/touch", "idle_timeout_ms": 60000, - "orientation": "portrait" + "orientation": "portrait", + "brightness": 80 } ``` -Behavior: +To tweak only brightness: -- Store display name. -- Store callback URL. -- 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. +```json +{ "brightness": 50 } +``` -Response: +Behavior: -```text -204 No Content -``` +- For each present field: validate, then update the persisted record. +- Apply orientation and brightness changes immediately. Orientation update redraws the IP/Loading screens (if shown) and updates mDNS TXT records. +- Survive reboot. +- Idempotent: re-posting the same body yields the same state. 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`. +- `display`: non-empty string. +- `callback`: must be `http://...`. +- `idle_timeout_ms`: `0` disables the idle timer; non-zero must be ≥ `5000`. Otherwise `400`. +- `orientation`: `portrait` or `landscape`. Otherwise `400`. +- `brightness`: integer `0`–`100`. Otherwise `400`. - HTTPS is not required for v1/v2. -### 5.3 POST /wake +Response: `204 No Content`. -Transitions the device to the awake state. +### 5.4 POST /state -Behavior: +Sets the wake/sleep state. -- If already awake: no-op, return `204`. -- If asleep: backlight on, render loading screen, reset idle timer. -- Do not POST a callback (Scrypted initiated). +Request body: -Response: `204 No Content`. +```json +{ "state": "wake" } +``` -### 5.4 POST /sleep +or -Transitions the device to the asleep state. +```json +{ "state": "sleep" } +``` Behavior: -- If already asleep: no-op, return `204`. -- If awake: backlight off. Framebuffer is discarded (not preserved across sleep). -- Do not POST a callback. +- `wake`: backlight on, render loading screen, reset idle timer. No-op if already awake. +- `sleep`: backlight off, framebuffer discarded. No-op if already asleep. +- Do not POST a callback (Scrypted initiated). +- Idempotent. + +Validation: `state` must be `wake` or `sleep`. Otherwise `400`. Response: `204 No Content`. @@ -281,37 +298,21 @@ Headers: `Content-Type: image/jpeg`. Body: raw JPEG bytes. Expected image: basel Behavior: -- If asleep: return `409 Conflict`. Do not paint. Scrypted must `POST /wake` first. +- If asleep: return `409 Conflict`. Do not paint. Scrypted must `POST /state {"state":"wake"}` first. - If awake: decode, push to display, reset idle timer, return `204`. Failure responses: ```text 400 Bad Request invalid content-type or malformed JPEG -409 Conflict device is asleep; POST /wake first +409 Conflict device is asleep; POST /state {"state":"wake"} first 413 Payload Too Large JPEG exceeds configured maximum 500 Internal Error decode/display failure (previous frame remains) +503 Service Unavailable concurrent frame in flight ``` -Single in-flight frame. Concurrent posts may be rejected `503`. - Scrypted is responsible for scaling/cropping/composition. Scrypted Viewport does not scale. -### 5.6 POST /brightness - -Sets backlight brightness. - -Request: `{"brightness": 75}`. Range `0`–`100`. Default on first boot: `80`. - -Behavior: - -- Reject out-of-range values with `400` (do not clamp). -- Persist to NVS. -- Apply immediately if awake; otherwise on next wake. -- Idempotent. - -Response: `204 No Content`. - --- ## 6. Touch Callback Contract @@ -323,30 +324,30 @@ Example request body: ```json { "display": "mudroom", - "event": "wake", - "type": "tap" + "state": "wake", + "event": "tap" } ``` -Supported events and types: +Supported combinations: ```text -event=wake type=tap (touchscreen tap while asleep) -event=sleep type=tap (touchscreen tap while awake) -event=sleep type=timeout (idle timer expired) +state=wake event=tap (touchscreen tap while asleep) +state=sleep event=tap (touchscreen tap while awake) +state=sleep event=timeout (idle timer expired) ``` 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. +`state` is the resulting wake/sleep state, also the imperative for Scrypted. `event` carries the cause and is forward-compatible with future inputs (swipes, long-press, hardware buttons). Scrypted may ignore `event` in v1. Rules: - The device owns wake/sleep state. Scrypted does not track it. - Scrypted owns the viewport→camera binding and decides which stream goes to which viewport. -- `wake`/`sleep` callbacks are idempotent imperatives: "start streaming" / "stop streaming". Scrypted acts on receipt and forgets. +- Callbacks are idempotent imperatives: `state=wake` means "start streaming", `state=sleep` means "stop streaming". Scrypted acts on receipt and forgets. - Scrypted enforces its own per-stream timeout independently of the device's idle timer. Either can end a session, whichever notices first. -- Scrypted-initiated `/sleep` and `/frame` do not echo a callback. +- Scrypted-initiated `POST /state` and `POST /frame` do not echo a callback. Callback delivery: @@ -484,13 +485,12 @@ Update TXT records when `orientation` changes via `/config`. Responsibilities: - Register routes: - - `GET /health` + - `GET /state` + - `GET /config` - `POST /config` - - `POST /wake` - - `POST /sleep` + - `POST /state` - `POST /frame` - - `POST /brightness` -- Parse JSON for config/brightness. +- Parse JSON for config and state bodies. Partial /config bodies are merged into the persisted record. - Stream JPEG request body into buffer. - Reject `/frame` with `409` when state is `asleep` (do not auto-wake). - All endpoints idempotent. @@ -537,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 awake — backlight on, render loading screen, POST `{"event":"wake","type":"tap"}`. - - If awake: transition to asleep — backlight off, POST `{"event":"sleep","type":"tap"}`. + - If asleep: transition to awake — backlight on, render loading screen, POST `{"state":"wake","event":"tap"}`. + - If awake: transition to asleep — backlight off, POST `{"state":"sleep","event":"tap"}`. - 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. @@ -556,8 +556,8 @@ Responsibilities: Responsibilities: -- Reset timer on `/frame`, `/wake`, and tap-driven wake. -- On expiry: transition to asleep (backlight off, POST `{"event":"sleep","type":"timeout"}`). +- Reset timer on `/frame`, `POST /state {state:wake}`, and tap-driven wake. +- On expiry: transition to asleep (backlight off, POST `{"state":"sleep","event":"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. @@ -698,12 +698,13 @@ for (const v of viewports) { callback: "http://scrypted.local:11080/api/viewport/touch", idle_timeout_ms: 60000, // device uses this; Scrypted uses the same value orientation: "portrait", // override per viewport if a screen is wall-mounted sideways + brightness: 80, }), }); } ``` -Stream a session of frames to a viewport — triggered by either a camera event (doorbell, person, motion) or a `wake` callback from the viewport: +Stream a session of frames to a viewport — triggered by either a camera event (doorbell, person, motion) or a `state=wake` callback from the viewport: ```ts await fetch(`${v.url}/frame`, { @@ -718,7 +719,12 @@ Each viewport is bound to exactly one camera (1:1 in v1). Multi-camera cycling i Scrypted-initiated session (camera event): ```ts -await fetch(`${v.url}/wake`, { method: "POST" }); // device shows loading screen +await fetch(`${v.url}/state`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state: "wake" }), +}); // 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`, { @@ -730,12 +736,12 @@ for (const jpeg of frames) { } ``` -Callback handler at `/api/viewport/touch`: +Callback handler at `/api/viewport/touch` (body: `{display, state, event}`): -- `wake` → look up viewport→camera binding, start streaming. No need to POST `/wake` first; device is already awake. -- `sleep` → stop streaming to that viewport. +- `state=wake` → look up viewport→camera binding, start streaming. No need to `POST /state` first; device is already awake. +- `state=sleep` → stop streaming to that viewport. -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. +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. --- @@ -758,16 +764,16 @@ Device gets DHCP lease over Ethernet. ### Milestone 2: HTTP + mDNS - Start HTTP server. -- Implement `/health`. -- Advertise `_scrypted-viewport._tcp.local`. +- Implement `GET /state`. +- Advertise `_scrypted-viewport._tcp.local` with TXT records. Acceptance: ```bash -curl http://viewport.local/health +curl http://viewport.local/state ``` -returns JSON. +returns JSON with `state: "unconfigured"` on a fresh device. ### Milestone 3: Display Bring-Up @@ -785,11 +791,12 @@ Screen displays deterministic test pattern. Done before any state-bearing endpoints so they can read values from NVS instead of working around hardcoded defaults. -- Implement `/config` (display, callback, idle_timeout_ms, orientation). -- Validate per spec (non-empty display, http callback, idle_timeout = 0 or ≥ 5000, orientation in {portrait, landscape}). +- Implement `GET /config` and `POST /config` (display, callback, idle_timeout_ms, orientation, brightness). +- Partial-update semantics on `POST /config`: only included fields are written. +- Validate per spec (non-empty display, http callback, idle_timeout = 0 or ≥ 5000, orientation in {portrait, landscape}, brightness 0–100). - Persist all fields to NVS atomically. -- Apply orientation immediately (mDNS TXT and `/health` reflect it). -- Brightness defaults to 80 on first boot, persisted. +- Apply orientation and brightness immediately (mDNS TXT and `/state` reflect them). +- Brightness defaults to 80 on first boot. Acceptance: @@ -797,9 +804,16 @@ Acceptance: curl -X POST -H "Content-Type: application/json" \ -d '{"display":"mudroom","callback":"http://host/cb","orientation":"landscape"}' \ http://viewport.local/config + +# Partial update — only brightness changes: +curl -X POST -H "Content-Type: application/json" \ + -d '{"brightness":50}' \ + http://viewport.local/config + +curl http://viewport.local/config ``` -After reboot, `/health` shows `configured=true`, name preserved, `orientation=landscape`, `resolution=800x480`. +After reboot, `GET /state` shows `configured=true` and name preserved; `GET /config` shows `orientation=landscape`, `brightness=50`, and the original callback. ### Milestone 5: JPEG Frame Push @@ -818,40 +832,40 @@ curl -X POST \ updates screen. Re-running with `landscape` set via `/config` requires an 800×480 test image. -### Milestone 6: Wake/Sleep/Brightness +### Milestone 6: State + Idle Timer -- Implement `/wake`, `/sleep`, `/brightness` (persisted to NVS). +- Implement `POST /state` (`wake` / `sleep`, idempotent). - Make `/frame` reject with `409` when asleep — no auto-wake. -- Add idle timer using `idle_timeout_ms` from NVS; on expiry, transition to sleep and POST `sleep:timeout` callback (callback target is a no-op until M7). +- Add idle timer using `idle_timeout_ms` from NVS; on expiry, transition to sleep and POST `state=sleep event=timeout` callback (callback target is a no-op until M7). Acceptance: ```bash -curl -X POST http://viewport.local/sleep # backlight off -curl -X POST http://viewport.local/wake # backlight on, loading screen +curl -X POST -d '{"state":"sleep"}' http://viewport.local/state # backlight off +curl -X POST -d '{"state":"wake"}' http://viewport.local/state # backlight on, loading ``` -`/frame` after `/sleep` returns 409. `/frame` after `/wake` paints. Brightness survives reboot. +`/frame` after `state=sleep` returns 409. `/frame` after `state=wake` paints. Idle timer fires after `idle_timeout_ms` of no frames. ### Milestone 7: Touch Callback - Initialize touch controller. - Detect tap; toggle wake/sleep locally. -- POST `wake:tap`, `sleep:tap`, and `sleep:timeout` callback JSON. +- POST callback JSON for `state=wake event=tap`, `state=sleep event=tap`, and `state=sleep event=timeout`. Acceptance: ```text -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}. +Tap on asleep device: backlight on, callback {state:wake,event:tap}. +Tap on awake device: backlight off, callback {state:sleep,event:tap}. +After idle_timeout_ms with no /frame: callback {state:sleep,event:timeout}. ``` ### Milestone 8: Local Screens + BOOT button - Embed minimal bitmap font. - 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`. +- Render loading screen on every wake (via tap or `POST /state`); replaced by next `/frame`. - Wire BOOT short-press to 15s IP-screen overlay (no state change). - Wire BOOT 5s-hold to NVS-clear + reboot. @@ -859,7 +873,7 @@ Acceptance: ```text Fresh device boots to IP screen. -Tap (or POST /wake) shows "Loading…" until next /frame. +Tap (or POST /state {state: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. ``` @@ -871,7 +885,7 @@ The first post-`/frame` enhancement. Once `/frame` works end-to-end, add a strea - 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). +- On `POST /state {state: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. @@ -929,7 +943,7 @@ Ethernet disconnects: Display init fails: - log loudly -- continue serving `/health` with `state="unconfigured"` if possible +- continue serving `GET /state` with `state="unconfigured"` if possible Watchdog: @@ -979,16 +993,16 @@ The project is successful when: 1. Device powers from PoE. 2. Device gets DHCP over Ethernet. -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`. +3. Device advertises mDNS with TXT records (`version`, `resolution`, `orientation`, `name`). +4. `GET /state` returns runtime state, frame counters, and error counters. +5. `GET /config` returns the persisted config (with defaults filled in before first `/config`). +6. `POST /config` persists display, callback, idle timeout, orientation, and brightness across reboot, with partial-update semantics. +7. `POST /config` validates `idle_timeout_ms` (0 disables; non-zero ≥ 5000; else 400), `orientation` (`portrait` or `landscape`; else 400), and `brightness` (0–100; else 400). Defaults on first boot: `orientation=portrait`, `brightness=80`, `idle_timeout_ms=60000`. +8. `POST /state` transitions wake↔sleep idempotently and rejects unknown state values with 400. +9. `POST /frame` paints when awake, returns 409 when asleep, and never changes state. +10. Brightness PWM is gamma-corrected (perceptual 0–100). +11. Idle timer fires `state=sleep event=timeout` callback after `idle_timeout_ms` of no `/frame`. +12. Tap toggles wake/sleep locally and POSTs `state=wake event=tap` or `state=sleep event=tap`. 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. -- cgit v1.2.3