<feed xmlns='http://www.w3.org/2005/Atom'>
<title>luke/esp32-poe-scrypted-viewport/main/http_api.c, branch v1.0.0</title>
<subtitle>ESP32-POE Scrypted viewport (private)
</subtitle>
<id>https://src.nth.io/luke/esp32-poe-scrypted-viewport/atom?h=v1.0.0</id>
<link rel='self' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/atom?h=v1.0.0'/>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/'/>
<updated>2026-06-20T00:31:23+00:00</updated>
<entry>
<title>firmware: build fixes — esp_app_format component + forward-declare s_last_painted_seq</title>
<updated>2026-06-20T00:31:23+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T00:31:23+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=30a317dcb6dd40d2eca64aa09fdbe9031c37996f'/>
<id>urn:sha1:30a317dcb6dd40d2eca64aa09fdbe9031c37996f</id>
<content type='text'>
Two build breaks discovered when actually compiling the prior commits:

- main/CMakeLists.txt missing esp_app_format requirement, so
  esp_app_desc.h couldn't be resolved when app_main.c included it for
  the boot-time git-hash log.

- s_last_painted_seq is referenced inside state_post_handler (reset
  to 0 on /state wake) but the static was declared further down the
  file alongside the other /frame state. C requires declaration
  before use — forward declare it above state_post_handler and keep
  a stub comment at the original location.
</content>
</entry>
<entry>
<title>disable Nagle on /frame socket + add min/max/worst-frame to script log</title>
<updated>2026-06-20T00:15:28+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T00:15:28+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=504360a77afc6cd87d806047179343138f9b8593'/>
<id>urn:sha1:504360a77afc6cd87d806047179343138f9b8593</id>
<content type='text'>
#1: TCP_NODELAY for /frame
ESP-IDF lwIP defaults Nagle ON. /frame is the worst Nagle workload:
~140KB body POST followed by an empty 204 response, no follow-up data
either way. Both the final partial-MTU body packet and the response
packet can sit in the kernel send buffer up to 40ms waiting for an
ACK that the other side is delaying-ACKing — silently adding tens of
ms to every frame's wall time and showing up as a fat net_up bucket
on the Scrypted side.

setsockopt(TCP_NODELAY) at handler entry on every /frame. Cheap and
idempotent. Includes the /state and /config sockets too — those are
infrequent but small responses also benefit.

#2: min/max + worst-frame decomposition in the Scrypted log
The p50/p95 line answered "what's typical" but not "what happened in
the worst frame this window". Now every 10-fetch log adds:
  - min and max columns alongside p50/p95 per bucket
  - a worst-frame breakdown row: identifies the single slowest fetch
    in the window and decomposes its wall time across all stages.
    Answers "did the slow frame get gated by emit→post (ffmpeg
    backed up), net_up (TCP/handshake spike), or fw_dec (large
    JPEG)?" directly, instead of us inferring from percentiles.

Together these are the prerequisites for evaluating whether further
optimization (HTTP keep-alive, chunked streaming) is worth pursuing.
</content>
</entry>
<entry>
<title>unified end-to-end per-frame instrumentation + version stamps</title>
<updated>2026-06-20T00:07:55+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T00:07:55+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=846e4dbfed188154332fa01058e13df8336f0476'/>
<id>urn:sha1:846e4dbfed188154332fa01058e13df8336f0476</id>
<content type='text'>
Cross-side timing was opaque: script saw "req=70ms" but couldn't
split TCP/dispatch overhead from firmware decode time, and firmware
serial logs and Scrypted console logs couldn't be correlated.

Firmware /frame handler now emits Server-Timing on every response
with per-stage breakdown:
  Server-Timing: recv;dur=X, dec;dur=Y, paint;dur=Z,
                 post;dur=W, handle;dur=total
Script parses it, joins by X-Frame-Seq implicitly (one POST per seq),
derives net_up = req − fw_total, and logs a multi-line p50/p95
breakdown every 10 fetches:

  fetch "kitchen" #10 (jpeg=137KB)
     wall      p50=65ms  p95=140ms
     emit→post p50=0ms   p95=2ms      (queue wait)
     req       p50=62ms  p95=135ms    (fetch → Response headers)
       net_up  p50=43ms  p95=110ms    (TCP + body wire + dispatch)
       fw_recv p50=12ms  p95=18ms     (body off the wire)
       fw_dec  p50=6ms   p95=8ms      (hardware JPEG)
       fw_paint p50=0.1ms p95=0.2ms   (backbuffer flip)
       fw_post p50=0.4ms p95=0.6ms
     body-read p50=1ms                 (drain — empty body)
     inflight  d0=8 d1=2 d2=0
     stale-drops=0

