src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md153
-rw-r--r--Scrypted-Viewport-v2-Claude-Code-Implementation-Guide.md266
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.