<feed xmlns='http://www.w3.org/2005/Atom'>
<title>luke/esp32-poe-scrypted-viewport, 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>firmware: live-update last_paint_event_us_low so /state g2g reflects real frame age</title>
<updated>2026-06-20T16:50:25+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T16:50:25+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=16f4c5be220756808747bfb47739733d3f107634'/>
<id>urn:sha1:16f4c5be220756808747bfb47739733d3f107634</id>
<content type='text'>
Previous Phase 4 implementation only published last_paint_event_us_low
into the windowed-stats snapshot at every 30-frame roll (~1.5s at
20fps). Combined with the script's 5s /state poll cadence, the
"freshest frame age" we could compute was up to ~6.5s stale before
any actual lag — meaningless as a perf signal.

Update s_stats.last_paint_event_us_low under portMUX on every
painted v1 frame. The other window stats (recv/dec/paint/idle
min/avg/max + fps/MBps) keep their roll cadence because they
genuinely need a full window to compute; this single u32 just gets
overwritten with the most recent value each paint.

portENTER_CRITICAL on the ESP32-P4 is ~half a microsecond per side
— at 20fps that's 20µs/s of overhead, immeasurable next to the
30-40ms recv per frame.

Expected: g2g during a saturated stream drops from the previous
2-6s reading to single-digit hundreds of ms or lower, reflecting
the actual emit→display lag.
</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>
</feed>
