<feed xmlns='http://www.w3.org/2005/Atom'>
<title>luke/esp32-poe-scrypted-viewport/main/http_api.c, branch main</title>
<subtitle>ESP32-POE Scrypted viewport (private)
</subtitle>
<id>https://src.nth.io/luke/esp32-poe-scrypted-viewport/atom?h=main</id>
<link rel='self' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/atom?h=main'/>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/'/>
<updated>2026-07-17T23:56:03+00:00</updated>
<entry>
<title>main: code-review fixes — brightness units, OTA stall cap, stats + locking</title>
<updated>2026-07-17T23:56:03+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-07-17T23:56:03+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=9d94f710f7b00d109bf6f6ee57996f43bce5c67f'/>
<id>urn:sha1:9d94f710f7b00d109bf6f6ee57996f43bce5c67f</id>
<content type='text'>
- display: s_last_pwm cached the raw 0-100 percentage at init but
  display_wake writes it straight to REG_PWM (0-255 duty) — first wake
  ran visibly dim until a /config brightness change. Shared pct_to_duty
  helper now converts in both paths.
- http_api: cap consecutive OTA recv timeouts (a stalled client spun
  forever holding s_ota_in_progress, wedging OTA until reboot); cap
  viewport name at 54 chars so viewport-&lt;name&gt; fits the 63-byte mDNS
  label; log mdns_service_refresh failures; /state builds JSON from a
  snapshot instead of holding the state lock across ~25 cJSON allocs;
  respond_400 delegates to respond_status.
- stream_server: bytes_in_window now counts painted frames only (frames
  discarded while asleep inflated the first post-wake window's MB/s,
  avg-jpeg and chunk/wire averages; recv_bytes folded in); so_rcvbuf
  carried into the stats snapshot under the mux; drop dead HEADER_BYTES;
  merge read_body_instrumented into read_n.
- state_machine: transition mutex serializes concurrent wake/sleep
  (display side-effects ran after the state lock dropped, so racing
  callers could leave the backlight contradicting st-&gt;state); wake-path
  placeholder paint now takes the decoder lock like the stream path
  (concurrent esp_lcd_panel_draw_bitmap from two tasks isn't safe).
- local_screens: overlay paint takes the decoder lock too; check the
  overlay timer create; panel dims from viewport_state.h.
- jpeg_decoder: try_lock before init returns busy instead of passing a
  NULL semaphore to xSemaphoreTake.
- net_eth: clear cached IP string on link down (stale /state + info).
- dead code: display_present_bgr888, TOUCH_FT5426_ADDR, touch s_task,
  JPEG_DECODER_MAX_OUTPUT_BYTES; doc drift in nvs_config.h /
  jpeg_decoder.h / app_main flag legend.
</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>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>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>
</feed>
