<feed xmlns='http://www.w3.org/2005/Atom'>
<title>luke/esp32-poe-scrypted-viewport/main/http_api.c, 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-20T16:38:14+00:00</updated>
<entry>
<title>phase 4: glass-to-glass via 16-byte stream header + /state stream stats</title>
<updated>2026-06-20T16:38:14+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T16:38:14+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=e4a546ce29a3a29dc814b7d115a1b9c206385559'/>
<id>urn:sha1:e4a546ce29a3a29dc814b7d115a1b9c206385559</id>
<content type='text'>
Wire format change: stream frames now carry a 4-byte "VPRT" magic +
4-byte jpeg_len + 4-byte seq + 4-byte event_us_low. Total 16 bytes
(was 8). The firmware sniffs the first 4 bytes per frame: if they
spell VPRT it reads the remaining 12 bytes of v1 header; otherwise
it interprets bytes 0-3 as jpeg_len for the old v0 8-byte format and
reads 4 more for seq. Lets a v1 firmware accept a v0 (legacy)
Scrypted script during the rollout window. v0 will be removed once
all field deployments roll forward.

event_us_low is the low 32 bits of the Scrypted host's monotonic µs
at camera-event arrival. The firmware does NOT interpret it (the
clocks aren't sync'd); it just stamps it on every painted frame and
exposes the most recent value via /state. The script polls /state
every 5s during an active stream, reads last_paint_event_us_low,
and computes glass-to-glass = (now_us_low - last_paint_event_us_low)
with 32-bit wrap. 30s sanity ceiling on the wrap to discard event
timestamps from before the stream started.

Also expose the firmware's just-closed 30-frame window stats via
/state under the "stream" key — frames, bytes, window_us, plus
min/avg/max for recv/dec/paint/idle. Lets external tools (a curl
loop, the Scrypted plugin, etc) poll the firmware's view without
parsing serial logs.

Firmware:
- stream_server.h: 16-byte v1 wire spec, stream_server_stats_t
  struct, stream_server_snapshot_stats(out) getter.
- stream_server.c: magic-detect header read path, last_event_us_low
  capture into per-connection state, portMUX-protected window-stats
  snapshot at every 30-frame roll.
- http_api.c: GET /state JSON gains a "stream" sub-object with the
  full snapshot.
- viewport_state.h: VIEWPORT_VERSION 1.0.0 → 1.1.0 (new /state shape).

Scrypted:
- startStream captures eventUsLow = (tEvent * 1000) &gt;&gt;&gt; 0.
- TCP demux loop writes the 16-byte v1 header with the VPRT magic.
- New fwPoller setInterval (5s) fetches /state, parses .stream,
  computes g2g, emits one summary line per poll cycle.
</content>
</entry>
<entry>
<title>cleanup phase 2: delete HTTP-streaming-era dead code</title>
<updated>2026-06-20T16:23:23+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T16:23:23+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=4d5fd28400dad6a8e34f77a527d7ac4e92e76093'/>
<id>urn:sha1:4d5fd28400dad6a8e34f77a527d7ac4e92e76093</id>
<content type='text'>
Both sides simultaneously because the script's X-Frame-Seq sender and
the firmware's X-Frame-Seq receiver negotiated a contract that's now
retired entirely.

Firmware (main/http_api.c):
- Delete static uint32_t s_last_painted_seq (declaration + reset in
  state_post_handler + the read in frame_post_handler + the
  assignment after paint).
- Delete the X-Frame-Seq header read (httpd_req_get_hdr_value_str
  + strtoul block).
- Delete the X-Frame-Drop: stale-seq response path.
- Delete the entire Server-Timing httpd_resp_set_hdr block.
- Delete the setsockopt(TCP_NODELAY) at frame_post_handler entry.
  /frame is a single body POST → empty 204; no second packet for
  Nagle to coalesce with on the response side.
- Delete static int64_t s_last_post_us + the idle_us computation.
  /frame fires at most once per wake; idle gap between wakes is
  dominated by user/event timing, not anything firmware-controllable.
- Drop the per-10-frames condition on the timing log — /frame fires
  rarely enough that one log per snapshot is the right cadence —
  and rename "frame N: ..." → "snapshot: ..." to match.
