<feed xmlns='http://www.w3.org/2005/Atom'>
<title>luke/esp32-poe-scrypted-viewport/main, branch v1.3.2</title>
<subtitle>ESP32-POE Scrypted viewport (private)
</subtitle>
<id>https://src.nth.io/luke/esp32-poe-scrypted-viewport/atom?h=v1.3.2</id>
<link rel='self' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/atom?h=v1.3.2'/>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/'/>
<updated>2026-07-16T00:51:41+00:00</updated>
<entry>
<title>release: v1.3.2</title>
<updated>2026-07-16T00:51:41+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-07-16T00:51:41+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=866015678918560a0d0519be8a2549e4fc23b2cd'/>
<id>urn:sha1:866015678918560a0d0519be8a2549e4fc23b2cd</id>
<content type='text'>
Tear-free display path and thermal visibility:
- display: triple buffering + scan tracking via on_refresh_done —
  decode target is never the scanning or pending fb, so the
  flip-vs-scan tear (measured on ~6% of painted frames at full
  stream rate) is impossible by construction, with zero added
  latency. /state tear_guard_engaged counts averted frames.
- temp: on-die TSENS reported as /state temp_c, on the info
  overlay, and on the Scrypted per-stream stats line.
- screens: INFO_MAX_LINES 16 -&gt; 20 (temp line was silently capped).
- docs: README Display strategy rewritten for the triple-buffer
  model; new TCP window + EMAC tuning section.

Set VIEWPORT_VERSION and scrypted/package.json to 1.3.2.
</content>
</entry>
<entry>
<title>screens: raise INFO_MAX_LINES 16 -&gt; 20 (temp line was silently dropped)</title>
<updated>2026-07-16T00:41:41+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-07-16T00:41:41+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=739e6ff1afe6b5b80cc28afa19aa4b1b42282765'/>
<id>urn:sha1:739e6ff1afe6b5b80cc28afa19aa4b1b42282765</id>
<content type='text'>
The info overlay already had exactly 16 ADD lines; the temp line
added in f59cea3 was the 17th, and the ADD macro's bounds guard
drops overflow lines without any diagnostic — so temp never
rendered. Give the cap headroom and document the silent-drop
behavior at the definition.
</content>
</entry>
<entry>
<title>temp: report on-die temperature in /state and the info screen</title>
<updated>2026-07-16T00:18:32+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-07-16T00:18:32+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=f59cea3be9eed8d63fa44d4fd360c9c68e9318f6'/>
<id>urn:sha1:f59cea3be9eed8d63fa44d4fd360c9c68e9318f6</id>
<content type='text'>
New chip_temp module wraps the ESP32-P4 TSENS driver (20-100C range
for best accuracy in the warm band a PoE + 200MHz-PSRAM device
lives in). /state gains temp_c (0.1C resolution, omitted when the
sensor is unavailable); the long-press info overlay gains a temp
line (lowercase c suffix — the local 8x8 font has no uppercase C).
Junction temperature, ~10-20C above ambient under load.
</content>
</entry>
<entry>
<title>display: tear-free frame path via triple buffering + scan tracking</title>
<updated>2026-07-16T00:10:57+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-07-16T00:10:57+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=659da7f36473a21494693f031a39fb6bb977b90a'/>
<id>urn:sha1:659da7f36473a21494693f031a39fb6bb977b90a</id>
<content type='text'>
draw_bitmap on a direct fb pointer only updates the driver's
cur_fb_index; the DPI DMA reloads that index at the END of the
in-progress frame scan (~21ms period at ~47Hz). Under double
buffering, flipping and immediately decoding the next frame into
the other fb writes a buffer the DMA may still be scanning out —
a torn frame. This regime is common now that the TCP window fix
delivers frames back-to-back (decode starts ~6ms after flip).

Fix with zero added latency: num_fbs 2 -&gt; 3 (+1.15MB PSRAM of 25MB
free), track the actually-scanning fb via on_refresh_done (fires in
the DMA-done ISR exactly when the DMA reloads cur_fb_index), and
pick the decode target as the fb that is neither pending display
nor scanning. Three buffers minus at most two excluded roles =
always a free one; no waiting on vsync anywhere.

