src.nth.io/

summaryrefslogtreecommitdiff
path: root/main
diff options
context:
space:
mode:
authorLuke Hoersten <[email protected]>2026-06-20 11:38:14 -0500
committerLuke Hoersten <[email protected]>2026-06-20 11:38:14 -0500
commite4a546ce29a3a29dc814b7d115a1b9c206385559 (patch)
tree88c22070093349c81d953c2bc18f628146db972d /main
parentd5eef650e7640e5622c384e6783fe4ff201e172e (diff)
phase 4: glass-to-glass via 16-byte stream header + /state stream stats
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) >>> 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.
Diffstat (limited to 'main')
-rw-r--r--main/http_api.c30
-rw-r--r--main/stream_server.c91
-rw-r--r--main/stream_server.h45
-rw-r--r--main/viewport_state.h2
4 files changed, 158 insertions, 10 deletions
diff --git a/main/http_api.c b/main/http_api.c
index cb6a8b3..85992b1 100644
--- a/main/http_api.c
+++ b/main/http_api.c
@@ -16,6 +16,7 @@
#include "mdns_service.h"
#include "net_eth.h"
#include "nvs_config.h"
+#include "stream_server.h"
#include "state_machine.h"
#include "viewport_state.h"
@@ -67,6 +68,35 @@ static esp_err_t state_get_handler(httpd_req_t *req)
viewport_state_unlock();
+ // Most recent closed window of live-stream stats. Populated every
+ // 30 painted stream frames; zero before the first window rolls.
+ // last_paint_event_us_low is the low 32 bits of the Scrypted
+ // host monotonic µs at camera-event arrival, passed verbatim
+ // through the stream header. Script computes glass-to-glass as
+ // (now_us_low - last_paint_event_us_low) with wrap.
+ stream_server_stats_t stream;
+ stream_server_snapshot_stats(&stream);
+ cJSON *s = cJSON_CreateObject();
+ cJSON_AddNumberToObject(s, "frames", (double)stream.frames);
+ cJSON_AddNumberToObject(s, "bytes", (double)stream.bytes);
+ cJSON_AddNumberToObject(s, "window_us", (double)stream.window_us);
+ cJSON_AddNumberToObject(s, "window_end_us", (double)stream.window_end_us);
+ cJSON_AddNumberToObject(s, "recv_min_us", (double)stream.recv_min_us);
+ cJSON_AddNumberToObject(s, "recv_avg_us", (double)stream.recv_avg_us);
+ cJSON_AddNumberToObject(s, "recv_max_us", (double)stream.recv_max_us);
+ cJSON_AddNumberToObject(s, "dec_min_us", (double)stream.dec_min_us);
+ cJSON_AddNumberToObject(s, "dec_avg_us", (double)stream.dec_avg_us);
+ cJSON_AddNumberToObject(s, "dec_max_us", (double)stream.dec_max_us);
+ cJSON_AddNumberToObject(s, "paint_min_us", (double)stream.pnt_min_us);
+ cJSON_AddNumberToObject(s, "paint_avg_us", (double)stream.pnt_avg_us);
+ cJSON_AddNumberToObject(s, "paint_max_us", (double)stream.pnt_max_us);
+ cJSON_AddNumberToObject(s, "idle_min_us", (double)stream.idle_min_us);
+ cJSON_AddNumberToObject(s, "idle_avg_us", (double)stream.idle_avg_us);
+ cJSON_AddNumberToObject(s, "idle_max_us", (double)stream.idle_max_us);
+ cJSON_AddNumberToObject(s, "last_paint_event_us_low",
+ (double)stream.last_paint_event_us_low);
+ cJSON_AddItemToObject(root, "stream", s);
+
char *body = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
if (!body) return ESP_ERR_NO_MEM;
diff --git a/main/stream_server.c b/main/stream_server.c
index 38769f0..c417e4a 100644
--- a/main/stream_server.c
+++ b/main/stream_server.c
@@ -18,10 +18,22 @@
static const char *TAG = "stream";
-#define HEADER_BYTES 8 // 4 jpeg_len + 4 seq
+#define HEADER_V0_BYTES 8 // legacy: jpeg_len + seq
+#define HEADER_V1_BYTES 16 // current: magic + jpeg_len + seq + event_us_low
+#define HEADER_BYTES HEADER_V0_BYTES // used for FIONREAD threshold —
+ // "another header may already
+ // be queued" check needs only
+ // the smaller of the two.
+#define MAGIC_V1 0x56505254u // "VPRT" big-endian
static uint16_t s_port;
+// Last-window stats snapshot, written by handle_client at window roll
+// and read by /state via stream_server_snapshot_stats. portMUX keeps
+// the writer atomic against a concurrent reader on the other core.
+static portMUX_TYPE s_stats_mux = portMUX_INITIALIZER_UNLOCKED;
+static stream_server_stats_t s_stats;
+
// 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)
@@ -77,17 +89,46 @@ static void handle_client(int fd, const char *peer)
int64_t idle_min = INT64_MAX, idle_max = 0, idle_sum = 0;
int64_t lock_min = INT64_MAX, lock_max = 0, lock_sum = 0;
uint64_t window_samples = 0;
+ uint32_t last_event_us_low = 0; // captured from v1 headers
while (1) {
- uint8_t hdr[HEADER_BYTES];
- if (read_n(fd, hdr, HEADER_BYTES) != ESP_OK) {
+ // Sniff the first 4 bytes — they're either the v1 magic
+ // "VPRT" or the v0 jpeg_len field. Decide once per frame.
+ uint8_t first4[4];
+ if (read_n(fd, first4, 4) != 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];
+ uint32_t first_word = ((uint32_t)first4[0] << 24) | ((uint32_t)first4[1] << 16)
+ | ((uint32_t)first4[2] << 8) | (uint32_t)first4[3];
+
+ uint32_t jpeg_len, seq, event_us_low;
+ if (first_word == MAGIC_V1) {
+ // v1: 16-byte header total, 12 more bytes after the magic.
+ uint8_t rest[HEADER_V1_BYTES - 4];
+ if (read_n(fd, rest, sizeof(rest)) != ESP_OK) {
+ ESP_LOGI(TAG, "client %s disconnected (v1 header read)", peer);
+ return;
+ }
+ jpeg_len = ((uint32_t)rest[0] << 24) | ((uint32_t)rest[1] << 16)
+ | ((uint32_t)rest[2] << 8) | (uint32_t)rest[3];
+ seq = ((uint32_t)rest[4] << 24) | ((uint32_t)rest[5] << 16)
+ | ((uint32_t)rest[6] << 8) | (uint32_t)rest[7];
+ event_us_low = ((uint32_t)rest[8] << 24) | ((uint32_t)rest[9] << 16)
+ | ((uint32_t)rest[10] << 8) | (uint32_t)rest[11];
+ } else {
+ // v0: 8-byte header, 4 more bytes after the first word.
+ // first_word is jpeg_len; read seq.
+ uint8_t rest[HEADER_V0_BYTES - 4];
+ if (read_n(fd, rest, sizeof(rest)) != ESP_OK) {
+ ESP_LOGI(TAG, "client %s disconnected (v0 header read)", peer);
+ return;
+ }
+ jpeg_len = first_word;
+ seq = ((uint32_t)rest[0] << 24) | ((uint32_t)rest[1] << 16)
+ | ((uint32_t)rest[2] << 8) | (uint32_t)rest[3];
+ event_us_low = 0; // legacy clients don't supply it
+ }
if (jpeg_len == 0 || jpeg_len > JPEG_DECODER_MAX_INPUT_BYTES) {
ESP_LOGW(TAG, "bad frame length %u from %s — closing connection",
@@ -175,6 +216,7 @@ static void handle_client(int fd, const char *peer)
viewport_state_unlock();
last_painted_seq = seq;
+ if (event_us_low != 0) last_event_us_low = event_us_low;
jpeg_decoder_unlock();
state_machine_frame_painted();
frames_decoded++;
@@ -232,6 +274,33 @@ static void handle_client(int fd, const char *peer)
(long long)(idle_min == INT64_MAX ? 0 : idle_min),
(long long)(idle_sum / window_samples),
(long long)idle_max);
+
+ // Publish the just-closed window so /state can expose it.
+ // Skipped under portENTER_CRITICAL — handful of integer
+ // moves, completes in single-digit µs.
+ stream_server_stats_t snap = {
+ .frames = window_samples,
+ .bytes = bytes_in_window,
+ .window_us = (uint64_t)(now - t_window_start),
+ .window_end_us = (uint64_t)now,
+ .recv_min_us = (uint32_t)recv_min,
+ .recv_avg_us = (uint32_t)(recv_sum / window_samples),
+ .recv_max_us = (uint32_t)recv_max,
+ .dec_min_us = (uint32_t)dec_min,
+ .dec_avg_us = (uint32_t)(dec_sum / window_samples),
+ .dec_max_us = (uint32_t)dec_max,
+ .pnt_min_us = (uint32_t)pnt_min,
+ .pnt_avg_us = (uint32_t)(pnt_sum / window_samples),
+ .pnt_max_us = (uint32_t)pnt_max,
+ .idle_min_us = (uint32_t)(idle_min == INT64_MAX ? 0 : idle_min),
+ .idle_avg_us = (uint32_t)(idle_sum / window_samples),
+ .idle_max_us = (uint32_t)idle_max,
+ .last_paint_event_us_low = last_event_us_low,
+ };
+ portENTER_CRITICAL(&s_stats_mux);
+ s_stats = snap;
+ portEXIT_CRITICAL(&s_stats_mux);
+
t_window_start = now;
bytes_in_window = 0;
recv_min = dec_min = pnt_min = idle_min = lock_min = INT64_MAX;
@@ -306,3 +375,11 @@ esp_err_t stream_server_start(uint16_t port)
BaseType_t ok = xTaskCreate(accept_task, "stream", 8192, NULL, 5, NULL);
return (ok == pdPASS) ? ESP_OK : ESP_FAIL;
}
+
+void stream_server_snapshot_stats(stream_server_stats_t *out)
+{
+ if (!out) return;
+ portENTER_CRITICAL(&s_stats_mux);
+ *out = s_stats;
+ portEXIT_CRITICAL(&s_stats_mux);
+}
diff --git a/main/stream_server.h b/main/stream_server.h
index ef8a334..1d13457 100644
--- a/main/stream_server.h
+++ b/main/stream_server.h
@@ -1,6 +1,7 @@
#pragma once
#include <stdint.h>
+#include <stdbool.h>
#include "esp_err.h"
// Raw-TCP frame ingestion server. Replaces the per-frame HTTP /frame
@@ -9,10 +10,50 @@
// 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]
+// Wire protocol — one connection, repeating; all integers big-endian.
+// Two formats are accepted on every connection:
+//
+// v1 (16-byte header, current):
+// [u32 magic = 0x56505254 "VPRT"]
+// [u32 jpeg_len]
+// [u32 seq]
+// [u32 event_us_low] low 32 bits of Scrypted-host monotonic µs
+// at camera-event arrival; firmware passes
+// this through verbatim to /state so the
+// script can compute glass-to-glass.
+// [jpeg_len bytes: JPEG body]
+//
+// v0 (8-byte header, legacy):
+// [u32 jpeg_len]
+// [u32 seq]
+// [jpeg_len bytes: JPEG body]
+//
+// We sniff the first 4 bytes: if they spell "VPRT" the connection is
+// v1, otherwise we interpret bytes 0-3 as jpeg_len (v0) and continue.
+// The v0 path will be removed once all Scrypted scripts in the field
+// have rolled forward.
//
// 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);
+
+// Snapshot of the most recent 30-frame window plus the latest
+// painted frame's pass-through event timestamp. Locking is internal
+// to stream_server — the caller just reads. All durations in
+// microseconds. Fields are zero before the first window rolls.
+typedef struct {
+ uint64_t frames; // count of painted frames in the window
+ uint64_t bytes; // total body bytes received in the window
+ uint64_t window_us; // wall-clock span of the window
+ uint64_t window_end_us; // esp_timer_get_time() at window roll
+ uint32_t recv_min_us, recv_avg_us, recv_max_us;
+ uint32_t dec_min_us, dec_avg_us, dec_max_us;
+ uint32_t pnt_min_us, pnt_avg_us, pnt_max_us;
+ uint32_t idle_min_us, idle_avg_us, idle_max_us;
+ uint32_t last_paint_event_us_low; // last v1 frame's event_us_low,
+ // 0 if none seen yet on this
+ // boot or last frame was v0
+} stream_server_stats_t;
+
+void stream_server_snapshot_stats(stream_server_stats_t *out);
diff --git a/main/viewport_state.h b/main/viewport_state.h
index 7fa3b5d..165d1a7 100644
--- a/main/viewport_state.h
+++ b/main/viewport_state.h
@@ -3,7 +3,7 @@
#include <stdbool.h>
#include <stdint.h>
-#define VIEWPORT_VERSION "1.0.0"
+#define VIEWPORT_VERSION "1.1.0"
#define VIEWPORT_PANEL_WIDTH 800
#define VIEWPORT_PANEL_HEIGHT 480