<feed xmlns='http://www.w3.org/2005/Atom'>
<title>luke/esp32-poe-scrypted-viewport/scrypted/scrypted-viewport.ts, branch v1.1.0</title>
<subtitle>ESP32-POE Scrypted viewport (private)
</subtitle>
<id>https://src.nth.io/luke/esp32-poe-scrypted-viewport/atom?h=v1.1.0</id>
<link rel='self' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/atom?h=v1.1.0'/>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/'/>
<updated>2026-06-20T17:23:49+00:00</updated>
<entry>
<title>scrypted: stamp SCRIPT_VERSION = e75891e</title>
<updated>2026-06-20T17:23:49+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T17:23:49+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=3729f04458e358fab7872b9590d9fdb012f5ddf9'/>
<id>urn:sha1:3729f04458e358fab7872b9590d9fdb012f5ddf9</id>
<content type='text'>
</content>
</entry>
<entry>
<title>scrypted: settings page /state fetch — sequence + 3s timeout</title>
<updated>2026-06-20T17:23:34+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T17:23:34+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=e75891e75dae80ff2de5b8c3d8a3b1a39bf97ffc'/>
<id>urn:sha1:e75891e75dae80ff2de5b8c3d8a3b1a39bf97ffc</id>
<content type='text'>
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."
</content>
</entry>
<entry>
<title>scrypted: stamp SCRIPT_VERSION = e3bccca</title>
<updated>2026-06-20T17:21:01+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T17:21:01+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=ff714de79c857ecd7855d6ee578679502c89dba1'/>
<id>urn:sha1:ff714de79c857ecd7855d6ee578679502c89dba1</id>
<content type='text'>
</content>
</entry>
<entry>
<title>scrypted: max_node_buf_mb cap → destroy+reconnect socket to drop entire backlog</title>
<updated>2026-06-20T17:20:48+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T17:20:48+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=e3bcccaf63cb5feafb048cc32b1ca7bf6688fba1'/>
<id>urn:sha1:e3bcccaf63cb5feafb048cc32b1ca7bf6688fba1</id>
<content type='text'>
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 &gt;
cap*MB → log + sock.destroy() + increment flushes. Else normal write.
</content>
</entry>
<entry>
<title>scrypted: stamp SCRIPT_VERSION = 439dadb</title>
<updated>2026-06-20T17:12:09+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T17:12:09+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=fb4e900636dc0828eba2e70f1bbd0ef53a0bcabd'/>
<id>urn:sha1:fb4e900636dc0828eba2e70f1bbd0ef53a0bcabd</id>
<content type='text'>
</content>
</entry>
<entry>
<title>scrypted: log node_buf in stream window — confirms script-side buffering</title>
<updated>2026-06-20T17:11:54+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T17:11:54+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=439dadb24c4e07eb11f1aa9804cf7a973518e539'/>
<id>urn:sha1:439dadb24c4e07eb11f1aa9804cf7a973518e539</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>scrypted: stamp SCRIPT_VERSION = 28f39bf</title>
<updated>2026-06-20T16:54:30+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T16:54:30+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=a7fbd013f2bfabcb7a18a7878a959c24ed838110'/>
<id>urn:sha1:a7fbd013f2bfabcb7a18a7878a959c24ed838110</id>
<content type='text'>
</content>
</entry>
<entry>
<title>scrypted: kill listener leak across script reloads + one-line sent-vs-painted log</title>
<updated>2026-06-20T16:54:14+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T16:54:14+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=28f39bf5102ef398b6e91fdf61e7583c99d7252c'/>
<id>urn:sha1:28f39bf5102ef398b6e91fdf61e7583c99d7252c</id>
<content type='text'>
#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.
</content>
</entry>
<entry>
<title>scrypted: stamp SCRIPT_VERSION = 568de10</title>
<updated>2026-06-20T16:46:47+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T16:46:47+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=4e35d9c07061c760a3c5d6c69463cd748b8e2ae1'/>
<id>urn:sha1:4e35d9c07061c760a3c5d6c69463cd748b8e2ae1</id>
<content type='text'>
</content>
</entry>
<entry>
<title>g2g semantics now per-frame, drop sharp mozjpeg for ~4x faster snapshot</title>
<updated>2026-06-20T16:46:33+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T16:46:33+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=568de1068e7fc3b4faefac6d218d7919337b212c'/>
<id>urn:sha1:568de1068e7fc3b4faefac6d218d7919337b212c</id>
<content type='text'>
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.
</content>
</entry>
</feed>
