<feed xmlns='http://www.w3.org/2005/Atom'>
<title>luke/esp32-poe-scrypted-viewport/README.md, branch v1.4.0</title>
<subtitle>ESP32-POE Scrypted viewport (private)
</subtitle>
<id>https://src.nth.io/luke/esp32-poe-scrypted-viewport/atom?h=v1.4.0</id>
<link rel='self' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/atom?h=v1.4.0'/>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/'/>
<updated>2026-07-16T00:36:56+00:00</updated>
<entry>
<title>scrypted: temp_c on the stats line; docs: triple-buffer model + window tuning</title>
<updated>2026-07-16T00:36:56+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-07-16T00:36:56+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=2af69f78a6179334c6f59d4273bd72ae170fb821'/>
<id>urn:sha1:2af69f78a6179334c6f59d4273bd72ae170fb821</id>
<content type='text'>
- Scrypted per-stream stats line gains temp=&lt;c&gt;C from /state temp_c —
  free thermal trending under streaming load.
- README Display strategy rewritten for the tear-free triple-buffer
  model: why the deferred fb-index reload tears under double
  buffering, why scan-tracked buffer roles beat vsync-waiting, and
  the tear_guard_engaged counter.
- New 'TCP window + EMAC tuning' section documenting the measured
  window raise, the EMAC-RX-pool-below-window RTO regression, and
  the pool &gt;= TCP_WND invariant; stale 'window bump is safe but
  won't help' backlog text updated.
- Memory strategy updated (3 fbs, decoder writes into them directly,
  stream body ring).
</content>
</entry>
<entry>
<title>docs: reframe protocol sections for the TCP-socket streaming model</title>
<updated>2026-07-01T02:22:22+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-07-01T02:22:22+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=ec49dcd0ac9bf73b8be56127eef30428af5824a4'/>
<id>urn:sha1:ec49dcd0ac9bf73b8be56127eef30428af5824a4</id>
<content type='text'>
The Wake/Sleep, race-handling, and idempotency sections still described the
old model where Scrypted streamed via POST /frame and learned of sleep from a
/frame 409. Streaming now runs over the TCP data socket (:81); the firmware
/frame endpoint remains only for one-shot snapshots/debug. Reframe the recovery
model accordingly:
- race-free property: frames arriving while asleep are discarded by the decode
  task (stream socket) / rejected 409 (/frame) — neither re-wakes; sleep is
  learned from the device's state=sleep callback + Scrypted's safety timer
- drop every "recovered by the next /frame returning 409" clause in favor of
  the safety timer + discard-while-asleep
- idempotency table: add the stream-socket row (paints if awake, discarded if
  asleep, skip-oldest)
- Loading screen: shown on wake AND each new stream connection until first paint
- Philosophy: list the MJPEG stream server (:81) and /firmware
Left the POST /frame endpoint's own API/semantics intact — it still exists.
</content>
</entry>
<entry>
<title>docs: update main README streaming section to the shipped TCP-socket path</title>
<updated>2026-07-01T02:18:43+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-07-01T02:18:43+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=a84f17ec5da36a704636c45aaa5a67c4b8a2bd5b'/>
<id>urn:sha1:a84f17ec5da36a704636c45aaa5a67c4b8a2bd5b</id>
<content type='text'>
The Scrypted Integration section still framed the snapshot POST /frame path as
v1 and MJPEG streaming as a future POST /stream milestone, contradicting the
already-current "What's next" perf section. Bring it in line:
- v1 is the live ffmpeg MJPEG stream over the raw TCP data socket (:81) with
  prebuffer fast-start (~0.7s wake-to-video); POST /stream was never the shape
- replace the /frame streaming code example with the TCP socket + framed-MJPEG
  sketch; brightness default 100 (was 80)
- sleep is learned via the device's sleep callback, not a /frame 409
- Status table: streaming + end-to-end are done/verified, not "not started"
- Idle: timer resets on any painted frame, not specifically a /frame POST

Left intact: the POST /frame API + protocol/idempotency sections — that
firmware endpoint still exists for one-shot snapshots/debug and its 409-when-
asleep semantics are unchanged.
</content>
</entry>
<entry>
<title>README: stream-server task split is the critical change that closed the painted&lt;sent gap</title>
<updated>2026-06-21T02:01:23+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-21T02:01:23+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=a438a6303e94cea99f4f4c51017d958d0b44b0ac'/>
<id>urn:sha1:a438a6303e94cea99f4f4c51017d958d0b44b0ac</id>
<content type='text'>
Document the architecture pivot. The per-frame budget table now
reflects the long-lived TCP stream socket on port 81 (replaced the
HTTP /frame pattern) and the new recv/decode/paint timings:
recv 14-18ms on the wire (pure body), dec ~5ms, paint &lt;35us, with
decode-task idle ~30ms per frame waiting on recv.

The critical change wasn't a TCP tuning knob — it was splitting
recv off its own FreeRTOS task with a 3-buffer PSRAM ring and a
1-deep latest-frame slot to the decode/paint task (d1c8d45). The
single-task loop blocked the socket for 6ms during decode+paint,
which against the IDF-default 5760-byte window forced a stop-go
cycle that capped painted at ~17fps. Raising the window to 65535
made it worse (g2g grew to 17s) because lwIP RX couldn't drain
the bursts and there's no way to skip-oldest on the kernel queue.
The task split eliminated the coupling: recv-task drains
continuously regardless of decode timing, the kernel buffer stays
near-empty, and painted now matches sent at the source rate.

