diff options
| author | Luke Hoersten <[email protected]> | 2026-06-15 09:29:52 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-06-15 09:29:52 -0500 |
| commit | 28a6335576cfec144dc3860bc47f7f6d87274651 (patch) | |
| tree | 3c220a6b70a1ace878f017cd3af7ba51488a0018 | |
| parent | bba40948edccd4ac6c4a661c8f16c6469b212985 (diff) | |
pipeline two /frame POSTs + HTTP keep-alive on the Scrypted side
Decouples network upload from firmware decode-and-paint. Before, the
inFlight boolean guard meant Scrypted sent frame N, waited for the
full ~80ms response (~40ms body upload + ~20ms decode + ~50µs paint +
ack), THEN sent frame N+1. The next ffmpeg frame arriving during that
window got dropped.
After:
- ScryptedViewportProvider keeps a per-host undici Agent with
keepAliveTimeout 30s, connections:2, pipelining:0. Sockets stay
open across /frame, /state, /config — saves the SYN+SYN-ACK+ACK
round-trip every POST (small on LAN but real on Wi-Fi).
- Stream loop's inFlight boolean is now a counter capped at 2 so
frame N+1 can begin uploading on socket B while frame N is still
being decoded on the device via socket A. Roughly doubles effective
throughput when body upload time dominates.
Firmware side:
- esp_http_server max_open_sockets bumped 7→4 explicitly: 2 for
pipelined /frame + 2 spare for concurrent /state and /config slots.
The JPEG decoder mutex still serialises decode (only one /frame
can be decoding at a time); this change only unblocks the network
half of the pipeline.
| -rw-r--r-- | main/http_api.c | 6 | ||||
| -rw-r--r-- | scrypted/scrypted-viewport.ts | 52 |
2 files changed, 52 insertions, 6 deletions
diff --git a/main/http_api.c b/main/http_api.c index 28ceeeb..4e03868 100644 --- a/main/http_api.c +++ b/main/http_api.c @@ -486,6 +486,12 @@ 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. + cfg.max_open_sockets = 4; httpd_handle_t server = NULL; ESP_RETURN_ON_ERROR(httpd_start(&server, &cfg), TAG, "httpd_start"); diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts index 66f89dc..ae99e09 100644 --- a/scrypted/scrypted-viewport.ts +++ b/scrypted/scrypted-viewport.ts @@ -291,6 +291,30 @@ class ScryptedViewportProvider extends ScryptedDeviceBase }>(); private scryptedBase = ""; + // Per-host undici Agent so /frame POSTs reuse the TCP connection + // and pipeline two-in-flight. Reusing the socket saves the SYN + + // SYN-ACK + ACK round-trip (~1ms on LAN, more painful on Wi-Fi), + // and the connections:2 cap lets us overlap upload of frame N+1 + // with the firmware's decode-and-paint of frame N. pipelining:0 + // because esp_http_server doesn't support HTTP/1.1 request + // pipelining on a single connection — we want two independent + // sockets, not two requests stacked on one. + private dispatchers = new Map<string, any>(); + private dispatcherFor(host: string): any { + let d = this.dispatchers.get(host); + if (!d) { + const { Agent } = require("undici"); + d = new Agent({ + keepAliveTimeout: 30_000, + keepAliveMaxTimeout: 60_000, + connections: 2, + pipelining: 0, + }); + this.dispatchers.set(host, d); + } + return d; + } + constructor(nativeId?: string) { super(nativeId); this.start().catch(e => this.console.error("start failed", e)); @@ -683,7 +707,15 @@ class ScryptedViewportProvider extends ScryptedDeviceBase // Single-flight: only one POST /frame in flight at a time. The // firmware's JPEG decoder mutex returns 503 otherwise; we just // drop the spare frames silently — natural rate-limiter. - let inFlight = false; + // Allow up to MAX_INFLIGHT concurrent /frame POSTs so that the + // network upload of frame N+1 overlaps with the firmware's + // decode+paint of frame N. The firmware's JPEG decoder mutex + // serialises the decode itself; this only buys us the upload + // overlap (≈ half of total /frame wall time). Keep this in + // sync with cfg.max_open_sockets in main/http_api.c — that + // sits at 4 (2 for streaming + 2 spare for /state, /config). + const MAX_INFLIGHT = 2; + let inFlight = 0; let droppedFrames = 0; let sentFrames = 0; let lastLogUs = Date.now(); @@ -737,8 +769,8 @@ class ScryptedViewportProvider extends ScryptedDeviceBase workBuf = workBuf.subarray(eoi + 2); if (frame.length < 4 || frame[0] !== 0xff || frame[1] !== 0xd8) continue; - if (inFlight) { droppedFrames++; continue; } - inFlight = true; + if (inFlight >= MAX_INFLIGHT) { droppedFrames++; continue; } + inFlight++; sentFrames++; this.pushStreamFrame(v, frame, abort) .catch(e => { @@ -747,7 +779,7 @@ class ScryptedViewportProvider extends ScryptedDeviceBase const cause = (err as any)?.cause?.code || ""; this.console.warn(`pushStreamFrame "${v.name}":`, err.message, cause ? `(${cause})` : ""); }) - .finally(() => { inFlight = false; }); + .finally(() => { inFlight--; }); } }); @@ -858,7 +890,12 @@ class ScryptedViewportProvider extends ScryptedDeviceBase headers: { "Content-Type": "image/jpeg" }, body: jpeg, signal: abort.signal, - }); + // @ts-ignore - undici dispatcher: keep-alive + 2 connections + // per host. Lets frame N+1 begin uploading on socket B while + // frame N is still being decoded/painted on the device via + // socket A. + dispatcher: this.dispatcherFor(v.host), + } as any); const wallMs = Date.now() - t0; const n = (this.fetchCount.get(v.nativeId!) || 0) + 1; this.fetchCount.set(v.nativeId!, n); @@ -949,12 +986,15 @@ class ScryptedViewportProvider extends ScryptedDeviceBase // ------------------------------------------------------------------------ private async postJSON(url: string, body: any) { + const host = new URL(url).host.split(":")[0]; const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), - }); + // @ts-ignore - undici dispatcher + dispatcher: this.dispatcherFor(host), + } as any); if (!res.ok && res.status !== 204) { const text = await res.text().catch(() => ""); throw new Error(`POST ${url} -> ${res.status} ${text}`); |
