src.nth.io/

summaryrefslogtreecommitdiff
path: root/main
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-19 19:59:14 -0500
committerLuke Hoersten <[email protected]>2026-06-19 19:59:14 -0500
commitb97c2502b74277f9ea9dace97e8188201d5570fb (patch)
tree5a36c4f3f06ae3e310da5d42e3df84e989e91e3b /main
parentc89f832eb2c10f007ecf000165625c9953216a82 (diff)
streaming pivot: raw TCP data plane, drop per-frame HTTP entirely
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).
Diffstat (limited to 'main')
-rw-r--r--main/app_main.c7
-rw-r--r--main/stream_server.c245
-rw-r--r--main/stream_server.h18
3 files changed, 269 insertions, 1 deletions
diff --git a/main/app_main.c b/main/app_main.c
index 00a77bb..a6174c8 100644
--- a/main/app_main.c
+++ b/main/app_main.c
@@ -7,6 +7,7 @@
#include "nvs_config.h"
#include "state_client.h"
#include "state_machine.h"
+#include "stream_server.h"
#include "touch.h"
#include "viewport_state.h"
@@ -52,7 +53,7 @@ void app_main(void)
// don't get a DHCP lease. mDNS + HTTP advertise / bind anyway and will
// start serving the moment the link comes up.
// ------------------------------------------------------------------
- char flags[7] = { '-','-','-','-','-','-', 0 }; // E M H D J T
+ char flags[8] = { '-','-','-','-','-','-','-', 0 }; // E M H S D J T
esp_err_t eth_err = net_eth_init();
mark(eth_err, 'E', &flags[0]);
@@ -71,6 +72,10 @@ void app_main(void)
ESP_ERROR_CHECK(state_client_init());
mark(mdns_service_start(), 'M', &flags[1]);
mark(http_api_start(), 'H', &flags[2]);
+ // Data plane: raw TCP on :81 for back-to-back JPEG streaming,
+ // bypassing per-frame HTTP overhead. /frame HTTP stays alive for
+ // the snapshot one-shot and for curl debugging.
+ mark(stream_server_start(81), 'S', &flags[3]);
// ------------------------------------------------------------------
// Display + I²C-bound peripherals run on their own task. ESP-IDF's
diff --git a/main/stream_server.c b/main/stream_server.c
new file mode 100644
index 0000000..cebe3cb
--- /dev/null
+++ b/main/stream_server.c
@@ -0,0 +1,245 @@
+#include "stream_server.h"
+
+#include <errno.h>
+#include <string.h>
+
+#include "esp_log.h"
+#include "esp_timer.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "lwip/netdb.h"
+#include "lwip/sockets.h"
+
+#include "display.h"
+#include "jpeg_decoder.h"
+#include "state_machine.h"
+#include "viewport_state.h"
+
+static const char *TAG = "stream";
+
+#define HEADER_BYTES 8 // 4 jpeg_len + 4 seq
+
+static uint16_t s_port;
+
+// recv() in a loop until n bytes are read or the connection drops.
+// Returns ESP_OK on full read, ESP_FAIL on EOF or socket error.
+static esp_err_t read_n(int fd, void *buf, size_t n)
+{
+ uint8_t *p = (uint8_t *)buf;
+ size_t got = 0;
+ while (got < n) {
+ ssize_t r = recv(fd, p + got, n - got, 0);
+ if (r <= 0) return ESP_FAIL;
+ got += (size_t)r;
+ }
+ return ESP_OK;
+}
+
+// Read and discard exactly n bytes — used to stay framed when we
+// have to skip a frame (decoder busy, asleep, etc) without dropping
+// the connection.
+static esp_err_t drain_n(int fd, size_t n)
+{
+ uint8_t scratch[256];
+ while (n > 0) {
+ size_t want = n > sizeof(scratch) ? sizeof(scratch) : n;
+ ssize_t r = recv(fd, scratch, want, 0);
+ if (r <= 0) return ESP_FAIL;
+ n -= (size_t)r;
+ }
+ return ESP_OK;
+}
+
+// One client owns the decoder for the lifetime of the connection.
+// Loops: header → body → (decode + paint OR skip) → repeat.
+static void handle_client(int fd, const char *peer)
+{
+ // NODELAY on the accepted socket so our outbound (response-less)
+ // ACKs flow immediately. There's no app-layer "response" in this
+ // protocol so we have to make sure TCP doesn't withhold ACKs
+ // waiting to piggyback on data we'll never send.
+ int one = 1;
+ (void)setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
+
+ // Sequence counter is per-connection — Scrypted resets to 1 on
+ // each new socket so we follow.
+ uint32_t last_painted_seq = 0;
+ uint64_t frames_decoded = 0;
+ int64_t t_window_start = esp_timer_get_time();
+ uint64_t bytes_in_window = 0;
+
+ while (1) {
+ uint8_t hdr[HEADER_BYTES];
+ if (read_n(fd, hdr, HEADER_BYTES) != ESP_OK) {
+ ESP_LOGI(TAG, "client %s disconnected (header read)", peer);
+ return;
+ }
+ uint32_t jpeg_len = ((uint32_t)hdr[0] << 24) | ((uint32_t)hdr[1] << 16)
+ | ((uint32_t)hdr[2] << 8) | (uint32_t)hdr[3];
+ uint32_t seq = ((uint32_t)hdr[4] << 24) | ((uint32_t)hdr[5] << 16)
+ | ((uint32_t)hdr[6] << 8) | (uint32_t)hdr[7];
+
+ if (jpeg_len == 0 || jpeg_len > JPEG_DECODER_MAX_INPUT_BYTES) {
+ ESP_LOGW(TAG, "bad frame length %u from %s — closing connection",
+ (unsigned)jpeg_len, peer);
+ return;
+ }
+
+ int64_t t_entry = esp_timer_get_time();
+
+ if (!jpeg_decoder_try_lock(2000)) {
+ ESP_LOGW(TAG, "decoder busy 2s — skipping seq %u", (unsigned)seq);
+ if (drain_n(fd, jpeg_len) != ESP_OK) return;
+ continue;
+ }
+
+ uint8_t *in = jpeg_decoder_input_buffer();
+ if (read_n(fd, in, jpeg_len) != ESP_OK) {
+ jpeg_decoder_unlock();
+ ESP_LOGI(TAG, "client %s disconnected (body read)", peer);
+ return;
+ }
+ int64_t t_recv = esp_timer_get_time();
+ bytes_in_window += jpeg_len;
+
+ // While asleep we still drain frames (to stay in sync) but
+ // skip the decode + paint. The state machine will wake on a
+ // POST /state {wake} from the control plane.
+ if (state_machine_current() != VIEWPORT_STATE_AWAKE) {
+ jpeg_decoder_unlock();
+ continue;
+ }
+
+ // Stale-seq guard. Each socket starts at 0 so the first
+ // frame (seq=1) always paints.
+ if (seq != 0 && seq <= last_painted_seq) {
+ jpeg_decoder_unlock();
+ continue;
+ }
+
+ size_t back_size = 0;
+ void *back = display_back_buffer(&back_size);
+ uint16_t w = 0, h = 0;
+ if (jpeg_decoder_decode(jpeg_len, back, back_size, &w, &h) != ESP_OK) {
+ viewport_state_lock();
+ viewport_state_get()->decode_errors++;
+ viewport_state_unlock();
+ jpeg_decoder_unlock();
+ continue;
+ }
+ if (w != VIEWPORT_PANEL_WIDTH || h != VIEWPORT_PANEL_HEIGHT) {
+ viewport_state_lock();
+ viewport_state_get()->decode_errors++;
+ viewport_state_unlock();
+ jpeg_decoder_unlock();
+ ESP_LOGW(TAG, "dim mismatch seq=%u: expected %ux%u got %ux%u",
+ (unsigned)seq, VIEWPORT_PANEL_WIDTH, VIEWPORT_PANEL_HEIGHT, w, h);
+ continue;
+ }
+ int64_t t_decode = esp_timer_get_time();
+ if (display_flip_back_buffer() != ESP_OK) {
+ jpeg_decoder_unlock();
+ continue;
+ }
+ int64_t t_paint = esp_timer_get_time();
+
+ viewport_state_lock();
+ viewport_state_t *st = viewport_state_get();
+ st->frames_received++;
+ st->last_frame_us = esp_timer_get_time();
+ viewport_state_unlock();
+
+ last_painted_seq = seq;
+ jpeg_decoder_unlock();
+ state_machine_frame_painted();
+ frames_decoded++;
+
+ // Every 30 frames log a per-stage breakdown + window throughput
+ // — same shape as the old /frame log so the serial output stays
+ // useful for debugging without the script side.
+ if (frames_decoded % 30 == 0) {
+ int64_t now = esp_timer_get_time();
+ double win_s = (now - t_window_start) / 1.0e6;
+ double mb_per_s = (win_s > 0)
+ ? ((double)bytes_in_window / win_s) / (1024.0 * 1024.0) : 0.0;
+ ESP_LOGI(TAG,
+ "frame %llu seq=%u: recv=%lldus dec=%lldus paint=%lldus "
+ "total=%lldus (jpeg=%uKB) window=%llumiB/%.1fs (%.2fMB/s)",
+ (unsigned long long)frames_decoded, (unsigned)seq,
+ (long long)(t_recv - t_entry),
+ (long long)(t_decode - t_recv),
+ (long long)(t_paint - t_decode),
+ (long long)(t_paint - t_entry),
+ (unsigned)(jpeg_len / 1024),
+ (unsigned long long)(bytes_in_window / (1024 * 1024)),
+ win_s, mb_per_s);
+ t_window_start = now;
+ bytes_in_window = 0;
+ }
+ }
+}
+
+static void accept_task(void *arg)
+{
+ (void)arg;
+ int listen_sock = -1;
+
+ while (1) {
+ if (listen_sock < 0) {
+ listen_sock = socket(AF_INET, SOCK_STREAM, 0);
+ if (listen_sock < 0) {
+ ESP_LOGE(TAG, "socket() failed: errno=%d", errno);
+ vTaskDelay(pdMS_TO_TICKS(1000));
+ continue;
+ }
+ int reuse = 1;
+ setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
+
+ struct sockaddr_in addr = {
+ .sin_family = AF_INET,
+ .sin_addr.s_addr = htonl(INADDR_ANY),
+ .sin_port = htons(s_port),
+ };
+ if (bind(listen_sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
+ ESP_LOGE(TAG, "bind(%d) failed: errno=%d", s_port, errno);
+ close(listen_sock);
+ listen_sock = -1;
+ vTaskDelay(pdMS_TO_TICKS(1000));
+ continue;
+ }
+ if (listen(listen_sock, 1) < 0) {
+ ESP_LOGE(TAG, "listen() failed: errno=%d", errno);
+ close(listen_sock);
+ listen_sock = -1;
+ vTaskDelay(pdMS_TO_TICKS(1000));
+ continue;
+ }
+ ESP_LOGI(TAG, "stream server listening on tcp/:%d", s_port);
+ }
+
+ struct sockaddr_in client;
+ socklen_t client_len = sizeof(client);
+ int fd = accept(listen_sock, (struct sockaddr *)&client, &client_len);
+ if (fd < 0) {
+ ESP_LOGW(TAG, "accept() failed: errno=%d", errno);
+ vTaskDelay(pdMS_TO_TICKS(100));
+ continue;
+ }
+
+ char ip[INET_ADDRSTRLEN];
+ inet_ntoa_r(client.sin_addr, ip, sizeof(ip));
+ ESP_LOGI(TAG, "client connected from %s:%u",
+ ip, (unsigned)ntohs(client.sin_port));
+
+ handle_client(fd, ip);
+ shutdown(fd, SHUT_RDWR);
+ close(fd);
+ }
+}
+
+esp_err_t stream_server_start(uint16_t port)
+{
+ s_port = port;
+ BaseType_t ok = xTaskCreate(accept_task, "stream", 8192, NULL, 5, NULL);
+ return (ok == pdPASS) ? ESP_OK : ESP_FAIL;
+}
diff --git a/main/stream_server.h b/main/stream_server.h
new file mode 100644
index 0000000..ef8a334
--- /dev/null
+++ b/main/stream_server.h
@@ -0,0 +1,18 @@
+#pragma once
+
+#include <stdint.h>
+#include "esp_err.h"
+
+// Raw-TCP frame ingestion server. Replaces the per-frame HTTP /frame
+// POST loop with a single long-lived TCP connection that streams
+// length+seq prefixed JPEGs back-to-back. Eliminates per-frame TCP
+// setup, HTTP parsing, and the 200ms Nagle/delayed-ACK deadlocks
+// that intermittently spiked the HTTP path to ~250ms wall.
+//
+// Wire protocol (one connection, repeating; all integers big-endian):
+// [4 bytes: jpeg_len ][4 bytes: seq][jpeg_len bytes: JPEG body]
+//
+// One client at a time. New client = previous client's seq counter
+// is reset (so each stream session starts fresh, and stale frames
+// from a previous reconnect can't paint over current ones).
+esp_err_t stream_server_start(uint16_t port);