Plus version stamps on both sides for the "is the user on the right
code" question:
- CMakeLists: PROJECT_VER = git short hash + -dirty marker if dirty.
  esp_app_get_description()-&gt;version exposes it at runtime. Boot
  log: "Scrypted Viewport boot (v0.1.0 build=4cf36e2-dirty)".
- TS: SCRIPT_VERSION const at the top, bumped per commit, logged at
  script-eval: "Scrypted Viewport up (script=4cf36e2). Callback ..."
</content>
</entry>
<entry>
<title>add X-Frame-Seq monotonic ordering for pipelined /frame POSTs</title>
<updated>2026-06-15T14:32:57+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T14:32:57+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=0ff5d03b760d726f6151cd5f3f23ef1a1039d319'/>
<id>urn:sha1:0ff5d03b760d726f6151cd5f3f23ef1a1039d319</id>
<content type='text'>
With two /frame POSTs in flight on separate sockets, the firmware's
JPEG decoder mutex serialises decode but FreeRTOS semaphore
acquisition is not FIFO — under jitter the later-arriving older
frame can grab the lock first and paint over the newer one,
producing brief "panel travels backward in time" glitches.

Scrypted side:
- Per-viewport monotonic frameSeq counter, sent as X-Frame-Seq on
  every /frame POST. Reset on stopStream so each new stream starts
  at 1.

Firmware side:
- s_last_painted_seq tracks the highest seq we've painted. Frame
  arrives, mutex acquired, body received — if seq &lt;= s_last_painted_seq
  the frame is dropped (200 OK + X-Frame-Drop: stale-seq header) and
  the mutex released without touching the back buffer.
- s_last_painted_seq resets to 0 on POST /state {wake} so the next
  stream's seq=1 isn't rejected because the previous session reached
  a higher counter.
- Missing/zero X-Frame-Seq (legacy clients) skips the check entirely
  — preserves pre-pipelining behaviour.
</content>
</entry>
<entry>
<title>pipeline two /frame POSTs + HTTP keep-alive on the Scrypted side</title>
<updated>2026-06-15T14:29:52+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T14:29:52+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=28a6335576cfec144dc3860bc47f7f6d87274651'/>
<id>urn:sha1:28a6335576cfec144dc3860bc47f7f6d87274651</id>
<content type='text'>
Decouples network upload from firmware decode-and-paint. Before, the
inFlight boolean guard meant Scrypted sent frame N, waited for the
full ~80ms response (~40ms body upload + ~20ms decode + ~50µs paint +
ack), THEN sent frame N+1. The next ffmpeg frame arriving during that
window got dropped.

After:
- ScryptedViewportProvider keeps a per-host undici Agent with
  keepAliveTimeout 30s, connections:2, pipelining:0. Sockets stay
  open across /frame, /state, /config — saves the SYN+SYN-ACK+ACK
  round-trip every POST (small on LAN but real on Wi-Fi).
- Stream loop's inFlight boolean is now a counter capped at 2 so
  frame N+1 can begin uploading on socket B while frame N is still
  being decoded on the device via socket A. Roughly doubles effective
  throughput when body upload time dominates.

Firmware side:
- esp_http_server max_open_sockets bumped 7→4 explicitly: 2 for
  pipelined /frame + 2 spare for concurrent /state and /config slots.
  The JPEG decoder mutex still serialises decode (only one /frame
  can be decoding at a time); this change only unblocks the network
  half of the pipeline.
</content>
</entry>
<entry>
<title>instrumentation: firmware idle gap between frames + Scrypted per-fetch wall-clock</title>
<updated>2026-06-15T13:25:02+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T13:25:02+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=827ea716db0ee8f58deb5af9ea67ca69cab58e99'/>
<id>urn:sha1:827ea716db0ee8f58deb5af9ea67ca69cab58e99</id>
<content type='text'>
</content>
</entry>
<entry>
<title>firmware: double-buffer the panel + zero-copy decode → ~22 fps ceiling</title>
<updated>2026-06-15T12:50:23+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T12:50:23+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=3f53ced44ea112a39eec70216c8c72fa6867726e'/>
<id>urn:sha1:3f53ced44ea112a39eec70216c8c72fa6867726e</id>
<content type='text'>
Per-frame paint cost drops from ~24 ms to ~45 µs (≈500× faster) by
enabling num_fbs=2 on the DPI panel and decoding straight into the
back framebuffer. Measured on the bench:

  before: lock=7us ttfb=370us body=40ms dec=6ms paint=24ms post=35us = ~70ms / ~14fps
  after : lock=7us ttfb=330us body=38ms dec=6ms paint=42us  post=25us = ~45ms / ~22fps