Backlog entries for body shrink and OTA marked done.
</content>
</entry>
<entry>
<title>firmware: OTA firmware updates via POST /firmware + rollback</title>
<updated>2026-06-20T18:03:23+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T18:03:23+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=175dd50ba2a2a6c8d033a9fd4e91f4823f9e210a'/>
<id>urn:sha1:175dd50ba2a2a6c8d033a9fd4e91f4823f9e210a</id>
<content type='text'>
Streams the raw .bin to the inactive ota_0/ota_1 slot via esp_ota_*, flips otadata, replies 200, reboots after 500 ms. Single-shot guarded by atomic_flag (409 on concurrent). CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE armed: new images boot pending-verify and ota_arm_healthy_timer marks them valid after 30 s of healthy uptime; otherwise the bootloader reverts on next reset. /state gains ota_state.
</content>
</entry>
<entry>
<title>sdkconfig: revert TCP window bump — net-negative on /frame path</title>
<updated>2026-06-15T12:59:44+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T12:59:44+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=ec8d498ba5040b7ba862211975d2043baa6e5536'/>
<id>urn:sha1:ec8d498ba5040b7ba862211975d2043baa6e5536</id>
<content type='text'>
Bumping WND/SND_BUF to 32 KiB + RECVMBOX to 16 + SACK on regressed
ttfb from ~350µs to ~1.2ms across every frame and introduced periodic
250+ms body stalls (TCP retransmit on the larger window). Mean body
unchanged. Reason: Scrypted's per-frame fetch() opens a fresh TCP
socket, so the larger receive window just slows slow-start on every
new connection rather than helping.

Reverted to lwIP defaults. The real fix is HTTP keep-alive on the
Scrypted side; will revisit window tuning once persistent connections
are in place. README backlog updated with what we learned.
</content>
</entry>
<entry>
<title>README: refresh per-frame budget with double-buffered measurements; network body is now the only big lever</title>
<updated>2026-06-15T12:52:34+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T12:52:34+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=d949b19a6b89b9e74e8706bafa1d1396ac8f17f8'/>
<id>urn:sha1:d949b19a6b89b9e74e8706bafa1d1396ac8f17f8</id>
<content type='text'>
</content>
</entry>
<entry>
<title>scrypted: don't reset idle timer on each painted frame + finer timing</title>
<updated>2026-06-15T12:41:45+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T12:41:45+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=b3217b9dbb731ec69cd1ebde201aa2178143d961'/>
<id>urn:sha1:b3217b9dbb731ec69cd1ebde201aa2178143d961</id>
<content type='text'>
Two fixes plus a README refresh:

1. scrypted: pushStreamFrame previously reset the per-stream idle timer
   on every successful /frame response. That made the timer anchored to
   "frames are flowing" rather than to "the camera event that triggered
   the stream", so a continuously-streaming source would never let the
   stream time out. Removed the reset. The startStream → stopStream(false)
   cancel-and-replace path on repeated events still keeps the stream
   alive while the event keeps firing; idle (no new events) now actually
   ends the stream at idle_timeout_ms.

2. firmware: break the previous coarse recv/dec/paint timing into
      lock  : try_lock returned
      ttfb  : first httpd_req_recv chunk landed
      body  : remaining bytes received
      dec   : hardware JPEG decode
      paint : esp_lcd_panel_draw_bitmap returned
      post  : state-counter bookkeeping + unlock
   Logged every 10 frames at INFO. Splits the previously-fat recv bucket
   into TCP/HTTP handshake overhead (ttfb) vs wire-time (body), and
   surfaces any tail bookkeeping cost.

3. README: replace the stale "5 fps ceiling caused by CPU RGB conversion"
   guess with the actual measured per-phase budget and re-rank the
   backlog accordingly. Double-buffering the panel (paint 24 ms → ~2 ms)
   is now the highest-value next move; the previously-listed DMA-2D
   rewrite is moot because the CPU loop is already gone.
</content>
</entry>
<entry>
<title>README: refresh What's next — M1–M8 ✅, list current backlog (DMA-2D first, OTA next, ...)</title>
<updated>2026-06-15T12:06:27+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T12:06:27+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=78a1a327b23bf216c1021a06d22a67795176e288'/>
<id>urn:sha1:78a1a327b23bf216c1021a06d22a67795176e288</id>
<content type='text'>
</content>
</entry>
<entry>
<title>Drop VIEWPORT_STATE_UNCONFIGURED — state is just awake/asleep</title>
<updated>2026-06-14T23:16:46+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-14T23:16:46+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=4a6bbdb075a5e4f9910cb9b85e615ff8c50aa4ec'/>
<id>urn:sha1:4a6bbdb075a5e4f9910cb9b85e615ff8c50aa4ec</id>
<content type='text'>
\`state\` now reports only the screen's runtime state (awake or asleep).
Whether a viewport is set up to talk to Scrypted is a separate
\`configured\` flag, derived from \`viewport_name &amp;&amp; scrypted_url\`.
There's no third state.

Behaviour changes:
- POST /state always succeeds; the previous 409 "device unconfigured"
  path is gone. The screen toggles regardless of /config status.
- POST /config now sets \`configured\` directly from the derived
  predicate instead of mutating the state enum.
- Outbound state-client POST to Scrypted is still gated on a scrypted
  URL being present — that's the only thing the configured flag now
  actually controls in the runtime path.

GET /state JSON unchanged in shape, but \`state\` is now never
"unconfigured" — that's reported through the existing \`configured\`
boolean instead.
</content>
</entry>
</feed>
