<feed xmlns='http://www.w3.org/2005/Atom'>
<title>luke/esp32-poe-scrypted-viewport/main/stream_server.h, 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:59:14+00:00</updated>
<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>