How the win actually lands:
- num_fbs=2 in the esp_lcd_dpi_panel_config_t makes the IDF driver
  allocate two framebuffers and stream from one while we fill the
  other.
- display_back_buffer() returns the inactive fb pointer + its size.
- jpeg_decoder_decode() now accepts a caller-provided destination
  buffer instead of owning its own scratch. http_api passes the panel
  back-fb so the hardware JPEG decoder writes BGR888 pixels straight
  into where the DSI will eventually scan from. Zero memcpy in the
  hot path.
- display_flip_back_buffer() calls esp_lcd_panel_draw_bitmap with the
  fb pointer. Because the buffer is inside the panel's own fb range,
  the IDF driver skips its memcpy and just does a cache writeback +
  swaps cur_fb_index. The actual flip happens on the next vsync,
  asynchronously — the call returns in microseconds.

The remaining ceiling is network body time (~38 ms for ~210 KB JPEGs)
and the hardware decoder (~6 ms). Per-viewport JPEG quality (smaller
files = shorter body) is the next lever; everything firmware-side is
already at or near floor.

Also drop the old static jpeg output scratch + JPEG_DECODER_MAX_OUTPUT_BYTES
constant — nothing references them anymore.
</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>http_api: log per-frame timing breakdown every 10 frames (recv / decode / paint)</title>
<updated>2026-06-15T12:30:37+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T12:30:37+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=def48e1130a46d16b89794bafa78592dbe146184'/>
<id>urn:sha1:def48e1130a46d16b89794bafa78592dbe146184</id>
<content type='text'>
</content>
</entry>
<entry>
<title>firmware: zero-copy JPEG → BGR888 → DSI hot path; Scrypted pre-rotates</title>
<updated>2026-06-15T12:23:47+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T12:23:47+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=51b816025183801f5953d849fe4ea83cd9385582'/>
<id>urn:sha1:51b816025183801f5953d849fe4ea83cd9385582</id>
<content type='text'>
Architectural rework of the /frame hot path. Scrypted now ships every
JPEG already scaled + rotated to the panel's native dimensions (read
from the firmware's /state response so nothing is hardcoded on the
Scrypted side); the firmware decodes the JPEG straight into a BGR888
buffer that's directly draw_bitmap'able by the DSI driver, with zero
CPU pixel work and zero rotation work in between.

Firmware
- jpeg_decoder now uses JPEG_DECODE_OUT_FORMAT_RGB888 +
  JPEG_DEC_RGB_ELEMENT_ORDER_BGR. Output buffer sized for 800*480*3.
- New display_present_bgr888() is a one-liner that hands the decoder's
  output straight to esp_lcd_panel_draw_bitmap.
- /frame handler validates dimensions against the panel-native
  VIEWPORT_PANEL_WIDTH x VIEWPORT_PANEL_HEIGHT (was effective_dims
  branching on orientation). Returns 400 if it's anything else.
- /state JSON adds panel_width + panel_height so Scrypted can read them
  without hardcoding board-specific knowledge.
- display_present_rgb565 + s_rot_buf stay for the local-screens cold
  path (info screen, loading) which still does its own CPU conversion +
  rotation — infrequent enough that it's not worth the rewrite.

Scrypted
- startStream() GETs /state at stream-start time, caches panel_width
  and panel_height in viewport storage, and uses them as the ffmpeg
  scale target. Falls back to cached or 800x480 if /state is mid-reboot.
- For portrait viewports the ffmpeg pipeline now does
  scale=H:W:flags=lanczos,transpose=1 so the JPEG arrives pre-rotated
  90° CW into panel-native dimensions. Landscape is just scale=W:H.
- No more in-firmware rotation; Scrypted is the single source of truth
  for "how do I get this camera frame into a panel-shaped JPEG".

Expected ceiling lift: ~5 fps → ~10 fps, gated by the JPEG decoder
hardware throughput instead of the CPU rgb565→bgr888 + rotation loop.
</content>
</entry>
</feed>