- Drop cfg.max_open_sockets 4 → 2 (snapshot POST + concurrent /state
  or /config).
- Drop #include "lwip/sockets.h" (orphaned with TCP_NODELAY).

Script (scrypted/scrypted-viewport.ts):
- Delete the agents Map + agentFor() method (per-host keepAlive
  Agent pool; over-engineered for ~1 POST/min control plane).
- Delete httpRequest() helper (40 lines wrapping http.request to
  surface tHeaders/tDone — no consumer reads those fields anymore).
- Rewrite postJSON() to a 10-line fetch() with AbortSignal.timeout.
- Delete the frameSeq Map + the seq counter + X-Frame-Seq header on
  the snapshot fetch + the X-Frame-Drop response check + the
  frameSeq.delete in stopStream.
- Extract buildVf(orientation, panelW, panelH) helper near top of
  ScryptedViewportProvider — used by both startStream (live) and
  pushSnapshot (one-shot ffmpeg fallback).

TCP_NODELAY references remain in the live-stream socket path
(scrypted-viewport.ts:773, 788). That's a different socket (raw
net.Socket on TCP/81) and noDelay there is what eliminates Nagle
stalls under the streaming-heavy live workload. Load-bearing.
</content>
</entry>
<entry>
<title>cleanup phase 1: stale-comment refresh, zero behavior change</title>
<updated>2026-06-20T16:16:52+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T16:16:52+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=131c46f8c539b065feb397e2ee706cff1c81e4cc'/>
<id>urn:sha1:131c46f8c539b065feb397e2ee706cff1c81e4cc</id>
<content type='text'>
Removes/rewrites every comment that referenced the dead HTTP-streaming
architecture so a reader of v1.0.0+ source isn't chasing a model
that's been gone for several releases. No code paths changed; this is
the safe pre-pass before Phase 2's actual deletions.

Firmware (main/http_api.c):
- s_last_painted_seq / s_last_post_us / X-Frame-Seq parse / stale-
  frame guard / Server-Timing emission / TCP_NODELAY setsockopt /
  max_open_sockets=4 — each block now leads with "(Legacy from the
  HTTP-streaming era; removed in Phase 2)" so the reader knows the
  block is doomed, not load-bearing.
- /frame dim-mismatch comment narrowed: panel-native is always 800x480
  BGR888, no Scrypted-side variation expected; Scrypted does the
  rotation+scale via sharp/mediaManager/ffmpeg cascade (snapshot) or
  ffmpeg -vf (stream).
- "Single in-flight frame. Concurrent posts get 503" rewritten to
  reflect: decoder mutex now mostly serves to fence /frame snapshots
  against an active stream_server decode.

Firmware (main/stream_server.c:136-142):
- FIONREAD-skip rationale reduced from a 7-line paragraph to one
  sentence; the savings/tradeoff math now lives in the plan, not the
  per-line comment.

Script (scrypted/scrypted-viewport.ts):
- Top-of-file tuning constants block drops the "frame_interval_ms
  removed", "fps filter", "in-flight back-to-back startStream"
  rationale; one short line covers the model: "Stream rate is paced
  by camera + TCP backpressure; no app-level fps cap."
- agentFor() rationale rewritten: this Agent is over-engineered for
  control-plane traffic (~1 POST/min steady state) — a legacy of when
  it backed per-frame /frame POSTs. Marked for Phase 2 retirement.
- noDelay on Agent: clarified it's now a no-op safety for control
  plane (was load-bearing for live-stream pipelining).
- snapshot fire-and-forget comment: replaced X-Frame-Seq-race
  rationale with the actual TCP-streaming truth (sharp/mediaManager/
  ffmpeg cascade race against stream socket bring-up).
- writeLatencies probe: rewrote the "keep-alive socket" comment
  (live stream uses raw net.Socket, not the http.Agent pool).
- socketBackpressured: explicit "diagnostic only, never gates writes"
  comment added at declaration site.
- skipLogger header: rewrote the inFlight/MAX_INFLIGHT/fps-filter
  rationale into a one-line description of what the log line actually
  emits today.
- frameSeq map: now flagged as legacy of HTTP-streaming era, retired
  in Phase 2.
</content>
</entry>
<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>
</feed>
