src.nth.io/

summaryrefslogtreecommitdiff
path: root/scrypted
AgeCommit message (Collapse)AuthorFilesLines
2026-06-21scrypted: log end-of-method state for lifecycle calls + bootstrap exceptionLuke Hoersten1-3/+10
start() previously logged only at entry, so a silent throw between 'Scrypted Viewport up' and 'this.running = true' would leave us guessing. Add explicit end-of-method logs with the post-write state, plus a try/catch around bootstrap that surfaces the exception message before re-throwing. This will tell us whether start() reaches its set this.running=true on script load (i.e. whether the prototype state-proxy write fires) or whether bootstrap is silently failing somewhere mid-way.
2026-06-21scrypted: stamp SCRIPT_VERSION = 101fb3eLuke Hoersten1-1/+1
2026-06-21scrypted: add OnOff alongside StartStop + lifecycle logging to identify ↵Luke Hoersten1-17/+41
panel binding The 'Status and Controls' panel in @scrypted/core 0.3.147 was rendering Unknown despite the previous commit adding StartStop. Reading the SDK (sdk/src/index.ts:195 + plugins/core/src/script.ts:47) confirmed how it should work: - ScryptedDeviceBase installs Object.defineProperty getters/setters for every interface property at runtime, so this.running = true propagates through _lazyLoadDeviceState → getDeviceState proxy → system state. - Scripts plugin's mergeHandler auto-detects interfaces by mapping method names: start/stop → StartStop, turnOn/turnOff → OnOff, putSetting/getSettings → Settings, etc. In theory the previous commit was sufficient. To pin down whether the panel is calling something different (older Scrypted UIs lean toward OnOff), this commit: - Implements OnOff alongside StartStop. Both pairs are bound to the same drain/bootstrap logic; whichever the panel calls, the user's intent succeeds. - Initialises this.running = false and this.on = false synchronously in the constructor so the device state record carries a defined value at registration time, instead of Scrypted defaulting to Unknown on undefined. - Logs 'lifecycle: X() called (...)' on every method entry. After the user reloads + clicks STOP / START, the console will reveal exactly which method Scrypted is invoking — or whether neither is. If one of the methods fires, the other is dead code we can drop. If neither fires on the panel's STOP/START button, the panel is a Scripts-runtime control unrelated to device interfaces and we need to look for a different lifecycle hook.
2026-06-21scrypted: stamp SCRIPT_VERSION = 39b0479Luke Hoersten1-1/+1
2026-06-21scrypted: log attachListener early-return when no camera assignedLuke Hoersten1-2/+5
Silent early-return at 'if (!v.cameraId) return;' makes a brand-new viewport with no camera selected look identical (in the console) to one that subscribed successfully — there's no positive or negative signal until you try to fire a camera event. After observing a fresh viewport produce zero output on a doorbell press, switching the early-return to a warning that says 'no camera assigned — open Settings and pick a camera; subscription skipped' so the missing configuration becomes self-evident.
2026-06-21scrypted: stamp SCRIPT_VERSION = 000ab3aLuke Hoersten1-1/+1
2026-06-21scrypted: implement StartStop for proper lifecycle UI + clean tear-downLuke Hoersten1-5/+50
The Scripts 'Status and Controls' panel previously rendered Unknown because the Provider exposed no lifecycle interface. STOP / START in that panel didn't do anything useful — Scrypted's only option was to unload the script entirely, which left the user's re-paste flow relying on the constructor-time globalThis cleanup hack (and even that didn't cover in-flight streams until 1ce49d3). This commit makes the panel real: class ScryptedViewportProvider ... implements ..., StartStop - async start() : drain shutdown cleaners, bootstrap, running=true - async stop() : drain shutdown cleaners, clear viewport+listener+ stream maps, running=false Both methods are idempotent (no-op when already in target state). The constructor calls start() automatically so script load still bootstraps without user action. The private start() method that did the actual provisioning is renamed to bootstrap() to free up the public name for the StartStop contract. registerShutdownCleaners is renamed to drainShutdownCleaners and now takes a 'reason' label so the log line distinguishes 'start: tearing down N resources' (previous load) from 'stop: tearing down N resources' (user-driven). Effect on the workflow the user described: 1. Click STOP in the Scripts UI → stop() drains every resource (streams, listeners, intervals) the provider currently holds. 2. Re-paste the new script. 3. New constructor runs → start() drains anything left behind → bootstrap re-discovers children + re-attaches listeners → ready. No more accumulating ghosts of prior loads.
2026-06-21scrypted: stamp SCRIPT_VERSION = 1ce49d3Luke Hoersten1-1/+1
2026-06-21scrypted: unified shutdown-cleaner array covers streams + listeners + timersLuke Hoersten1-32/+85
Scrypted's Scripts sandbox doesn't release a previous load's resources before constructing a new Provider instance, so anything long-lived survives a re-paste. Previously we hand-rolled cleanup via two separate globalThis hooks: __viewportListenerCleaners (camera event listeners) and __viewportRegisterInterval (the 5-minute re-register timer). Anything else — in-flight ffmpeg children, TCP sockets to the firmware, the per-stream stats interval — leaked across reloads. Visible symptom: re-pasting the script during an active stream left the old stream's ffmpeg + socket running against a dead Provider instance, accumulating across reloads until the host was restarted. Unify all cleanup into one globalThis array, __viewportShutdownCleaners, that every long-lived resource pushes onto. The next start() snapshots and drains it before doing anything else. Resources covered: - 5-minute re-register interval (clearInterval) - Every camera + child-device event listener (reg.removeListener) - Every in-flight stream's AbortController (abort.abort, which the existing abort listeners chain into socket.destroy() + ffmpeg SIGTERM + clearInterval on the stats logger) Each cleanup is removed from the array when the resource naturally ends (stream timeout / stopStream), so the list stays compact across many stream cycles rather than growing forever. Snapshot-before-walk avoids the iteration-skip bug where a stream abort fires its abort listener, which splices the closure out of the same array we're iterating. Snapshot the array, clear the original, then walk the snapshot. Doesn't address the 'Unknown' status panel in Scrypted Scripts UI — that's a separate cosmetic issue (we don't implement a lifecycle interface that reports running state).
2026-06-21scrypted: stamp SCRIPT_VERSION = 356e637Luke Hoersten1-1/+1
2026-06-21scrypted: retry once on Settings-page /state /config fetch failureLuke Hoersten1-4/+23
The Settings 'Status (live)' section reaches the device via two sequential GETs. A transient socket-level failure (httpd worker pool briefly saturated by the active stream connection, mid-reboot window, network jitter) used to leave the page showing 'device: offline / unreachable (fetch failed)' even though the device was fine — a refresh would clear it. One automatic 250 ms-backoff retry on either GET removes the sporadic false positive. The 3 s per-request timeout stays the same, so the worst-case page latency on a genuinely offline device is ~6.5 s (3 + 0.25 + 3) instead of 3 s — acceptable for a deliberate Settings open.
2026-06-21scrypted: stamp SCRIPT_VERSION = 2ca1307Luke Hoersten1-1/+1
2026-06-21scrypted: subscribe to camera child devices (fix Unifi doorbell wake)Luke Hoersten1-17/+39
Unifi doorbell cameras expose the bell button as a child device of the camera (separate nativeId, its own BinarySensor interface), not as a property of the camera itself. The previous code did cam.listen() on the parent camera only, so motion + person events arrived (those fire on the camera itself) but bell-press events never reached handleCameraEvent. HomeKit kept working because Scrypted's HomeKit bridge auto-syncs all child devices; our plugin was silently the only consumer missing the event. Confirmed by the trace log in 3b0ab73 staying silent across a bell press while motion events still printed. Fix: in attachListener, walk systemManager.getDeviceIds() and pick any device with providerId === cam.id as an additional listen target. Subscribe on all of them with the same iface list — listen() no-ops on unsupported ifaces, so we don't have to introspect each child. Tracking changed from Map<nativeId, EventListenerRegister> to Map<nativeId, EventListenerRegister[]> so detach/script-reload cleanup removes every listener, not just the camera's.
2026-06-20scrypted: stamp SCRIPT_VERSION = 3b0ab73Luke Hoersten1-1/+1
2026-06-20scrypted: trace log on every camera event (diagnose missing doorbell wake)Luke Hoersten1-0/+7
handleCameraEvent now logs iface/typeof/data/details for every event the camera fires on BinarySensor/MotionSensor/ObjectDetector. Helps diagnose why a Unifi doorbell press isn't waking the viewport while the same event reaches HomeKit. Temporary — remove once root cause is confirmed.
2026-06-20scrypted: skip-oldest backpressure on stream socketLuke Hoersten1-60/+98
Hold at most one pending frame; new ffmpeg frames arriving while the socket is backpressured replace the held one (drop oldest) instead of queueing. On 'drain', flush the held frame. The in-flight write stays out of our hands. Steady-state Node-side buffer drops from up to 20MB to ~1 frame, eliminating the cap-and-reconnect loop as the primary shedding mechanism — it's now an emergency safety net for stuck connections that never fire 'drain'. Capture-time timestamp instead of write-time so a held frame keeps its true age and g2g reflects any pending-slot hold latency. New log fields: drop-oldest=N (frames shed from the pending slot this window) and bp=N% (share of ffmpeg frames in the window that found the link backpressured at decision time).
2026-06-20scrypted: stamp SCRIPT_VERSION = e75891ev1.1.0Luke Hoersten1-1/+1
2026-06-20scrypted: settings page /state fetch — sequence + 3s timeoutLuke Hoersten1-10/+11
Two reasons the Status (live) section was showing "offline / unreachable (fetch failed)" intermittently: 1. /state and /config were fetched in parallel, eating both available httpd sockets simultaneously (Phase 2 dropped max_open_sockets from 4 to 2). Any other inbound HTTP at the same instant would either queue or error. 2. The 1.5s timeout was tight given the firmware now juggles a live stream socket on port 81 (with occasional cap-flush reconnects) alongside the httpd workers on port 80. Sequence the two fetches and bump the timeout to 3s. Total worst-case 6s if both are slow; that's fine for a Settings page, far from "feels offline."
2026-06-20scrypted: stamp SCRIPT_VERSION = e3bcccaLuke Hoersten1-1/+1
2026-06-20scrypted: max_node_buf_mb cap → destroy+reconnect socket to drop entire ↵Luke Hoersten1-10/+57
backlog User's intent: "we want to see what's going on right now, not all the shit we throttled and backlogged in the buffer." Dropping new arrivals when the buffer is full doesn't help — the already-queued frames still get processed in order, painting 5+ seconds of stale content before catching up to live. Better: when the buffer exceeds the configured cap, blow away the whole queue by destroying the socket. The firmware's accept_task picks up the next reconnect within ~500ms and the pipeline restarts with the freshest ffmpeg frame as seq=1. Trade a sub-second visual gap for clearing the entire backlog instantly. Per-viewport setting "Max Scrypted-side buffer (MB)" under Display. Default 20 MB (≈5s buffering ceiling at our measured ~3.5 MB/s ffmpeg → firmware throughput). Range 1-200. Lower for tighter g2g at cost of more frequent reconnect gaps under sustained backpressure; higher to tolerate longer backlogs before flushing. flushCount tracked per-stream and surfaced in the unified log line alongside drops, plus node_buf shown vs the cap so the user can see how close they're running to the threshold. Demux loop now: if !socketReady → drop. Else if writableLength > cap*MB → log + sock.destroy() + increment flushes. Else normal write.
2026-06-20scrypted: stamp SCRIPT_VERSION = 439dadbLuke Hoersten1-1/+1
2026-06-20scrypted: log node_buf in stream window — confirms script-side bufferingLuke Hoersten1-2/+15
Adds two values to the unified stream log line: node_buf=NKB≈Mms node_buf bytes is sock.writableLength — bytes that socket.write() accepted from us but the kernel send buffer hasn't pulled yet, so they sit in Node's internal queue. These have NOT been sent over the wire; the firmware can't have them. Dividing by the current send rate (bytes/sec computed from the same window's bytesSent) gives the buffer depth in milliseconds: how many seconds of already-extracted-from-ffmpeg frames are stalled at the script. When g2g shows 7s steady state and node_buf shows e.g. 5000-6000KB ≈ 6500ms, the conclusion is obvious — the buffering isn't in the firmware or on the wire, it's in Scrypted's Node process. No behavior change; pure diagnostic.
2026-06-20scrypted: stamp SCRIPT_VERSION = 28f39bfLuke Hoersten1-1/+1
2026-06-20scrypted: kill listener leak across script reloads + one-line ↵Luke Hoersten1-56/+75
sent-vs-painted log #1 — Camera listener leak Same Scripts-sandbox lifecycle gap that bit us with setInterval (fixed in 521de7e): cam.listen() returns an EventListenerRegister with a removeListener() method. The listener is held on the camera plugin's side, not the script's, so when the old Provider instance gets GC'd on script reload its registrations stay live with a dead callback. Field symptom: after re-pasting the script N times, every camera event arrives N times in handleCameraEvent. Today the streamStarting guard catches sequential dupes, but two callbacks firing on the same event within the JS microtask window both check streamStarting BEFORE either has added their nativeId, both proceed, two pushSnapshots launch in parallel. They race for the firmware decoder lock — one paints, one gets 503 — and the user perceives sharp-fighting-with-itself as "snapshot quality is bad again." Fix: same globalThis trick as the setInterval. Push a remover closure onto G.__viewportListenerCleaners every time attachListener fires; at start() of the new Provider instance, drain the array calling each remover before creating fresh listeners. Idempotent across reloads. #2 — Unified sent-vs-painted log The user asked: "do we have an understanding in the logging/ instrumentation what the FPS of the stream output is from scrypted out is vs the actual rendered FPS once the stream is loaded?" Both numbers existed but in separate log lines (skipLogger every 10s + fwPoller every 5s, interleaved in the console). Folded the /state poll into the 10s skipLogger so there's ONE line per window with sent fps + painted fps side-by-side plus the gap explicitly labeled: stream "kitchen": sent=24.2fps painted=22.8fps (fw-skipped=1.4fps, drops=0) 4.58MB/s sent / 4.29MB/s painted | socket.write p50=0ms p95=1ms max=5ms backpressured=true | recv=27776/37237/44470us dec=5788/5991/6626us paint=30/36/46us idle=164/588/11383us | g2g=142ms fw-skipped = (sent − painted), how many frames per second the firmware's FIONREAD skip dropped to keep the panel on the freshest frame. The g2g value is now meaningful per-frame thanks to the firmware-side live-update we landed last commit. Drops the separate fwPoller; one comprehensive log per 10s window.
2026-06-20scrypted: stamp SCRIPT_VERSION = 568de10Luke Hoersten1-1/+1
2026-06-20g2g semantics now per-frame, drop sharp mozjpeg for ~4x faster snapshotLuke Hoersten1-18/+20
Two follow-up fixes informed by the first-round field data: #1 — g2g measures display age, not time-since-wake Previously eventUsLow was stamped once at wake event and reused for the whole stream session. /state's last_paint_event_us_low echoed back the wake-time anchor, so g2g = "elapsed time since user wake." That metric is true but not actually informative — it just grows linearly with stream duration. What's actually useful for the "is the panel showing current reality" question is the AGE of the currently-displayed frame. Stamp event_us_low per emitted frame at Date.now()*1000 (low 32 bits) inside the stream demux loop. Now last_paint_event_us_low reflects the most recent painted frame's emit time, and g2g = (script_now_us_low - last_paint_event_us_low) = display staleness in ms. #2 — sharp first-paint dropped from ~1800ms to ~800ms Previous commit (phase 5) turned on sharp's mozjpeg encoder for slightly tighter file size at the same JPEG quality. Field measurement showed it costs ~1.5s of CPU per snapshot vs libjpeg-turbo's ~400ms — a ~4× regression on the metric we care most about (event → first-paint). The file-size win is ~5%; on 800x480 panel output that's not perceptible. Drop mozjpeg: true. Quality settings (quality=100, chroma 4:4:4 at jpegQuality ≤ 2) stay the same, so visual output is unchanged. First-paint comes back down to where it should be.
2026-06-20scrypted: stamp SCRIPT_VERSION = e4a546cLuke Hoersten1-1/+1
2026-06-20phase 4: glass-to-glass via 16-byte stream header + /state stream statsLuke Hoersten1-5/+47
Wire format change: stream frames now carry a 4-byte "VPRT" magic + 4-byte jpeg_len + 4-byte seq + 4-byte event_us_low. Total 16 bytes (was 8). The firmware sniffs the first 4 bytes per frame: if they spell VPRT it reads the remaining 12 bytes of v1 header; otherwise it interprets bytes 0-3 as jpeg_len for the old v0 8-byte format and reads 4 more for seq. Lets a v1 firmware accept a v0 (legacy) Scrypted script during the rollout window. v0 will be removed once all field deployments roll forward. event_us_low is the low 32 bits of the Scrypted host's monotonic µs at camera-event arrival. The firmware does NOT interpret it (the clocks aren't sync'd); it just stamps it on every painted frame and exposes the most recent value via /state. The script polls /state every 5s during an active stream, reads last_paint_event_us_low, and computes glass-to-glass = (now_us_low - last_paint_event_us_low) with 32-bit wrap. 30s sanity ceiling on the wrap to discard event timestamps from before the stream started. Also expose the firmware's just-closed 30-frame window stats via /state under the "stream" key — frames, bytes, window_us, plus min/avg/max for recv/dec/paint/idle. Lets external tools (a curl loop, the Scrypted plugin, etc) poll the firmware's view without parsing serial logs. Firmware: - stream_server.h: 16-byte v1 wire spec, stream_server_stats_t struct, stream_server_snapshot_stats(out) getter. - stream_server.c: magic-detect header read path, last_event_us_low capture into per-connection state, portMUX-protected window-stats snapshot at every 30-frame roll. - http_api.c: GET /state JSON gains a "stream" sub-object with the full snapshot. - viewport_state.h: VIEWPORT_VERSION 1.0.0 → 1.1.0 (new /state shape). Scrypted: - startStream captures eventUsLow = (tEvent * 1000) >>> 0. - TCP demux loop writes the 16-byte v1 header with the VPRT magic. - New fwPoller setInterval (5s) fetches /state, parses .stream, computes g2g, emits one summary line per poll cycle.
2026-06-20scrypted: stamp SCRIPT_VERSION = cdd1827Luke Hoersten1-1/+1
2026-06-20phase 5: fix snapshot looking lower-quality than streamLuke Hoersten1-2/+15
User reported "snapshot is still lower qual than the stream" even at jpegQuality=1. Two compounding causes in the sharp transform path: 1. Quality mapping topped out at 97. Previous formula was Math.max(50, 100 - v.jpegQuality * 3); at jpegQuality=1 that produced quality=97 — versus the stream's ffmpeg mjpeg -q:v 1 which lands at sharp-equivalent ~99-100. Visible compression artifacts on flat regions accounted for ~half the perceived quality gap. New formula Math.min(100, 102 - v.jpegQuality * 2) yields: jpegQuality=1 → 100 (was 97) jpegQuality=2 → 98 (was 94) jpegQuality=5 → 92 (was 85) jpegQuality=10 → 82 (was 70) jpegQuality=31 → 40 (was 50, clamped) 2. Chroma subsampling was sharp's default 4:2:0 — half-rate chroma in U+V planes. On colored edges (UI overlays, sharp camera subjects against backgrounds, text) this smears and is the dominant visible artifact at panel-native 800x480. ffmpeg mjpeg's default behavior at -q:v 1 is closer to 4:2:2 or no subsampling. Force 4:4:4 when jpegQuality <= 2 (the "I want max quality" regime); keep 4:2:0 for jpegQuality >= 3 since the point at that quality is smaller files. Plus mozjpeg: true on the encoder. Same quality value, slightly tighter file size — modest but free. The Lanczos kernel was already lanczos3 — confirmed, no change. Expected outcome: snapshot at jpegQuality=1 is visually indistinguishable from a stream frame at the same scene.
2026-06-20scrypted: stamp SCRIPT_VERSION = e4a6d07Luke Hoersten1-1/+1
2026-06-20phase 3: parallel-startup timing trace anchored to camera-event arrivalLuke Hoersten1-15/+36
Adds per-stage timing logs to both the stream and snapshot paths, all relative to a single t=0 anchor: the moment the camera event arrived in handleCameraEvent. Lets us see whether snapshot and stream actually start in parallel (they should, within ~2ms) and where wall-clock goes inside each path. handleCameraEvent now captures tEvent = Date.now() and threads it into startStream(v, tEvent). startStream threads it further into pushSnapshot(v, cam, tEvent). Both helpers define a `since()` arrow that computes the current offset. New log lines on wake: event MotionSensor -> "kitchen": fired at +0ms (wake) stream "kitchen": start +0ms stream "kitchen": socket connect requested +1ms snapshot "kitchen": start +1ms snapshot "kitchen": takePicture +320ms stream "kitchen": socket connect open +24ms snapshot "kitchen": transform +458ms via sharp (217KB) snapshot "kitchen": post sent +459ms snapshot "kitchen": post acked +551ms ← first user-visible paint stream "kitchen": first ffmpeg frame +780ms (jpeg=210KB) stream "kitchen": first socket.write +781ms post_acked is the snapshot's true glass-to-glass: /frame returns after display_flip_back_buffer, so by the time the response resolves the firmware has the new pixels queued for the next DPI scanout. No separate firmware-side wiring needed for the snapshot path's g2g measurement. The stream path's true g2g still needs the 16-byte header extension in Phase 4 — `first socket.write` is just "Scrypted sent the bytes," not "panel showed them."
2026-06-20scrypted: stamp SCRIPT_VERSION = 4d5fd28Luke Hoersten1-1/+1
2026-06-20cleanup phase 2: delete HTTP-streaming-era dead codeLuke Hoersten1-121/+25
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.
2026-06-20scrypted: stamp SCRIPT_VERSION = 131c46fLuke Hoersten1-1/+1
2026-06-20cleanup phase 1: stale-comment refresh, zero behavior changeLuke Hoersten1-49/+41
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.
2026-06-20scrypted: stamp SCRIPT_VERSION = 496da49Luke Hoersten1-1/+1
2026-06-20"paint the latest, drop the rest" — FIONREAD skip + Scrypted ↵Luke Hoersten1-7/+14
backpressure-blind + lwIP TCP window bump Three changes that work together to make the stream "always paint what's freshest, never sit on a stale frame": #1 — Firmware FIONREAD skip in stream_server Right after the body of frame N comes off the wire (and before we unlock the decoder + spend ~6ms on decode + paint), check the kernel receive buffer with ioctl(FIONREAD). If at least one more header (8 bytes) is queued, frame N is no longer the freshest possible — skip its decode + paint and loop back to read frame N+1. The TCP recv cost is unavoidable (bytes still have to cross the wire) but the decoder + paint cost is saved on every superseded frame. Glass-to-glass latency on the latest frame drops by however many frames had backed up. #2 — Scrypted: keep writing past kernel-buffer backpressure Previously: when sock.write() returned false we dropped the next ffmpeg frame at source. New: we keep writing through. Node buffers internally; under our load (~16 MB/s ffmpeg → ~5-7 MB/s firmware) the buffer rarely exceeds a frame or two. With the firmware now silently skipping decode on backed-up frames (#1), excess frames get shed for free on the device side. Scrypted's job is just to hand the firmware the freshest bytes as fast as possible. socketBackpressured is still tracked for the diagnostic log. #3 — lwIP TCP window bump (revisiting earlier regression) The previous attempt at LWIP_TCP_WND_DEFAULT=32k regressed under HTTP because every /frame opened a fresh socket and we paid the slow-start cost repeatedly. The streaming pivot eliminated that: the socket is long-lived, slow-start runs exactly once, then we ride the full window for the rest of the session. Bumping to 65535 (max for stock lwIP), SND_BUF to match, RECVMBOX to 16, SACK on. Expected: recv throughput ceiling moves up from ~5.3 MB/s, which directly raises the fps ceiling (recv is currently 37ms of the 43ms per-frame total).
2026-06-20scrypted: stamp SCRIPT_VERSION = 521de7eLuke Hoersten1-1/+1
2026-06-20scrypted: show Wake triggers on new-device dialog + stop leaking re-register ↵Luke Hoersten1-2/+28
interval #1 — Wake triggers in the new-device dialog The "+ Add Device" form was missing the Wake triggers multi-select, so a new viewport defaulted to the per-getter fallback of all three (doorbell + motion + person) silently and the user only saw the field after first edit. Now the dialog includes it up front with all three pre-selected, and createDevice persists whatever the user ticked into storage in the same JSON shape Viewport.putSetting uses for subsequent edits. #2 — Stop leaking the periodic re-register interval Why the user was seeing the "registered ..." log line repeat 14 times in steady-state with nothing happening: Scrypted's Scripts sandbox does NOT garbage-collect setInterval handles when a script is re-pasted/reloaded. Every re-paste left an orphan interval running against the previous Provider instance, accumulating one extra timer per reload. Every 5 minutes (REREGISTER_INTERVAL_MS), all N timers fired at once, producing N "registered ..." log lines in rapid succession. Fix: keep the timer handle on globalThis under a well-known key. At script start, clearInterval the previous one (if any) before arming the new one. Idempotent across reloads.
2026-06-20scrypted: stamp SCRIPT_VERSION = f982b88Luke Hoersten1-1/+1
2026-06-20scrypted: drop substream picker, hardcode best-resolution + skip low-resLuke Hoersten1-24/+19
Reverts the per-viewport "Camera substream" UI added in 6d74d02 — having a knob the user has to find and tune is the wrong shape. The goal is "best quality, highest fps that's actually achievable" and that's a deterministic walk, not a user choice. New walk: medium-resolution → local → remote → camera-default. Explicitly NOT in the list: - low-resolution: the camera's preview substream, capped at the ~5-8 fps that's been bottlenecking us. - remote-recorder: has the camera's ~10s prebuffer baked in (we'd display the past rather than the present). Wire cost is unchanged: every input resolution is re-encoded to panel-native 800x480 mjpeg q:v 1 before going to the device. The only tradeoff for picking the main stream is Scrypted-side ffmpeg CPU, which is plentiful. Throughput to the firmware is the same; upstream camera fps is what changes (5fps → 15-30fps typical).
2026-06-20scrypted: stamp SCRIPT_VERSION = 6d74d02Luke Hoersten1-1/+1
2026-06-20faster snapshot + deeper firmware timing + camera substream UI controlLuke Hoersten1-41/+108
Three independent improvements landing together because they all target the post-streaming-pivot "where do we spend the wall clock?" question. #1 — Snapshot: sharp → mediaManager native → ffmpeg cascade pushSnapshot now tries three transforms in order of cost: - sharp (~5-15ms, libvips bindings, handles resize + rotate) - mediaManager.convertMediaObjectToBuffer with image/jpeg;width=W ;height=H mime hint (~10-30ms, Scrypted's native converter, used only for landscape since rotation isn't standard) - ffmpeg one-shot (~500-700ms cold start, the old slow path) A `path=...` field in the snapshot log identifies which transform actually ran so the user can confirm the fast paths are reachable in their Scrypted runtime. takePicture, transform, and POST timings are each broken out so we can see exactly where the snapshot wall goes. #2 — Firmware: windowed min/avg/max breakdown + idle-gap Stream server replaces the per-frame single-sample log with a 30- frame window summary: N frames over Xs: Yfps Z MB/s avg-jpeg=KB | lock min/avg/max | recv min/avg/max | dec min/avg/max | paint min/avg/max | idle min/avg/max - lock = mutex acquire time (sanity check; should be ~0us with one client owning the decoder) - recv = body bytes off the wire - dec = HW JPEG decode - paint = backbuffer flip + DMA queue - idle = gap between previous paint completing and next header landing (= upstream slack). Large idle means we're waiting on ffmpeg/network; near-zero means we're the bottleneck. #3 — Camera substream picker "Stream-source choice drives end-to-end latency more than anything else" — the existing hardcoded low-latency-first walk lands on the camera's preview substream which is typically capped at 5-8 fps. Adds a per-viewport setting under Display: Camera substream: auto | low-resolution | medium-resolution | local | remote | remote-recorder auto keeps the current behavior; the pinned options let the user force a higher-fps source when they want stream rate > preview rate. The chosen destination is logged at stream start.
2026-06-20scrypted: stamp SCRIPT_VERSION = d8d9a66Luke Hoersten1-1/+1
2026-06-20scrypted: drop frame_interval_ms, brightness default 80 → 100Luke Hoersten1-18/+7
Two settings cleanups now that streaming has landed: - frame_interval_ms removed entirely. Under the TCP streaming data plane ffmpeg emits at the camera's native rate and TCP backpressure naturally caps us when the firmware can't keep up. The setting, the getter, the UI field, and the -vf "fps=N" filter argument all go away. Net: a few fewer questions to answer on every viewport, and one fewer place for the user to misconfigure latency. Existing storage values are silently ignored on next render. - Default brightness 80 → 100. The panel is dim enough at 80 that the change in ambient lighting can wash it out; 100 is a better default. Users who want lower can still set it in the UI; this only affects newly-created viewports (existing ones keep whatever was last saved).
2026-06-19scrypted: stamp SCRIPT_VERSION = b97c250Luke Hoersten1-1/+1
2026-06-19streaming pivot: raw TCP data plane, drop per-frame HTTP entirelyLuke Hoersten1-221/+89
Replaces the per-frame HTTP POST loop with a single long-lived TCP connection on port 81. The HTTP control plane (/state, /config, /frame for snapshot) stays unchanged. Wire protocol (big-endian, repeating until connection close): [4 bytes jpeg_len][4 bytes seq][jpeg_len bytes JPEG] Why --- The HTTP path hit a measured floor of ~37ms p50 with intermittent ~230ms p95 spikes that survived every Nagle/keep-alive fix attempt. Each frame paid: TCP setup (or pool churn), HTTP parsing, body recv, decode, paint, response write, response ACK. Streaming removes everything except recv + decode + paint. Firmware (main/stream_server.[ch], new) --------------------------------------- - TCP listen on configurable port (81) in its own FreeRTOS task, one client at a time (matches the one-stream-per-device model). - Per accepted socket: TCP_NODELAY on, then loop reading 8-byte header → jpeg body → through the existing jpeg_decoder + display paths. Same stale-seq guard as the HTTP /frame handler (reset per connection so each session starts at seq 1). - Frames received while asleep are still drained (to stay framed) but not painted. The HTTP control-plane POST /state {wake} resumes painting on the next frame. - Every 30 painted frames a structured serial log shows per-stage timing + sustained MB/s — replaces the cross-side Server-Timing header (no HTTP response to attach it to anymore). Scrypted side ------------- - net.createConnection({ host, port: 81, noDelay: true }) opened once per stream session. On disconnect/error/close we auto- reconnect after 500ms. - ffmpeg stdout demux writes [header][body] directly to the socket. sock.write() returning false sets a backpressured flag that drops incoming ffmpeg frames until 'drain' fires — natural TCP backpressure handles "firmware can't keep up" without us modeling it manually. - Stripped the entire fetch-based timing infrastructure (pushStreamFrame, fetchSamples, parseServerTiming, depth histogram, Server-Timing parser). Replaced with one stream-shaped log every 10s: fps + MB/s + socket.write p50/p95/max + drop count + backpressured flag. - pushSnapshot (one-shot first-paint) still uses the HTTP /frame endpoint — small and infrequent, not worth reworking. - postJSON still uses node:http for /state and /config. State machine status flags -------------------------- Extended boot-time flags array from 6 → 7 slots so the stream server's bring-up shows up alongside ETH/MDNS/HTTP. Layout is now E M H S D J T (was E M H D J T).
2026-06-19scrypted: stamp SCRIPT_VERSION = e88aa51Luke Hoersten1-1/+1
2026-06-19scrypted: noDelay:true on keep-alive sockets — kills 200ms Nagle deadlockLuke Hoersten1-1/+17
After enabling keep-alive in bccf0fa, fw_recv p95 spiked from ~25ms to ~230ms on a significant fraction of frames and net_up p95 went from <15ms to 200+ms simultaneously. Worst frames hit 467ms wall. Root cause: Node http.Agent's outgoing sockets default to noDelay false (Nagle ON). When a ~128KB JPEG body doesn't end on an exact MTU boundary, the sender holds the final partial packet for up to 200ms waiting for either more data or a peer ACK. Firmware-side TCP_NODELAY only controls what the firmware *sends* (its ACKs and response packets); it has no effect on what arrives at the firmware from a Nagling sender. Setting noDelay: true on the http.Agent makes new sockets in the pool opt out of Nagle on creation. Plus a belt-and-suspenders setNoDelay on the request's socket event in case the Agent-level option isn't honored on this Node version. The 230ms signature is the classic Nagle + delayed-ACK deadlock: - Sender: "I have a partial packet; I'll wait for more data or an ACK before sending." - Receiver: "I'll delay this ACK up to 200ms in case I can piggyback it on a response." - Result: 200ms stall on every send that doesn't fill an MTU.