Instrumented: /state tear_guard_engaged counts back-buffer picks
made while the previous fb was still mid-scan — each one is a
frame that would have torn under double buffering.
</content>
</entry>
<entry>
<title>release: v1.3.1</title>
<updated>2026-07-16T00:04:56+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-07-16T00:04:56+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=7620b935728ea964ddca09b91ac0be36069a8c64'/>
<id>urn:sha1:7620b935728ea964ddca09b91ac0be36069a8c64</id>
<content type='text'>
Stream throughput tuning, measured on hardware (kitchen panel):
wire 53 -&gt; 74 Mbps, per-frame recv 30.5 -&gt; 21.4 ms, painted fps
19.8 -&gt; 23.6, g2g 73 -&gt; 62 ms, sender backpressure 61% -&gt; 47%.

- lwip: TCP_WND 5760 -&gt; 23040 (16 x MSS), RECVMBOX 6 -&gt; 32
- eth: EMAC RX DMA pool sized above the TCP window (1600B x 24);
  below-window pool caused silent burst tail-drop + ~200-400ms
  sender RTO stalls
- stream: TCP-window decomposition instrumentation (wire kbps,
  hdr_gap, pend_age) in the window log and /state; connect log
  stamps TCP_WND/MSS/RECVMBOX

Set VIEWPORT_VERSION and scrypted/package.json to 1.3.1.
</content>
</entry>
<entry>
<title>stream: instrument TCP-window decomposition (wire kbps, hdr_gap, pend_age)</title>
<updated>2026-07-15T23:04:31+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-07-15T23:04:31+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=6ee12595d339eeafc7fdc7dbb9ba4c4c757f3565'/>
<id>urn:sha1:6ee12595d339eeafc7fdc7dbb9ba4c4c757f3565</id>
<content type='text'>
Before touching CONFIG_LWIP_TCP_WND_DEFAULT, make the window question
decidable from the logs. New per-window metrics in the stream log,
/state, and stats struct:

- wire min/avg/max kbps: instantaneous throughput while each body
  drained (jpeg_len/recv_us). Ceiling ~= TCP_WND/RTT, so it scales
  with the window iff the window is the limiter.
- hdr_gap min/avg/max us: time blocked waiting for the next header
  after finishing a body. Large = sender-paced; ~0 = receive path
  is the bottleneck.
- pend_age min/avg/max us: publish-&gt;claim latency of painted frames.
  Growing across windows = queue backlog building, the failure mode
  that killed the previous WND=65535 attempt.

Together with recv/dec/paint the frame interval is now fully
decomposable: interval ~= hdr_gap + recv + pend_age + dec + paint.

Also stamp TCP_WND/TCP_MSS/RECVMBOX into the client-connect log line
so every capture is self-labeled with the config it ran under.
</content>
</entry>
<entry>
<title>release: v1.3.0</title>
<updated>2026-07-01T19:23:49+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-07-01T19:23:49+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=bdf497172a62c4ac318fa087bc59ef95813af61a'/>
<id>urn:sha1:bdf497172a62c4ac318fa087bc59ef95813af61a</id>
<content type='text'>
Unify firmware and plugin versions for the production release. Highlights
since v1.1.0/1.2.0:
- event wake fixed (self-healing camera-listener re-attach after the reload/
  add storage race); doorbell/motion/person confirmed on hardware
- stop() fully tears down (stop+start == fresh load); stale childId pruning;
  live-stream events ignored (no queue/relaunch)
- triggers default to person+doorbell; doorbell hidden for non-doorbell cams
- cold-start (wake -&gt; live video) ~6s -&gt; ~0.7s: prebuffered substream by id +
  request &gt;= GOP + burst-friendly ffmpeg flags; snapshot removed
- firmware: Loading screen on new stream connection (no stale-frame flash)
- logging trimmed for chatty cameras
- README/protocol docs rewritten for the TCP-socket streaming model

Set VIEWPORT_VERSION and scrypted/package.json to 1.3.0.
</content>
</entry>
<entry>
<title>firmware: clear panel to Loading screen on new stream connection</title>
<updated>2026-07-01T01:50:34+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-07-01T01:50:34+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=c5f1631b93456e3a084e6b72e55fc442d1e2817c'/>
<id>urn:sha1:c5f1631b93456e3a084e6b72e55fc442d1e2817c</id>
<content type='text'>
The panel used to hold its last framebuffer during the connect→first-frame gap,
flashing a stale old frame before video appeared — more visible now that the
Scrypted side no longer sends a bridging snapshot. The wake path already paints
the Loading screen, but state_machine_set(AWAKE) is a no-op when the device is
already awake (e.g. a stream restart), so the clear didn't happen there.

