src.nth.io/

summaryrefslogtreecommitdiff
path: root/scrypted
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-20 11:23:23 -0500
committerLuke Hoersten <[email protected]>2026-06-20 11:23:23 -0500
commit4d5fd28400dad6a8e34f77a527d7ac4e92e76093 (patch)
treefcccb70ef55281d1b046a713bcdbb5a4f31cd041 /scrypted
parent436d99e099ce085cb02f1b3906f9d7cdc3871e07 (diff)
cleanup phase 2: delete HTTP-streaming-era dead code
Both sides simultaneously because the script's X-Frame-Seq sender and the firmware's X-Frame-Seq receiver negotiated a contract that's now retired entirely. Firmware (main/http_api.c): - Delete static uint32_t s_last_painted_seq (declaration + reset in state_post_handler + the read in frame_post_handler + the assignment after paint). - Delete the X-Frame-Seq header read (httpd_req_get_hdr_value_str + strtoul block). - Delete the X-Frame-Drop: stale-seq response path. - Delete the entire Server-Timing httpd_resp_set_hdr block. - Delete the setsockopt(TCP_NODELAY) at frame_post_handler entry. /frame is a single body POST → empty 204; no second packet for Nagle to coalesce with on the response side. - Delete static int64_t s_last_post_us + the idle_us computation. /frame fires at most once per wake; idle gap between wakes is dominated by user/event timing, not anything firmware-controllable. - Drop the per-10-frames condition on the timing log — /frame fires rarely enough that one log per snapshot is the right cadence — and rename "frame N: ..." → "snapshot: ..." to match. - Drop cfg.max_open_sockets 4 → 2 (snapshot POST + concurrent /state or /config). - Drop #include "lwip/sockets.h" (orphaned with TCP_NODELAY). Script (scrypted/scrypted-viewport.ts): - Delete the agents Map + agentFor() method (per-host keepAlive Agent pool; over-engineered for ~1 POST/min control plane). - Delete httpRequest() helper (40 lines wrapping http.request to surface tHeaders/tDone — no consumer reads those fields anymore). - Rewrite postJSON() to a 10-line fetch() with AbortSignal.timeout. - Delete the frameSeq Map + the seq counter + X-Frame-Seq header on the snapshot fetch + the X-Frame-Drop response check + the frameSeq.delete in stopStream. - Extract buildVf(orientation, panelW, panelH) helper near top of ScryptedViewportProvider — used by both startStream (live) and pushSnapshot (one-shot ffmpeg fallback). TCP_NODELAY references remain in the live-stream socket path (scrypted-viewport.ts:773, 788). That's a different socket (raw net.Socket on TCP/81) and noDelay there is what eliminates Nagle stalls under the streaming-heavy live workload. Load-bearing.
Diffstat (limited to 'scrypted')
-rw-r--r--scrypted/scrypted-viewport.ts146
1 files changed, 25 insertions, 121 deletions
diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts
index 92c42ea..1b950d1 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 = "131c46f";
+const SCRIPT_VERSION = "pending";
//
// Architecture
// ------------
@@ -301,88 +301,6 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
}>();
private scryptedBase = "";
- // Per-host node:http Agent for the control plane (/state, /config,
- // and the snapshot /frame POST). Over-engineered for our volume
- // (<1 POST/min steady state, ~5 around a wake event) — legacy of
- // the HTTP-streaming era where it backed the per-frame /frame
- // POSTs. Phase 2 of the cleanup retires the pool and reverts
- // postJSON to a plain fetch() with AbortSignal.timeout.
- private agents = new Map<string, any>();
- private agentFor(host: string): any {
- let a = this.agents.get(host);
- if (!a) {
- const http = require("http");
- a = new http.Agent({
- keepAlive: true,
- keepAliveMsecs: 30_000,
- maxSockets: 2,
- maxFreeSockets: 2,
- timeout: 30_000,
- // noDelay was load-bearing when this Agent fronted the
- // live stream's per-frame POSTs (200ms Nagle+delayed-
- // ACK stall). It's now a no-op safety for control-plane
- // traffic; harmless to leave on.
- noDelay: true,
- });
- this.agents.set(host, a);
- }
- return a;
- }
-
- // Raw http.request wrapper that gives us per-stage timing the
- // built-in fetch hides. Returns { status, headers, tHeaders,
- // tDone } so callers can compute req-time vs body-read separately.
- private httpRequest(
- opts: {
- host: string; port: number; path: string; method: string;
- headers: Record<string, string>;
- body?: Buffer | string;
- timeoutMs: number;
- abort?: AbortSignal;
- }
- ): Promise<{ status: number; headers: Record<string, string | string[] | undefined>; tHeaders: number; tDone: number; }> {
- return new Promise((resolve, reject) => {
- const http = require("http");
- const req = http.request({
- host: opts.host,
- port: opts.port,
- path: opts.path,
- method: opts.method,
- headers: opts.headers,
- agent: this.agentFor(opts.host),
- timeout: opts.timeoutMs,
- }, (res: any) => {
- const tHeaders = Date.now();
- // Drain body so the socket can return to the keep-alive
- // pool. Empty body responses (204/200) still need this
- // — the agent won't recycle the socket until 'end'.
- res.on("data", () => {});
- res.on("end", () => resolve({
- status: res.statusCode || 0,
- headers: res.headers,
- tHeaders,
- tDone: Date.now(),
- }));
- res.on("error", reject);
- });
- const onAbort = () => req.destroy(new Error("aborted"));
- if (opts.abort) {
- if (opts.abort.aborted) onAbort();
- else opts.abort.addEventListener("abort", onAbort, { once: true });
- }
- req.on("timeout", () => req.destroy(new Error("request timeout")));
- req.on("error", reject);
- // Belt-and-suspenders: in case the Agent's noDelay option
- // isn't honored on every Node version, force it on the
- // socket as soon as it's allocated. Without this the
- // socket inherits Nagle ON and a JPEG body ending mid-MTU
- // stalls for the 200ms delayed-ACK window.
- req.on("socket", (s: any) => { try { s.setNoDelay(true); } catch {} });
- if (opts.body != null) req.write(opts.body);
- req.end();
- });
- }
-
constructor(nativeId?: string) {
super(nativeId);
this.start().catch(e => this.console.error("start failed", e));
@@ -761,9 +679,7 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
// scaling to an EXPLICIT panelWxpanelH (with setsar to clear
// any leftover aspect-ratio metadata) makes the final encoded
// dimensions deterministic regardless of source resolution.
- const vf = v.orientation === "portrait"
- ? `transpose=1,scale=${panelW}:${panelH}:flags=lanczos,setsar=1`
- : `scale=${panelW}:${panelH}:flags=lanczos,setsar=1`;
+ const vf = this.buildVf(v.orientation, panelW, panelH);
const qv = String(v.jpegQuality);
// Diagnostic — confirms which filter chain the *currently loaded*
// script is actually using. If you don't see this line in the
@@ -1060,7 +976,6 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
clearTimeout(s.timeout);
this.streams.delete(name);
const v = this.findByName(name);
- if (v) this.frameSeq.delete(v.nativeId!);
if (sendSleep && v?.host) {
this.postJSON(`http://${v.host}/state`, { state: "sleep" }).catch(() => {});
}
@@ -1124,9 +1039,7 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
// Path 3: ffmpeg fallback. The slow ~500ms cold-start path.
if (!transformed.length) {
- const vf = needsRotate
- ? `transpose=1,scale=${panelW}:${panelH}:flags=lanczos,setsar=1`
- : `scale=${panelW}:${panelH}:flags=lanczos,setsar=1`;
+ const vf = this.buildVf(v.orientation, panelW, panelH);
const { spawn } = require("child_process");
const ffmpegPath =
(mediaManager.getFFmpegPath ? await mediaManager.getFFmpegPath() : undefined) ||
@@ -1157,31 +1070,33 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
if (transformed.length < 4) return;
const tTransformed = Date.now();
- const seq = (this.frameSeq.get(v.nativeId!) || 0) + 1;
- this.frameSeq.set(v.nativeId!, seq);
try {
const res = await fetch(`http://${v.host}/frame`, {
method: "POST",
- headers: { "Content-Type": "image/jpeg", "X-Frame-Seq": String(seq) },
+ headers: { "Content-Type": "image/jpeg" },
body: transformed,
signal: AbortSignal.timeout(2000),
});
await res.text().catch(() => "");
const tPosted = Date.now();
- const wasStale = res.headers.get("X-Frame-Drop") === "stale-seq";
this.console.log(
`snapshot "${v.name}" via ${path}: ${tPosted - t0}ms total ` +
`(takePicture=${tDecoded - t0}ms transform=${tTransformed - tDecoded}ms ` +
- `post=${tPosted - tTransformed}ms, ${(transformed.length / 1024).toFixed(0)}KB)` +
- (wasStale ? " — beaten by stream frame" : ""));
+ `post=${tPosted - tTransformed}ms, ${(transformed.length / 1024).toFixed(0)}KB)`);
} catch { /* stream is starting anyway */ }
}
- // Legacy of the HTTP-streaming era. Used to feed the X-Frame-Seq
- // header on /frame POSTs so the firmware could drop pipelined-
- // out-of-order frames. /frame is now snapshot-only (single-shot),
- // so the comparator never trips. Retired in Phase 2.
- private frameSeq = new Map<string, number>();
+ // ffmpeg -vf filter chain producing panel-native 800x480 BGR888.
+ // Used by both startStream (live) and pushSnapshot (one-shot
+ // ffmpeg fallback). Rotation goes FIRST so the final mjpeg encoder
+ // sees the exact target dimensions — earlier we observed mjpeg
+ // writing pre-rotation dims into the JPEG SOF marker when scale
+ // came first, breaking the firmware's strict dim check.
+ private buildVf(orientation: string, panelW: number, panelH: number): string {
+ return orientation === "portrait"
+ ? `transpose=1,scale=${panelW}:${panelH}:flags=lanczos,setsar=1`
+ : `scale=${panelW}:${panelH}:flags=lanczos,setsar=1`;
+ }
private findByName(name: string): Viewport | undefined {
for (const v of this.viewports.values()) if (v.name === name) return v;
@@ -1250,26 +1165,15 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
// ------------------------------------------------------------------------
private async postJSON(url: string, body: any) {
- const u = new URL(url);
- const payload = JSON.stringify(body);
- const ctrl = new AbortController();
- const to = setTimeout(() => ctrl.abort(), HTTP_TIMEOUT_MS);
- try {
- const res = await this.httpRequest({
- host: u.hostname, port: Number(u.port) || 80, path: u.pathname + u.search,
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "Content-Length": String(Buffer.byteLength(payload)),
- },
- body: payload,
- timeoutMs: HTTP_TIMEOUT_MS,
- abort: ctrl.signal,
- });
- if ((res.status < 200 || res.status >= 300) && res.status !== 204) {
- throw new Error(`POST ${url} -> ${res.status}`);
- }
- } finally { clearTimeout(to); }
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
+ });
+ if (!res.ok && res.status !== 204) {
+ throw new Error(`POST ${url} -> ${res.status}`);
+ }
}
}