<feed xmlns='http://www.w3.org/2005/Atom'>
<title>luke/esp32-poe-scrypted-viewport/main/stream_server.h, 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>streaming pivot: raw TCP data plane, drop per-frame HTTP entirely</title>
<updated>2026-06-20T00:59:14+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T00:59:14+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=b97c2502b74277f9ea9dace97e8188201d5570fb'/>
<id>urn:sha1:b97c2502b74277f9ea9dace97e8188201d5570fb</id>
<content type='text'>
Replaces the per-frame HTTP POST loop with a single long-lived TCP
connection on port 81. The HTTP control plane (/state, /config,
/frame for snapshot) stays unchanged.

Wire protocol (big-endian, repeating until connection close):
  [4 bytes jpeg_len][4 bytes seq][jpeg_len bytes JPEG]

Why
---
The HTTP path hit a measured floor of ~37ms p50 with intermittent
~230ms p95 spikes that survived every Nagle/keep-alive fix attempt.
Each frame paid: TCP setup (or pool churn), HTTP parsing, body recv,
decode, paint, response write, response ACK. Streaming removes
everything except recv + decode + paint.

Firmware (main/stream_server.[ch], new)
---------------------------------------
- TCP listen on configurable port (81) in its own FreeRTOS task,
  one client at a time (matches the one-stream-per-device model).
- Per accepted socket: TCP_NODELAY on, then loop reading 8-byte
  header → jpeg body → through the existing jpeg_decoder + display
  paths. Same stale-seq guard as the HTTP /frame handler (reset per
  connection so each session starts at seq 1).
- Frames received while asleep are still drained (to stay framed)
  but not painted. The HTTP control-plane POST /state {wake}
  resumes painting on the next frame.
- Every 30 painted frames a structured serial log shows per-stage
  timing + sustained MB/s — replaces the cross-side Server-Timing
  header (no HTTP response to attach it to anymore).

Scrypted side
-------------
- net.createConnection({ host, port: 81, noDelay: true }) opened
  once per stream session. On disconnect/error/close we auto-
  reconnect after 500ms.
- ffmpeg stdout demux writes [header][body] directly to the
  socket. sock.write() returning false sets a backpressured flag
  that drops incoming ffmpeg frames until 'drain' fires — natural
  TCP backpressure handles "firmware can't keep up" without us
  modeling it manually.
- Stripped the entire fetch-based timing infrastructure
  (pushStreamFrame, fetchSamples, parseServerTiming, depth
  histogram, Server-Timing parser). Replaced with one stream-shaped
  log every 10s: fps + MB/s + socket.write p50/p95/max + drop count
  + backpressured flag.
- pushSnapshot (one-shot first-paint) still uses the HTTP /frame
  endpoint — small and infrequent, not worth reworking.
- postJSON still uses node:http for /state and /config.

State machine status flags
--------------------------
Extended boot-time flags array from 6 → 7 slots so the stream
server's bring-up shows up alongside ETH/MDNS/HTTP. Layout is now
E M H S D J T (was E M H D J T).
</content>
</entry>
</feed>
