src.nth.io/

summaryrefslogtreecommitdiff
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
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.
-rw-r--r--main/http_api.c142
-rw-r--r--scrypted/scrypted-viewport.ts146
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}`);
+ }
}
}