diff options
| author | Luke Hoersten <[email protected]> | 2026-06-20 11:23:23 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-06-20 11:23:23 -0500 |
| commit | 4d5fd28400dad6a8e34f77a527d7ac4e92e76093 (patch) | |
| tree | fcccb70ef55281d1b046a713bcdbb5a4f31cd041 | |
| parent | 436d99e099ce085cb02f1b3906f9d7cdc3871e07 (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.
| -rw-r--r-- | main/http_api.c | 142 | ||||
| -rw-r--r-- | scrypted/scrypted-viewport.ts | 146 |
2 files changed, 46 insertions, 242 deletions
diff --git a/main/http_api.c b/main/http_api.c index e1a93db..cb6a8b3 100644 --- a/main/http_api.c +++ b/main/http_api.c @@ -10,7 +10,6 @@ #include "esp_http_server.h" #include "esp_log.h" #include "esp_timer.h" -#include "lwip/sockets.h" // TCP_NODELAY setsockopt for /frame socket #include "display.h" #include "jpeg_decoder.h" @@ -266,13 +265,6 @@ static esp_err_t config_post_handler(httpd_req_t *req) // ============================================================================ // POST /state // ============================================================================ -// 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) { char buf[64]; @@ -291,9 +283,6 @@ 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 (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; } else { @@ -323,30 +312,8 @@ 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); } -// (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. 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; - (void)setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)); - } - // Content-Type must be image/jpeg. char ct[40] = {0}; if (httpd_req_get_hdr_value_str(req, "Content-Type", ct, sizeof(ct)) != ESP_OK || @@ -373,22 +340,11 @@ static esp_err_t frame_post_handler(httpd_req_t *req) int64_t t_entry = esp_timer_get_time(); - // 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); - } - - // 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. + // Fence /frame snapshot against an in-flight stream decode. The + // live stream task owns the decoder via stream_server; a snapshot + // landing mid-decode gets 503 here and Scrypted silently drops it + // (the snapshot is a fast-path nicety; the stream is the real + // data plane). if (!jpeg_decoder_try_lock(0)) { return respond_status(req, "503 Service Unavailable", "frame in flight"); } @@ -413,17 +369,6 @@ static esp_err_t frame_post_handler(httpd_req_t *req) int64_t t_recv = esp_timer_get_time(); - // (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(); - httpd_resp_set_status(req, "200 OK"); - httpd_resp_set_hdr(req, "X-Frame-Drop", "stale-seq"); - return httpd_resp_send(req, NULL, 0); - } - // Decode straight into the panel's back framebuffer — no scratch // buffer, no later memcpy. The flip below is a cache writeback + // index swap inside the IDF DPI driver. @@ -458,7 +403,6 @@ static esp_err_t frame_post_handler(httpd_req_t *req) esp_err_t paint_err = display_flip_back_buffer(); int64_t t_paint = esp_timer_get_time(); - int64_t t_post = 0; if (paint_err != ESP_OK) { jpeg_decoder_unlock(); return respond_status(req, "500 Internal Server Error", "display paint failed"); @@ -468,65 +412,25 @@ static esp_err_t frame_post_handler(httpd_req_t *req) viewport_state_t *st = viewport_state_get(); st->frames_received++; st->last_frame_us = esp_timer_get_time(); - uint64_t fr = st->frames_received; viewport_state_unlock(); - if (seq != 0) s_last_painted_seq = seq; - jpeg_decoder_unlock(); - // Track the bookkeeping tail too so we can see if anything between - // draw_bitmap-returns and the response-send adds up. - t_post = esp_timer_get_time(); - - // Idle gap = time from the PREVIOUS /frame response finishing to - // THIS request landing on the handler. Large gap = upstream - // (Scrypted / ffmpeg / TCP slow-start on a fresh socket) was idle; - // we're not the bottleneck. Skipped on the very first frame. - int64_t idle_us = s_last_post_us ? (t_entry - s_last_post_us) : 0; - s_last_post_us = t_post; - - // Timing log every 10 frames so the user can see where each /frame's - // wall-clock budget is going. Buckets: - // idle : time since the previous response finished (upstream gap) - // lock : mutex acquire - // ttfb : lock-acquired -> first body byte (TCP setup) - // body : remaining body bytes (wire time) - // dec : hardware JPEG decode - // paint : esp_lcd_panel_draw_bitmap → DSI fast-path - // post : state counters + unlock - if (fr % 10 == 0) { - ESP_LOGI(TAG, - "frame %llu: idle=%lldus lock=%lldus ttfb=%lldus body=%lldus " - "dec=%lldus paint=%lldus post=%lldus total=%lldms (jpeg=%uKB)", - (unsigned long long)fr, - (long long)idle_us, - (long long)(t_lock - t_entry), - (long long)(t_first_byte - t_lock), - (long long)(t_recv - t_first_byte), - (long long)(t_decode - t_recv), - (long long)(t_paint - t_decode), - (long long)(t_post - t_paint), - (long long)((t_post - t_entry) / 1000), - (unsigned)(got / 1024)); - } + // Snapshot stage timings, one line per /frame POST. /frame fires + // at most once per wake event, so logging every snapshot (not + // every 10) is the right cadence. + ESP_LOGI(TAG, + "snapshot: lock=%lldus ttfb=%lldus body=%lldus dec=%lldus paint=%lldus total=%lldms (jpeg=%uKB)", + (long long)(t_lock - t_entry), + (long long)(t_first_byte - t_lock), + (long long)(t_recv - t_first_byte), + (long long)(t_decode - t_recv), + (long long)(t_paint - t_decode), + (long long)((t_paint - t_entry) / 1000), + (unsigned)(got / 1024)); state_machine_frame_painted(); // reset idle timer - // (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", - (t_recv - t_first_byte) / 1000.0, - (t_decode - t_recv) / 1000.0, - (t_paint - t_decode) / 1000.0, - (t_post - t_paint) / 1000.0, - (t_post - t_entry) / 1000.0); - httpd_resp_set_hdr(req, "Server-Timing", st_hdr); - httpd_resp_set_status(req, "204 No Content"); return httpd_resp_send(req, NULL, 0); } @@ -557,14 +461,10 @@ 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 - // 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; + // /frame is snapshot-only and the live stream owns its own TCP + // socket on port 81. Two HTTP sockets cover: one snapshot POST + // concurrent with one /state or /config request. + cfg.max_open_sockets = 2; 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 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}`); + } } } |
