<feed xmlns='http://www.w3.org/2005/Atom'>
<title>luke/esp32-poe-scrypted-viewport/main/http_api.c, branch v1.3.0</title>
<subtitle>ESP32-POE Scrypted viewport (private)
</subtitle>
<id>https://src.nth.io/luke/esp32-poe-scrypted-viewport/atom?h=v1.3.0</id>
<link rel='self' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/atom?h=v1.3.0'/>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/'/>
<updated>2026-06-21T01:50:43+00:00</updated>
<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>
<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>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>
</feed>