Tie the clear to the stream connection instead: recv-task paints
local_screens_show_loading() once per new conn_id, guarded by the decoder lock
so the RGB565 present can't race a trailing decode-task paint, and gated on
AWAKE so it never draws on a sleeping panel.
</content>
</entry>
<entry>
<title>firmware: split stream recv into its own task with 3-buffer ping-pong</title>
<updated>2026-06-21T01:50:43+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-21T01:50:43+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=d1c8d45d5dc8ae09f03e2f0a9c6b3ac1910b8cdc'/>
<id>urn:sha1:d1c8d45d5dc8ae09f03e2f0a9c6b3ac1910b8cdc</id>
<content type='text'>
handle_client previously ran recv → decode → paint serially on one
FreeRTOS task. The kernel TCP buffer filled during decode+paint
(~6ms), and against the IDF-default 5760-byte window the sender
naturally stop-go-rate-limited to ~consumption. Raising the window
to 65535 (previous experiment) regressed g2g from ~100ms to 17s
growing unbounded — the sender pumped 45+ segments per round into
a kernel buffer the app couldn't drain in time, and there was no
way to skip-oldest on the kernel queue.

This commit decouples recv from decode+paint:

  recv-task:   owns the socket. Reads header + body into one of three
               preallocated PSRAM body buffers. On body complete, swaps
               the just-filled buffer into a 1-deep pending slot and
               picks a free buffer for the next recv. If the slot
               already held a frame (decode is slow), drops oldest in
               place — mirror of the Scrypted-side skip-oldest from
               e5acf93.

  decode-task: waits on a binary semaphore. On signal, claims pending,
               then decodes + paints without holding any shared lock.
               Frees its prior buffer implicitly by overwriting
               s_decode_idx on the next claim.

3 PSRAM body buffers (~3MB of 28MB free) ensure the invariant
{recv_idx, pending_idx, decode_idx} are pairwise distinct without
ever blocking recv. jpeg_decoder.c grew an alloc_input_buffer helper
+ jpeg_decoder_decode now takes an explicit input pointer so the
stream and http_api snapshot paths don't share scratch.

New stats:
- recv_dropped_oldest: per-window count of pending-slot overwrites
- decode_idle_min/avg/max_us: time decode-task spent waiting on signal

Measurement at IDF-default 5760 window, Unifi medium substream:

  before split: recv_avg=32ms recv_max~44ms fps=22-26 (recv blocked
                during 6ms decode+paint; chunk_max capped at 5760)
  after split:  recv_avg=17ms recv_max=18-37ms fps=21-29 steady,
                decode_idle_avg=27-40ms (decode mostly waiting),
                drop_oldest=0, painted at source rate

The bottleneck moved from 'decode+paint serializes recv' to the
wire's own send rate. Bigger windows are now safe (recv-task drains
continuously, can't bury us), but won't add fps until source rate
goes up — that's a separate conversation.
</content>
</entry>
<entry>
<title>firmware: recv-throughput instrumentation (FIONREAD pre-body, recv() call/chunk stats, SO_RCVBUF probe)</title>
<updated>2026-06-21T01:16:01+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-21T01:16:01+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=19c090566fc15e72508166d81fd42eb46ac8efd5'/>
<id>urn:sha1:19c090566fc15e72508166d81fd42eb46ac8efd5</id>
<content type='text'>
Per-frame samples aggregated over the existing 30-frame window:

- queued_at_body_start (FIONREAD just before body recv loop): how much
  of the frame the kernel already absorbed during the previous
  decode+paint. Close to jpeg_len → wire delivered the full frame
  while we were busy (we're decode/paint-bound). Much smaller →
  wire is throttled (window or buffer too small to absorb a frame
  in our paint window).
- recv_calls: number of recv() syscalls the body read needed per
  frame. High → small chunks → window-throttled sender.
- recv_chunk min/avg/max: bytes returned per recv() return in the
  window. Avg = window body bytes / total syscalls.
- SO_RCVBUF: one-shot getsockopt at accept, logged and stashed in
  stats. Confirms whether sdkconfig values reached the build —
  TCP_WND_DEFAULT discrepancies are otherwise invisible.

All surfaced in the windowed log and in /state JSON alongside the
existing recv/dec/paint/idle stats. No behavior change yet.
</content>
</entry>
</feed>
