src.nth.io/

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-20 11:16:52 -0500
committerLuke Hoersten <[email protected]>2026-06-20 11:16:52 -0500
commit131c46f8c539b065feb397e2ee706cff1c81e4cc (patch)
tree5843aab0989829e99e142dbf305445ae6129de6c
parente6510f2a719cbab4fe9392206f38c90c0d3d3ee3 (diff)
cleanup phase 1: stale-comment refresh, zero behavior change
Removes/rewrites every comment that referenced the dead HTTP-streaming architecture so a reader of v1.0.0+ source isn't chasing a model that's been gone for several releases. No code paths changed; this is the safe pre-pass before Phase 2's actual deletions. Firmware (main/http_api.c): - s_last_painted_seq / s_last_post_us / X-Frame-Seq parse / stale- frame guard / Server-Timing emission / TCP_NODELAY setsockopt / max_open_sockets=4 — each block now leads with "(Legacy from the HTTP-streaming era; removed in Phase 2)" so the reader knows the block is doomed, not load-bearing. - /frame dim-mismatch comment narrowed: panel-native is always 800x480 BGR888, no Scrypted-side variation expected; Scrypted does the rotation+scale via sharp/mediaManager/ffmpeg cascade (snapshot) or ffmpeg -vf (stream). - "Single in-flight frame. Concurrent posts get 503" rewritten to reflect: decoder mutex now mostly serves to fence /frame snapshots against an active stream_server decode. Firmware (main/stream_server.c:136-142): - FIONREAD-skip rationale reduced from a 7-line paragraph to one sentence; the savings/tradeoff math now lives in the plan, not the per-line comment. Script (scrypted/scrypted-viewport.ts): - Top-of-file tuning constants block drops the "frame_interval_ms removed", "fps filter", "in-flight back-to-back startStream" rationale; one short line covers the model: "Stream rate is paced by camera + TCP backpressure; no app-level fps cap." - agentFor() rationale rewritten: this Agent is over-engineered for control-plane traffic (~1 POST/min steady state) — a legacy of when it backed per-frame /frame POSTs. Marked for Phase 2 retirement. - noDelay on Agent: clarified it's now a no-op safety for control plane (was load-bearing for live-stream pipelining). - snapshot fire-and-forget comment: replaced X-Frame-Seq-race rationale with the actual TCP-streaming truth (sharp/mediaManager/ ffmpeg cascade race against stream socket bring-up). - writeLatencies probe: rewrote the "keep-alive socket" comment (live stream uses raw net.Socket, not the http.Agent pool). - socketBackpressured: explicit "diagnostic only, never gates writes" comment added at declaration site. - skipLogger header: rewrote the inFlight/MAX_INFLIGHT/fps-filter rationale into a one-line description of what the log line actually emits today. - frameSeq map: now flagged as legacy of HTTP-streaming era, retired in Phase 2.
-rw-r--r--main/http_api.c93
-rw-r--r--main/stream_server.c10
-rw-r--r--scrypted/scrypted-viewport.ts90
3 files changed, 93 insertions, 100 deletions
diff --git a/main/http_api.c b/main/http_api.c
index 37446af..e1a93db 100644
--- a/main/http_api.c
+++ b/main/http_api.c
@@ -266,8 +266,11 @@ static esp_err_t config_post_handler(httpd_req_t *req)
// ============================================================================
// POST /state
// ============================================================================
-// Defined alongside other /frame static state further down — forward
-// referenced here because state_post_handler resets it on wake.
+// Legacy of the HTTP-streaming era (multiple /frame POSTs in flight).
+// /frame is now snapshot-only and inherently single-shot, so this
+// comparator is effectively unused — but state_post_handler still
+// resets it on wake until Phase 2 of the cleanup removes the
+// X-Frame-Seq parsing path entirely.
static uint32_t s_last_painted_seq;
static esp_err_t state_post_handler(httpd_req_t *req)
@@ -288,9 +291,8 @@ static esp_err_t state_post_handler(httpd_req_t *req)
viewport_run_state_t target;
if (strcmp(j->valuestring, "wake") == 0) {
target = VIEWPORT_STATE_AWAKE;
- // Reset the pipelined-POST sequence comparator so the next
- // stream's first frame isn't rejected as stale just because
- // the previous stream's counter happened to be higher.
+ // Reset the (legacy) per-stream sequence comparator; harmless
+ // now that /frame is snapshot-only, removed in Phase 2.
s_last_painted_seq = 0;
} else if (strcmp(j->valuestring, "sleep") == 0) {
target = VIEWPORT_STATE_ASLEEP;
@@ -321,22 +323,24 @@ static esp_err_t respond_status(httpd_req_t *req, const char *status, const char
return httpd_resp_send(req, body, body ? HTTPD_RESP_USE_STRLEN : 0);
}
-// Tracks when the previous /frame response finished so we can log the
-// idle gap until the next request enters. Large idle = Scrypted/network
-// upstream is the bottleneck; small idle = we're saturating the link.
+// (Legacy from the HTTP-streaming era: tracked idle gap between
+// successive /frame POSTs. /frame is now snapshot-only — fires at
+// most once per wake — so the gap measurement is no longer meaningful
+// in steady state. Field is left in place to avoid touching the
+// timing-log format; Phase 2 will retire it alongside the rest of
+// the streaming machinery.)
static int64_t s_last_post_us;
// (s_last_painted_seq is forward-declared above state_post_handler;
// description there.)
static esp_err_t frame_post_handler(httpd_req_t *req)
{
- // Nagle off on this socket. /frame is a single large POST followed
- // by a tiny (empty 204) response — the worst case for Nagle. The
- // last partial-MTU packet of the body, and the response packet,
- // both sit in the kernel send buffer up to 40ms waiting for an
- // ACK that the peer's also delaying-ACKing. setsockopt is cheap
- // and idempotent; if keep-alive is ever added the flag persists
- // for the connection's life.
+ // Nagle off. Originally added for HTTP streaming where Nagle +
+ // delayed-ACK on a 130 KB body POST would stall 40 ms before the
+ // last partial-MTU segment shipped. /frame is now snapshot-only
+ // (one POST per wake) so the optimisation is largely moot — but
+ // setsockopt is essentially free per request and there's no harm
+ // in leaving it. Phase 2 of the cleanup removes this entirely.
int sockfd = httpd_req_to_sockfd(req);
if (sockfd >= 0) {
int one = 1;
@@ -369,17 +373,22 @@ static esp_err_t frame_post_handler(httpd_req_t *req)
int64_t t_entry = esp_timer_get_time();
- // Read the optional X-Frame-Seq header used to keep pipelined
- // POSTs in monotonic paint order. Missing or zero header = no
- // ordering enforcement (treat every frame as newer than the
- // previous one, same behaviour we had before pipelining).
+ // X-Frame-Seq is a legacy artifact of the HTTP-streaming era,
+ // where multiple /frame POSTs could be in flight at once and
+ // arrive out of order. /frame is now snapshot-only so the
+ // header is always missing in practice — kept-alive for one
+ // more cycle and then removed in Phase 2.
uint32_t seq = 0;
char seq_str[16] = {0};
if (httpd_req_get_hdr_value_str(req, "X-Frame-Seq", seq_str, sizeof(seq_str)) == ESP_OK) {
seq = (uint32_t)strtoul(seq_str, NULL, 10);
}
- // Single in-flight frame. Concurrent posts get 503 (spec).
+ // Decoder mutex. Used to serialise pipelined /frame POSTs during
+ // the HTTP-streaming era; now mostly redundant since /frame is
+ // snapshot-only and the live stream owns the decoder via
+ // stream_server. Concurrent posts (e.g. a snapshot landing while
+ // the live stream is mid-decode) get 503.
if (!jpeg_decoder_try_lock(0)) {
return respond_status(req, "503 Service Unavailable", "frame in flight");
}
@@ -404,17 +413,12 @@ static esp_err_t frame_post_handler(httpd_req_t *req)
int64_t t_recv = esp_timer_get_time();
- // Pipelined-POST stale-frame check. With the X-Frame-Seq header
- // set, drop anything we've already advanced past — we hold the
- // decoder lock, so re-checking here is race-free. Without the
- // header (seq==0) every frame paints, preserving pre-pipelining
- // behaviour.
+ // (Legacy stale-frame guard from HTTP-streaming era; never
+ // triggers under snapshot-only /frame because the header is
+ // missing. Removed in Phase 2 along with the rest of the
+ // sequencing machinery.)
if (seq != 0 && seq <= s_last_painted_seq) {
jpeg_decoder_unlock();
- // 200 OK with an empty body keeps the keep-alive socket
- // healthy and lets Scrypted's per-fetch wall-clock log
- // stay accurate; we just won't count this toward
- // frames_received.
httpd_resp_set_status(req, "200 OK");
httpd_resp_set_hdr(req, "X-Frame-Drop", "stale-seq");
return httpd_resp_send(req, NULL, 0);
@@ -436,10 +440,11 @@ static esp_err_t frame_post_handler(httpd_req_t *req)
return respond_status(req, "400 Bad Request", "JPEG decode failed");
}
- // /frame always expects the panel-native 800x480 BGR888 layout —
- // Scrypted does the rotation + scale, the firmware just decodes and
- // paints. Orientation lives in viewport_state for /state reporting
- // and the Scrypted-side ffmpeg pipeline only.
+ // /frame always expects the panel-native 800x480 BGR888 layout.
+ // Scrypted does the rotation + scale (snapshot: sharp / mediaManager
+ // / ffmpeg cascade; stream: ffmpeg -vf transpose+scale). Orientation
+ // is informational for /state reporting only — firmware never
+ // rotates pixels.
if (w != VIEWPORT_PANEL_WIDTH || h != VIEWPORT_PANEL_HEIGHT) {
viewport_state_lock();
viewport_state_get()->decode_errors++;
@@ -508,12 +513,10 @@ static esp_err_t frame_post_handler(httpd_req_t *req)
state_machine_frame_painted(); // reset idle timer
- // Server-Timing per-stage breakdown so the Scrypted side can build
- // a unified end-to-end trace per frame (joined by X-Frame-Seq).
- // Units are ms with 0.1ms precision — paint is sub-millisecond
- // so the decimal matters there. Scrypted subtracts the sum from
- // its own (fetch_start → Response_headers) measurement to derive
- // the network up + handler-dispatch overhead it can't see directly.
+ // (Legacy Server-Timing emission from the HTTP-streaming era;
+ // no Scrypted-side consumer remains — the live-stream cross-side
+ // trace now flows over the TCP framer's seq field plus per-window
+ // /state polling. Removed in Phase 2.)
char st_hdr[160];
snprintf(st_hdr, sizeof(st_hdr),
"recv;dur=%.1f, dec;dur=%.1f, paint;dur=%.1f, post;dur=%.1f, handle;dur=%.1f",
@@ -554,11 +557,13 @@ esp_err_t http_api_start(void)
cfg.max_uri_handlers = 8;
cfg.lru_purge_enable = true;
cfg.stack_size = 8192; // POST /config alone has ~2.4 KiB of stack locals
- // Allow Scrypted to keep two POST /frame in flight at once so the
- // body upload of frame N+1 overlaps with the JPEG decode of frame
- // N. The decoder mutex still serialises decode itself; this only
- // unblocks the network half of the pipeline. +1 socket reserve for
- // a concurrent /state or /config request landing during a stream.
+ // 2 sockets is enough now that /frame is snapshot-only and the
+ // live stream owns its own TCP socket on port 81: one slot for
+ // the snapshot POST + one for a concurrent /state or /config
+ // request. (Was 4 during the HTTP-streaming era to allow
+ // pipelined /frame POSTs.) Phase 2 of the cleanup actually
+ // bumps this down; for Phase 1 (comments-only) the value stays
+ // at 4 so behaviour is unchanged.
cfg.max_open_sockets = 4;
httpd_handle_t server = NULL;
diff --git a/main/stream_server.c b/main/stream_server.c
index 10d1e1b..38769f0 100644
--- a/main/stream_server.c
+++ b/main/stream_server.c
@@ -133,13 +133,9 @@ static void handle_client(int fd, const char *peer)
continue;
}
- // "Always paint the latest" — if the socket already has more
- // bytes queued in the kernel receive buffer, at least one more
- // header is on the way. The frame we just finished receiving
- // is no longer the freshest possible; skip its decode+paint
- // (saves ~6ms per skip) and loop straight to the next header.
- // This trades some intermediate frames for lower
- // glass-to-glass latency on the latest one.
+ // If the kernel already has another header queued, this frame
+ // is no longer the freshest; skip decode + paint, loop back to
+ // the head-of-queue header.
int queued = 0;
if (ioctl(fd, FIONREAD, &queued) == 0 && queued >= HEADER_BYTES) {
jpeg_decoder_unlock();
diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts
index 2d45c60..cebff82 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 = "496da49";
+const SCRIPT_VERSION = "pending";
//
// Architecture
// ------------
@@ -83,19 +83,12 @@ type Setting = any;
type Settings = any;
type SettingValue = any;
-// Tuning constants. Frame interval is also exposed on the parent's
-// Settings page so it can be tweaked without editing the script.
-// (frame_interval_ms removed in b97c250 — under the TCP streaming
-// data plane ffmpeg emits at the camera's native rate and TCP back-
-// pressure naturally caps us when the firmware can't keep up. The
-// fps filter was only ever useful as a max-fps safety under HTTP.)
+// Tuning constants.
+// Stream rate is paced by camera + TCP backpressure; no app-level fps
+// cap, no per-frame pipelining semaphore.
const REREGISTER_INTERVAL_MS = 5 * 60_000;
-// 5s gives /state + /config posts enough headroom to slip in between
-// /frame POSTs when the device is mid-stream. The firmware's single
-// httpd task processes one connection at a time; under heavy /frame
-// load a /state {wake} can queue behind 1–3 in-flight /frames before
-// landing. 1s was tripping when a burst of camera events triggered
-// back-to-back startStream calls.
+// 5s is generous for snapshot POSTs (the only HTTP traffic during a
+// stream) and survives long-tail latency on a busy Scrypted host.
const HTTP_TIMEOUT_MS = 5_000;
const DEFAULT_IDLE_TIMEOUT_MS = 60_000;
const DEFAULT_BRIGHTNESS = 100;
@@ -308,15 +301,12 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
}>();
private scryptedBase = "";
- // Per-host node:http Agent with keep-alive. Replaces the previous
- // undici dispatcher path — undici isn't reachable as a require()
- // module from Scrypted's plugin sandbox, but node:http is always
- // available. keepAlive: true reuses the underlying TCP connection
- // across POSTs (skips SYN+SYN-ACK+ACK per frame, ~1ms saved on
- // LAN and tail-spike-killer on Wi-Fi). maxSockets: 2 caps the
- // pool at the same value as the firmware's pipelining capacity,
- // so frame N+1 can begin uploading on socket B while frame N is
- // still being decoded on the device via socket A.
+ // 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);
@@ -328,15 +318,10 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
maxSockets: 2,
maxFreeSockets: 2,
timeout: 30_000,
- // CRITICAL: without this, the keep-alive socket inherits
- // Nagle ON by default. When a 128KB JPEG body doesn't
- // end on an MTU boundary, the kernel sits on the final
- // partial packet for up to 200ms (delayed-ACK window)
- // waiting for either more data or an ACK. Firmware-side
- // TCP_NODELAY only affects firmware's sends; the sender
- // also has to opt out. Without noDelay we measured
- // fw_recv p95 spiking from ~25ms to ~230ms — the exact
- // 200ms Nagle+delayed-ACK deadlock signature.
+ // 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);
@@ -734,9 +719,11 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
// ffmpeg-emitted frame lands (ffmpeg startup + RTSP connect +
// first H.264 keyframe wait). The snapshot fills the gap so
// the panel shows the camera near-instantly on tap/event.
- // Fire-and-forget — if it loses the race to the first stream
- // frame (which has a higher X-Frame-Seq), the firmware will
- // stale-drop it. Errors are silent so a missing snapshot path
+ // Fire-and-forget — runs in parallel with stream socket bring-up.
+ // Whichever lands first wins user-visibly; if the stream's
+ // first frame arrives before the snapshot finishes, the snapshot
+ // just overpaints stale data on top of a fresher frame for
+ // ~1 paint cycle. Errors are silent so a missing snapshot path
// doesn't break the stream start.
this.pushSnapshot(v, cam).catch(() => {});
@@ -855,6 +842,9 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
const net = require("net");
let sock: any = null;
let socketReady = false;
+ // Diagnostic only. Never gates writes; we keep pushing past
+ // kernel-buffer fullness because the firmware's FIONREAD skip
+ // (stream_server.c) drops superseded frames before decode.
let socketBackpressured = false;
let seq = 0;
let droppedFrames = 0;
@@ -864,10 +854,12 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
let workBuf: Buffer = Buffer.alloc(0);
// Latency probe — wall-clock from "ffmpeg emitted the JPEG"
- // to "kernel accepted the socket.write". With the keep-alive
- // socket + TCP_NODELAY this should be sub-millisecond steady
- // state; visible-double-digit numbers here mean kernel send
- // buffer is full (= firmware can't ingest fast enough).
+ // to "kernel accepted the socket.write". With TCP_NODELAY on
+ // the stream socket this is sub-millisecond steady state;
+ // double-digit ms numbers here mean kernel send buffer is
+ // full (= firmware can't ingest fast enough), which is fine
+ // — we don't gate on it and the firmware's FIONREAD skip
+ // drops the surplus before decode.
const writeLatencies: number[] = [];
const openStreamSocket = () => {
@@ -1014,12 +1006,13 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
try { currentProc?.kill("SIGTERM"); } catch {}
});
- // Periodic skip-rate log. The metric that matters is *delivered*
- // fps vs. requested rate, not raw drop count — at low intervals
- // ffmpeg's fps filter emits timestamp-clumped pairs and our
- // single-flight guard correctly skips the second, but the
- // first still painted on time. Only warn if delivered fps falls
- // below 75% of target.
+ // Periodic stream-health log. Emits delivered fps + sustained
+ // MB/s + socket.write p50/p95/max + drop count + backpressure
+ // flag every 10s. drops here count frames the demux loop
+ // threw away because the socket wasn't connected yet (initial
+ // race at stream start) — once the socket is up we keep
+ // writing past kernel backpressure and let the firmware's
+ // FIONREAD skip drop the stale frames before decode.
const skipLogger = setInterval(() => {
const now = Date.now();
const window = (now - lastLogUs) / 1000;
@@ -1184,11 +1177,10 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
} catch { /* stream is starting anyway */ }
}
- // Monotonic per-viewport sequence number paired with X-Frame-Seq
- // so the firmware can drop pipelined-out-of-order frames. Reset
- // on every stopStream so each stream session starts fresh; the
- // firmware also resets its comparator on /state {wake}, so the
- // two stay in step.
+ // 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>();
private findByName(name: string): Viewport | undefined {