src.nth.io/

summaryrefslogtreecommitdiff
path: root/scrypted/scrypted-viewport.ts
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-20 11:38:14 -0500
committerLuke Hoersten <[email protected]>2026-06-20 11:38:14 -0500
commite4a546ce29a3a29dc814b7d115a1b9c206385559 (patch)
tree88c22070093349c81d953c2bc18f628146db972d /scrypted/scrypted-viewport.ts
parentd5eef650e7640e5622c384e6783fe4ff201e172e (diff)
phase 4: glass-to-glass via 16-byte stream header + /state stream stats
Wire format change: stream frames now carry a 4-byte "VPRT" magic + 4-byte jpeg_len + 4-byte seq + 4-byte event_us_low. Total 16 bytes (was 8). The firmware sniffs the first 4 bytes per frame: if they spell VPRT it reads the remaining 12 bytes of v1 header; otherwise it interprets bytes 0-3 as jpeg_len for the old v0 8-byte format and reads 4 more for seq. Lets a v1 firmware accept a v0 (legacy) Scrypted script during the rollout window. v0 will be removed once all field deployments roll forward. event_us_low is the low 32 bits of the Scrypted host's monotonic µs at camera-event arrival. The firmware does NOT interpret it (the clocks aren't sync'd); it just stamps it on every painted frame and exposes the most recent value via /state. The script polls /state every 5s during an active stream, reads last_paint_event_us_low, and computes glass-to-glass = (now_us_low - last_paint_event_us_low) with 32-bit wrap. 30s sanity ceiling on the wrap to discard event timestamps from before the stream started. Also expose the firmware's just-closed 30-frame window stats via /state under the "stream" key — frames, bytes, window_us, plus min/avg/max for recv/dec/paint/idle. Lets external tools (a curl loop, the Scrypted plugin, etc) poll the firmware's view without parsing serial logs. Firmware: - stream_server.h: 16-byte v1 wire spec, stream_server_stats_t struct, stream_server_snapshot_stats(out) getter. - stream_server.c: magic-detect header read path, last_event_us_low capture into per-connection state, portMUX-protected window-stats snapshot at every 30-frame roll. - http_api.c: GET /state JSON gains a "stream" sub-object with the full snapshot. - viewport_state.h: VIEWPORT_VERSION 1.0.0 → 1.1.0 (new /state shape). Scrypted: - startStream captures eventUsLow = (tEvent * 1000) >>> 0. - TCP demux loop writes the 16-byte v1 header with the VPRT magic. - New fwPoller setInterval (5s) fetches /state, parses .stream, computes g2g, emits one summary line per poll cycle.
Diffstat (limited to 'scrypted/scrypted-viewport.ts')
-rw-r--r--scrypted/scrypted-viewport.ts52
1 files changed, 47 insertions, 5 deletions
diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts
index 38f3cec..c89eee4 100644
--- a/scrypted/scrypted-viewport.ts
+++ b/scrypted/scrypted-viewport.ts
@@ -6,7 +6,7 @@
// short git hash of the commit that added this constant — if the
// hash in the log doesn't match the HEAD this file came from, the
// Scrypted Script editor is still on stale code.
-const SCRIPT_VERSION = "cdd1827";
+const SCRIPT_VERSION = "pending";
//
// Architecture
// ------------
@@ -627,6 +627,11 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
async startStream(v: Viewport, tEvent: number = Date.now()) {
const since = () => Date.now() - tEvent;
this.console.log(`stream "${v.name}": start +${since()}ms`);
+ // event_us_low: low 32 bits of the Scrypted-host monotonic µs
+ // at camera-event arrival. The firmware stamps this on the
+ // most recently painted frame and echoes it back via /state,
+ // letting us compute glass-to-glass.
+ const eventUsLow = (tEvent * 1000) >>> 0;
// Race rule: cancel pending operations on every callback before
// beginning a fresh stream.
@@ -884,9 +889,14 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
continue;
}
seq++;
- const header = Buffer.alloc(8);
- header.writeUInt32BE(frame.length, 0);
- header.writeUInt32BE(seq, 4);
+ // 16-byte v1 header. Magic "VPRT" (0x56505254) lets
+ // the firmware autodetect old-vs-new clients during
+ // the rollout window.
+ const header = Buffer.alloc(16);
+ header.writeUInt32BE(0x56505254, 0); // "VPRT"
+ header.writeUInt32BE(frame.length, 4);
+ header.writeUInt32BE(seq, 8);
+ header.writeUInt32BE(eventUsLow, 12);
const t0 = Date.now();
// Single combined write avoids splitting header
// and body across two TCP packets — the firmware
@@ -898,7 +908,7 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
}
writeLatencies.push(Date.now() - t0);
if (writeLatencies.length > 200) writeLatencies.shift();
- bytesSent += 8 + frame.length;
+ bytesSent += header.length + frame.length;
sentFrames++;
// Track but don't gate on backpressure — the metric
// is still useful as a "kernel buffer was full"
@@ -975,6 +985,38 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
}, 10_000);
abort.signal.addEventListener("abort", () => clearInterval(skipLogger));
+ // Periodic /state poll to surface firmware-side window stats +
+ // computed glass-to-glass. Runs every 5s while the stream is
+ // active. g2g = (nowUsLow - last_paint_event_us_low) with
+ // 32-bit wrap; clamped to a 30s sanity ceiling because event
+ // timestamps from before the stream started are stale and
+ // would yield gigantic apparent latencies.
+ const fwPoller = setInterval(async () => {
+ try {
+ const st: any = await fetch(`http://${v.host}/state`, {
+ signal: AbortSignal.timeout(1500),
+ }).then(r => r.json());
+ const fs = st?.stream;
+ if (!fs || !fs.frames) return;
+ const fps = fs.window_us > 0 ? (fs.frames / (fs.window_us / 1e6)) : 0;
+ const mb = fs.window_us > 0 ? ((fs.bytes / (fs.window_us / 1e6)) / (1024 * 1024)) : 0;
+ let g2gMs = -1;
+ if (fs.last_paint_event_us_low) {
+ const nowUsLow = (Date.now() * 1000) >>> 0;
+ const diff = (nowUsLow - fs.last_paint_event_us_low) >>> 0;
+ if (diff < 30_000_000) g2gMs = diff / 1000;
+ }
+ this.console.log(
+ `firmware "${v.name}": fps=${fps.toFixed(1)} ${mb.toFixed(2)}MB/s | ` +
+ `recv=${fs.recv_min_us}/${fs.recv_avg_us}/${fs.recv_max_us}us | ` +
+ `dec=${fs.dec_min_us}/${fs.dec_avg_us}/${fs.dec_max_us}us | ` +
+ `paint=${fs.paint_min_us}/${fs.paint_avg_us}/${fs.paint_max_us}us | ` +
+ `idle=${fs.idle_min_us}/${fs.idle_avg_us}/${fs.idle_max_us}us | ` +
+ `g2g=${g2gMs >= 0 ? g2gMs.toFixed(0) + "ms" : "(no event yet)"}`);
+ } catch { /* /state timeout is fine, try next tick */ }
+ }, 5_000);
+ abort.signal.addEventListener("abort", () => clearInterval(fwPoller));
+
const timeoutMs = v.idleTimeoutMs > 0 ? v.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS;
const timeout = setTimeout(() => {
this.console.log(`"${v.name}": Scrypted-side stream timeout — stopping`);