src.nth.io/

summaryrefslogtreecommitdiff
path: root/main
AgeCommit message (Collapse)AuthorFilesLines
3 daysrelease: v1.4.0v1.4.0Luke Hoersten1-1/+1
3 daysmain: advertise mac in mDNS TXTLuke Hoersten1-0/+5
Stable identity for the Scrypted plugin's discovery/auto-heal: the viewport name is user-editable and the IP can renumber, but the MAC is fixed. Discovery matches on it to relocate a device after a DHCP change.
3 daysmain: code-review fixes — brightness units, OTA stall cap, stats + lockingLuke Hoersten14-102/+143
- 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-<name> 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->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.
5 daysrelease: v1.3.2v1.3.2Luke Hoersten1-1/+1
Tear-free display path and thermal visibility: - display: triple buffering + scan tracking via on_refresh_done — decode target is never the scanning or pending fb, so the flip-vs-scan tear (measured on ~6% of painted frames at full stream rate) is impossible by construction, with zero added latency. /state tear_guard_engaged counts averted frames. - temp: on-die TSENS reported as /state temp_c, on the info overlay, and on the Scrypted per-stream stats line. - screens: INFO_MAX_LINES 16 -> 20 (temp line was silently capped). - docs: README Display strategy rewritten for the triple-buffer model; new TCP window + EMAC tuning section. Set VIEWPORT_VERSION and scrypted/package.json to 1.3.2.
5 daysscreens: raise INFO_MAX_LINES 16 -> 20 (temp line was silently dropped)Luke Hoersten1-1/+3
The info overlay already had exactly 16 ADD lines; the temp line added in f59cea3 was the 17th, and the ADD macro's bounds guard drops overflow lines without any diagnostic — so temp never rendered. Give the cap headroom and document the silent-drop behavior at the definition.
5 daystemp: report on-die temperature in /state and the info screenLuke Hoersten6-3/+70
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.
5 daysdisplay: tear-free frame path via triple buffering + scan trackingLuke Hoersten3-23/+101
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 -> 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.
5 daysrelease: v1.3.1v1.3.1Luke Hoersten1-1/+1
Stream throughput tuning, measured on hardware (kitchen panel): wire 53 -> 74 Mbps, per-frame recv 30.5 -> 21.4 ms, painted fps 19.8 -> 23.6, g2g 73 -> 62 ms, sender backpressure 61% -> 47%. - lwip: TCP_WND 5760 -> 23040 (16 x MSS), RECVMBOX 6 -> 32 - eth: EMAC RX DMA pool sized above the TCP window (1600B x 24); below-window pool caused silent burst tail-drop + ~200-400ms sender RTO stalls - stream: TCP-window decomposition instrumentation (wire kbps, hdr_gap, pend_age) in the window log and /state; connect log stamps TCP_WND/MSS/RECVMBOX Set VIEWPORT_VERSION and scrypted/package.json to 1.3.1.
5 daysstream: instrument TCP-window decomposition (wire kbps, hdr_gap, pend_age)Luke Hoersten3-4/+117
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->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.
2026-07-01release: v1.3.0v1.3.0Luke Hoersten1-1/+1
Unify firmware and plugin versions for the production release. Highlights since v1.1.0/1.2.0: - event wake fixed (self-healing camera-listener re-attach after the reload/ add storage race); doorbell/motion/person confirmed on hardware - stop() fully tears down (stop+start == fresh load); stale childId pruning; live-stream events ignored (no queue/relaunch) - triggers default to person+doorbell; doorbell hidden for non-doorbell cams - cold-start (wake -> live video) ~6s -> ~0.7s: prebuffered substream by id + request >= GOP + burst-friendly ffmpeg flags; snapshot removed - firmware: Loading screen on new stream connection (no stale-frame flash) - logging trimmed for chatty cameras - README/protocol docs rewritten for the TCP-socket streaming model Set VIEWPORT_VERSION and scrypted/package.json to 1.3.0.
2026-06-30firmware: clear panel to Loading screen on new stream connectionLuke Hoersten1-0/+15
The panel used to hold its last framebuffer during the connect→first-frame gap, flashing a stale old frame before video appeared — more visible now that the Scrypted side no longer sends a bridging snapshot. The wake path already paints the Loading screen, but state_machine_set(AWAKE) is a no-op when the device is already awake (e.g. a stream restart), so the clear didn't happen there. Tie the clear to the stream connection instead: recv-task paints local_screens_show_loading() once per new conn_id, guarded by the decoder lock so the RGB565 present can't race a trailing decode-task paint, and gated on AWAKE so it never draws on a sleeping panel.
2026-06-20firmware: split stream recv into its own task with 3-buffer ping-pongLuke Hoersten5-246/+416
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.
2026-06-20firmware: recv-throughput instrumentation (FIONREAD pre-body, recv() ↵Luke Hoersten3-6/+104
call/chunk stats, SO_RCVBUF probe) 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.
2026-06-20firmware: OTA firmware updates via POST /firmware + rollbackLuke Hoersten6-5/+242
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.
2026-06-20firmware: live-update last_paint_event_us_low so /state g2g reflects real ↵Luke Hoersten1-1/+12
frame age Previous Phase 4 implementation only published last_paint_event_us_low into the windowed-stats snapshot at every 30-frame roll (~1.5s at 20fps). Combined with the script's 5s /state poll cadence, the "freshest frame age" we could compute was up to ~6.5s stale before any actual lag — meaningless as a perf signal. Update s_stats.last_paint_event_us_low under portMUX on every painted v1 frame. The other window stats (recv/dec/paint/idle min/avg/max + fps/MBps) keep their roll cadence because they genuinely need a full window to compute; this single u32 just gets overwritten with the most recent value each paint. portENTER_CRITICAL on the ESP32-P4 is ~half a microsecond per side — at 20fps that's 20µs/s of overhead, immeasurable next to the 30-40ms recv per frame. Expected: g2g during a saturated stream drops from the previous 2-6s reading to single-digit hundreds of ms or lower, reflecting the actual emit→display lag.
2026-06-20phase 6: performance review playbook in TESTING.md + UDP-vs-TCP rationale in ↵Luke Hoersten1-0/+23
stream_server Two pieces of documentation, neither changes behavior: 1. TESTING.md gains a "Performance review playbook" section: per- session capture commands for firmware serial + /state poll + Scrypted console, an annotated walkthrough of what each log shape means, an investigation-threshold table mapping user-facing symptoms to likely causes and the first thing to check, and a one-line tools list. Replaces the implicit "ask the maintainer how to debug" loop with a reproducible workflow. 2. stream_server.c gains a "Why TCP and not UDP" header comment documenting the analysis from the design phase. JPEGs are ~123 IP datagrams at 1500 MTU; on hardwired Gigabit LAN switch-fabric loss is < 1e-9/packet → per-frame corruption ≈ 1.2e-7. UDP's theoretical wins (no Nagle, latest-wins semantics) don't apply because TCP_NODELAY is on, socket.write p50 < 1ms, and the FIONREAD trick already implements latest-wins on the receive side. UDP's costs (200-400 LOC of app-layer fragmentation, loss of nc/curl debug, FIONREAD trick stops working under fragmentation) are real. Documented as reference so a future contributor doesn't re-derive the analysis from scratch.
2026-06-20phase 4: glass-to-glass via 16-byte stream header + /state stream statsLuke Hoersten4-10/+158
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.
2026-06-20cleanup phase 2: delete HTTP-streaming-era dead codeLuke Hoersten1-121/+21
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.
2026-06-20cleanup phase 1: stale-comment refresh, zero behavior changeLuke Hoersten2-51/+52
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.
2026-06-20release: v1.0.0 — streaming pivot completev1.0.0Luke Hoersten1-1/+1
First milestone where the device runs the way it was intended end- to-end: - Raw-TCP streaming data plane at ~14-20 fps painted, sub-ms socket writes, no Nagle/ACK pathology. - HTTP control plane (/state, /config) plus HTTP /frame for the one-shot snapshot fast path on wake. Snapshot via sharp → ~700ms first paint from a cold trigger (down from ~1000ms+ via ffmpeg). - Firmware-side "always paint the latest" — FIONREAD skip drops superseded frames before decode, keeping glass-to-glass tight even when upstream produces faster than we can ingest. - HW JPEG decode + zero-copy paint into the panel back framebuffer. - 12 fps goal hit cleanly at q:v 1 (visually lossless). Source pinned to medium-resolution substream so the camera supplies enough frames to actually fill the pipe. - Scrypted side: per-host node:http keep-alive Agent with NODELAY, the cascading sharp → mediaManager → ffmpeg snapshot transform, wake-trigger picker on the new-device dialog, and the leaked- setInterval-across-script-reloads bug squashed. Bumping VIEWPORT_VERSION 0.1.0 → 1.0.0 so the boot log and info screen reflect it.
2026-06-20"paint the latest, drop the rest" — FIONREAD skip + Scrypted ↵Luke Hoersten1-0/+14
backpressure-blind + lwIP TCP window bump Three changes that work together to make the stream "always paint what's freshest, never sit on a stale frame": #1 — Firmware FIONREAD skip in stream_server Right after the body of frame N comes off the wire (and before we unlock the decoder + spend ~6ms on decode + paint), check the kernel receive buffer with ioctl(FIONREAD). If at least one more header (8 bytes) is queued, frame N is no longer the freshest possible — skip its decode + paint and loop back to read frame N+1. The TCP recv cost is unavoidable (bytes still have to cross the wire) but the decoder + paint cost is saved on every superseded frame. Glass-to-glass latency on the latest frame drops by however many frames had backed up. #2 — Scrypted: keep writing past kernel-buffer backpressure Previously: when sock.write() returned false we dropped the next ffmpeg frame at source. New: we keep writing through. Node buffers internally; under our load (~16 MB/s ffmpeg → ~5-7 MB/s firmware) the buffer rarely exceeds a frame or two. With the firmware now silently skipping decode on backed-up frames (#1), excess frames get shed for free on the device side. Scrypted's job is just to hand the firmware the freshest bytes as fast as possible. socketBackpressured is still tracked for the diagnostic log. #3 — lwIP TCP window bump (revisiting earlier regression) The previous attempt at LWIP_TCP_WND_DEFAULT=32k regressed under HTTP because every /frame opened a fresh socket and we paid the slow-start cost repeatedly. The streaming pivot eliminated that: the socket is long-lived, slow-start runs exactly once, then we ride the full window for the rest of the session. Bumping to 65535 (max for stock lwIP), SND_BUF to match, RECVMBOX to 16, SACK on. Expected: recv throughput ceiling moves up from ~5.3 MB/s, which directly raises the fps ceiling (recv is currently 37ms of the 43ms per-frame total).
2026-06-20faster snapshot + deeper firmware timing + camera substream UI controlLuke Hoersten1-14/+67
Three independent improvements landing together because they all target the post-streaming-pivot "where do we spend the wall clock?" question. #1 — Snapshot: sharp → mediaManager native → ffmpeg cascade pushSnapshot now tries three transforms in order of cost: - sharp (~5-15ms, libvips bindings, handles resize + rotate) - mediaManager.convertMediaObjectToBuffer with image/jpeg;width=W ;height=H mime hint (~10-30ms, Scrypted's native converter, used only for landscape since rotation isn't standard) - ffmpeg one-shot (~500-700ms cold start, the old slow path) A `path=...` field in the snapshot log identifies which transform actually ran so the user can confirm the fast paths are reachable in their Scrypted runtime. takePicture, transform, and POST timings are each broken out so we can see exactly where the snapshot wall goes. #2 — Firmware: windowed min/avg/max breakdown + idle-gap Stream server replaces the per-frame single-sample log with a 30- frame window summary: N frames over Xs: Yfps Z MB/s avg-jpeg=KB | lock min/avg/max | recv min/avg/max | dec min/avg/max | paint min/avg/max | idle min/avg/max - lock = mutex acquire time (sanity check; should be ~0us with one client owning the decoder) - recv = body bytes off the wire - dec = HW JPEG decode - paint = backbuffer flip + DMA queue - idle = gap between previous paint completing and next header landing (= upstream slack). Large idle means we're waiting on ffmpeg/network; near-zero means we're the bottleneck. #3 — Camera substream picker "Stream-source choice drives end-to-end latency more than anything else" — the existing hardcoded low-latency-first walk lands on the camera's preview substream which is typically capped at 5-8 fps. Adds a per-viewport setting under Display: Camera substream: auto | low-resolution | medium-resolution | local | remote | remote-recorder auto keeps the current behavior; the pinned options let the user force a higher-fps source when they want stream rate > preview rate. The chosen destination is logged at stream start.
2026-06-19streaming pivot: raw TCP data plane, drop per-frame HTTP entirelyLuke Hoersten3-1/+269
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).
2026-06-19firmware: build fixes — esp_app_format component + forward-declare ↵Luke Hoersten2-14/+9
s_last_painted_seq Two build breaks discovered when actually compiling the prior commits: - main/CMakeLists.txt missing esp_app_format requirement, so esp_app_desc.h couldn't be resolved when app_main.c included it for the boot-time git-hash log. - s_last_painted_seq is referenced inside state_post_handler (reset to 0 on /state wake) but the static was declared further down the file alongside the other /frame state. C requires declaration before use — forward declare it above state_post_handler and keep a stub comment at the original location.
2026-06-19disable Nagle on /frame socket + add min/max/worst-frame to script logLuke Hoersten1-0/+14
#1: TCP_NODELAY for /frame ESP-IDF lwIP defaults Nagle ON. /frame is the worst Nagle workload: ~140KB body POST followed by an empty 204 response, no follow-up data either way. Both the final partial-MTU body packet and the response packet can sit in the kernel send buffer up to 40ms waiting for an ACK that the other side is delaying-ACKing — silently adding tens of ms to every frame's wall time and showing up as a fat net_up bucket on the Scrypted side. setsockopt(TCP_NODELAY) at handler entry on every /frame. Cheap and idempotent. Includes the /state and /config sockets too — those are infrequent but small responses also benefit. #2: min/max + worst-frame decomposition in the Scrypted log The p50/p95 line answered "what's typical" but not "what happened in the worst frame this window". Now every 10-fetch log adds: - min and max columns alongside p50/p95 per bucket - a worst-frame breakdown row: identifies the single slowest fetch in the window and decomposes its wall time across all stages. Answers "did the slow frame get gated by emit→post (ffmpeg backed up), net_up (TCP/handshake spike), or fw_dec (large JPEG)?" directly, instead of us inferring from percentiles. Together these are the prerequisites for evaluating whether further optimization (HTTP keep-alive, chunked streaming) is worth pursuing.
2026-06-19unified end-to-end per-frame instrumentation + version stampsLuke Hoersten2-1/+23
Cross-side timing was opaque: script saw "req=70ms" but couldn't split TCP/dispatch overhead from firmware decode time, and firmware serial logs and Scrypted console logs couldn't be correlated. Firmware /frame handler now emits Server-Timing on every response with per-stage breakdown: Server-Timing: recv;dur=X, dec;dur=Y, paint;dur=Z, post;dur=W, handle;dur=total Script parses it, joins by X-Frame-Seq implicitly (one POST per seq), derives net_up = req − fw_total, and logs a multi-line p50/p95 breakdown every 10 fetches: fetch "kitchen" #10 (jpeg=137KB) wall p50=65ms p95=140ms emit→post p50=0ms p95=2ms (queue wait) req p50=62ms p95=135ms (fetch → Response headers) net_up p50=43ms p95=110ms (TCP + body wire + dispatch) fw_recv p50=12ms p95=18ms (body off the wire) fw_dec p50=6ms p95=8ms (hardware JPEG) fw_paint p50=0.1ms p95=0.2ms (backbuffer flip) fw_post p50=0.4ms p95=0.6ms body-read p50=1ms (drain — empty body) inflight d0=8 d1=2 d2=0 stale-drops=0 Plus version stamps on both sides for the "is the user on the right code" question: - CMakeLists: PROJECT_VER = git short hash + -dirty marker if dirty. esp_app_get_description()->version exposes it at runtime. Boot log: "Scrypted Viewport boot (v0.1.0 build=4cf36e2-dirty)". - TS: SCRIPT_VERSION const at the top, bumped per commit, logged at script-eval: "Scrypted Viewport up (script=4cf36e2). Callback ..."
2026-06-15add X-Frame-Seq monotonic ordering for pipelined /frame POSTsLuke Hoersten1-1/+44
With two /frame POSTs in flight on separate sockets, the firmware's JPEG decoder mutex serialises decode but FreeRTOS semaphore acquisition is not FIFO — under jitter the later-arriving older frame can grab the lock first and paint over the newer one, producing brief "panel travels backward in time" glitches. Scrypted side: - Per-viewport monotonic frameSeq counter, sent as X-Frame-Seq on every /frame POST. Reset on stopStream so each new stream starts at 1. Firmware side: - s_last_painted_seq tracks the highest seq we've painted. Frame arrives, mutex acquired, body received — if seq <= s_last_painted_seq the frame is dropped (200 OK + X-Frame-Drop: stale-seq header) and the mutex released without touching the back buffer. - s_last_painted_seq resets to 0 on POST /state {wake} so the next stream's seq=1 isn't rejected because the previous session reached a higher counter. - Missing/zero X-Frame-Seq (legacy clients) skips the check entirely — preserves pre-pipelining behaviour.
2026-06-15pipeline two /frame POSTs + HTTP keep-alive on the Scrypted sideLuke Hoersten1-0/+6
Decouples network upload from firmware decode-and-paint. Before, the inFlight boolean guard meant Scrypted sent frame N, waited for the full ~80ms response (~40ms body upload + ~20ms decode + ~50µs paint + ack), THEN sent frame N+1. The next ffmpeg frame arriving during that window got dropped. After: - ScryptedViewportProvider keeps a per-host undici Agent with keepAliveTimeout 30s, connections:2, pipelining:0. Sockets stay open across /frame, /state, /config — saves the SYN+SYN-ACK+ACK round-trip every POST (small on LAN but real on Wi-Fi). - Stream loop's inFlight boolean is now a counter capped at 2 so frame N+1 can begin uploading on socket B while frame N is still being decoded on the device via socket A. Roughly doubles effective throughput when body upload time dominates. Firmware side: - esp_http_server max_open_sockets bumped 7→4 explicitly: 2 for pipelined /frame + 2 spare for concurrent /state and /config slots. The JPEG decoder mutex still serialises decode (only one /frame can be decoding at a time); this change only unblocks the network half of the pipeline.
2026-06-15instrumentation: firmware idle gap between frames + Scrypted per-fetch ↵Luke Hoersten1-5/+22
wall-clock
2026-06-15firmware: double-buffer the panel + zero-copy decode → ~22 fps ceilingLuke Hoersten5-35/+92
Per-frame paint cost drops from ~24 ms to ~45 µs (≈500× faster) by enabling num_fbs=2 on the DPI panel and decoding straight into the back framebuffer. Measured on the bench: before: lock=7us ttfb=370us body=40ms dec=6ms paint=24ms post=35us = ~70ms / ~14fps after : lock=7us ttfb=330us body=38ms dec=6ms paint=42us post=25us = ~45ms / ~22fps How the win actually lands: - num_fbs=2 in the esp_lcd_dpi_panel_config_t makes the IDF driver allocate two framebuffers and stream from one while we fill the other. - display_back_buffer() returns the inactive fb pointer + its size. - jpeg_decoder_decode() now accepts a caller-provided destination buffer instead of owning its own scratch. http_api passes the panel back-fb so the hardware JPEG decoder writes BGR888 pixels straight into where the DSI will eventually scan from. Zero memcpy in the hot path. - display_flip_back_buffer() calls esp_lcd_panel_draw_bitmap with the fb pointer. Because the buffer is inside the panel's own fb range, the IDF driver skips its memcpy and just does a cache writeback + swaps cur_fb_index. The actual flip happens on the next vsync, asynchronously — the call returns in microseconds. The remaining ceiling is network body time (~38 ms for ~210 KB JPEGs) and the hardware decoder (~6 ms). Per-viewport JPEG quality (smaller files = shorter body) is the next lever; everything firmware-side is already at or near floor. Also drop the old static jpeg output scratch + JPEG_DECODER_MAX_OUTPUT_BYTES constant — nothing references them anymore.
2026-06-15scrypted: don't reset idle timer on each painted frame + finer timingLuke Hoersten1-8/+23
Two fixes plus a README refresh: 1. scrypted: pushStreamFrame previously reset the per-stream idle timer on every successful /frame response. That made the timer anchored to "frames are flowing" rather than to "the camera event that triggered the stream", so a continuously-streaming source would never let the stream time out. Removed the reset. The startStream → stopStream(false) cancel-and-replace path on repeated events still keeps the stream alive while the event keeps firing; idle (no new events) now actually ends the stream at idle_timeout_ms. 2. firmware: break the previous coarse recv/dec/paint timing into lock : try_lock returned ttfb : first httpd_req_recv chunk landed body : remaining bytes received dec : hardware JPEG decode paint : esp_lcd_panel_draw_bitmap returned post : state-counter bookkeeping + unlock Logged every 10 frames at INFO. Splits the previously-fat recv bucket into TCP/HTTP handshake overhead (ttfb) vs wire-time (body), and surfaces any tail bookkeeping cost. 3. README: replace the stale "5 fps ceiling caused by CPU RGB conversion" guess with the actual measured per-phase budget and re-rank the backlog accordingly. Double-buffering the panel (paint 24 ms → ~2 ms) is now the highest-value next move; the previously-listed DMA-2D rewrite is moot because the CPU loop is already gone.
2026-06-15http_api: log per-frame timing breakdown every 10 frames (recv / decode / paint)Luke Hoersten1-0/+21
2026-06-15firmware: zero-copy JPEG → BGR888 → DSI hot path; Scrypted pre-rotatesLuke Hoersten5-19/+48
Architectural rework of the /frame hot path. Scrypted now ships every JPEG already scaled + rotated to the panel's native dimensions (read from the firmware's /state response so nothing is hardcoded on the Scrypted side); the firmware decodes the JPEG straight into a BGR888 buffer that's directly draw_bitmap'able by the DSI driver, with zero CPU pixel work and zero rotation work in between. Firmware - jpeg_decoder now uses JPEG_DECODE_OUT_FORMAT_RGB888 + JPEG_DEC_RGB_ELEMENT_ORDER_BGR. Output buffer sized for 800*480*3. - New display_present_bgr888() is a one-liner that hands the decoder's output straight to esp_lcd_panel_draw_bitmap. - /frame handler validates dimensions against the panel-native VIEWPORT_PANEL_WIDTH x VIEWPORT_PANEL_HEIGHT (was effective_dims branching on orientation). Returns 400 if it's anything else. - /state JSON adds panel_width + panel_height so Scrypted can read them without hardcoding board-specific knowledge. - display_present_rgb565 + s_rot_buf stay for the local-screens cold path (info screen, loading) which still does its own CPU conversion + rotation — infrequent enough that it's not worth the rewrite. Scrypted - startStream() GETs /state at stream-start time, caches panel_width and panel_height in viewport storage, and uses them as the ffmpeg scale target. Falls back to cached or 800x480 if /state is mid-reboot. - For portrait viewports the ffmpeg pipeline now does scale=H:W:flags=lanczos,transpose=1 so the JPEG arrives pre-rotated 90° CW into panel-native dimensions. Landscape is just scale=W:H. - No more in-firmware rotation; Scrypted is the single source of truth for "how do I get this camera frame into a panel-shaped JPEG". Expected ceiling lift: ~5 fps → ~10 fps, gated by the JPEG decoder hardware throughput instead of the CPU rgb565→bgr888 + rotation loop.
2026-06-14state_machine: wait one panel-refresh after paint before lighting backlight ↵Luke Hoersten1-5/+9
(33 ms)
2026-06-14state_machine: paint placeholder before wake — no more stale-frame flashLuke Hoersten1-2/+4
On wake, display_wake() turned the backlight on first and then the local_screens_show_loading / show_info call repainted the framebuffer. For a few ms the user saw the previous /frame's contents (the last camera snapshot from the prior wake cycle) before the new screen landed. Swap the order: paint the placeholder first, then turn the backlight on.
2026-06-14Default name = MAC; brief wake on boot; drop dead touch defencesLuke Hoersten10-107/+70
- Seed viewport_name with the full base MAC, colons stripped (e8:f6:0a:e0:90:94 → "e8f60ae09094"). Stable across reboots, globally unique, 12 alphanumeric chars — well inside the mDNS hostname limit even with the "viewport-" prefix. - Add a mac_str field to viewport_state and expose it as the new "mac" key in GET /state and as a "mac" line on the info screen. - "Configured" no longer requires a viewport_name (it always has the MAC default); the scrypted URL alone gates outbound POSTs and the loading-vs-info screen choice. - Strip viewport_name[0] fallbacks in http_api / mdns / local_screens now that the field is never empty. - Replace the wake-on-boot-stays-on behaviour with a ~600 ms flash followed by sleep — long enough for the FT5426 touch IC to come out of its initial unresponsive state, short enough that an idle device doesn't burn the backlight. - Drop the touch defensive cruft added when we were chasing a PoE-power theory: the DEV_MODE=0x00 write at init, the touch_reset_pulse() call at init, and the runtime wedge-self-heal in the polling loop didn't actually fix anything — the wake-on-boot flash did. Also delete the display_touch_reset_pulse() helper since it now has zero callers.
2026-06-14touch: wake on boot — FT5426 only reports while LCD is streaming + litLuke Hoersten2-32/+57
Symptom: after a few cleanup cycles + power cycles, fresh boots left touch dead — the FT5426 ack'd, polling read TD_STATUS=0x00 cleanly, but taps never registered. Curl-waking the screen made touch work again instantly; tapping the dark backlight-off panel did nothing. Root cause: on this panel the FT5426 firmware only reports contact points while the DSI link is actively streaming with the backlight on. Booting straight into ASLEEP per the spec leaves taps silently unread, and there's no built-in way to wake without touch (no hardware button on this board — see prior commit). The fix is to wake on boot; the idle timer cuts back to sleep after idle_timeout_ms if nothing happens. Also defensively write DEV_MODE=0x00 (Working Mode) at touch_init, and add a runtime self-heal: if the polling loop sees stuck TD_STATUS above the 5-touch ceiling for ~3 s, pulse PC_RST_TP_N via the panel MCU and retry. Combined with display_touch_reset_pulse() the chip recovers from cold-boot wedges without manual intervention.
2026-06-14touch: retry + hard-reset when FT5426 boots into wedged 0xff stateLuke Hoersten3-2/+48
After a long bench session of taps + power cycles, the FT5426 on this unit started returning dev_mode=0xff on every read. The polling loop interpreted that as 15 simultaneous touches forever (td_status bits 0..3 all set), wedged `was_down=true` permanently, and no tap or long-press ever fired — the screen looked dead. Two layers of defence in touch_init: 1. retry the dev_mode read up to 10 × 30 ms in case the chip is just slow to come up after PC_RST_TP_N is released. 2. if still stuck, pulse PC_RST_TP_N via a new display_touch_reset_pulse() helper (drop reset for 20 ms then release + 50 ms settle), then retry the read another 10 × 30 ms. Plus a polling-loop sanity check: an FT5x06 supports max 5 simultaneous points, so a TD_STATUS lower-nibble value > 5 is bogus and the poll cycle is skipped. That keeps a wedged chip from producing phantom "15 touches" reports if it slips into 0xff during runtime. If both attempts still report 0xff, we log loudly and let the polling task run — it harmlessly drops cycles via the >5 sanity check. Wake still works via POST /state from Scrypted; this only disables touch gestures on a hardware-stuck boot.
2026-06-14Tidy: delete dead code, inline single-use helpers, fix double-lockLuke Hoersten12-215/+89
Code-review pass after the M5 colour fix. -126 net lines (-215 / +89). Dead code removed: - display_fill, display_test_pattern — M3 bring-up self-tests, no callers - net_eth_is_up — no callers - nvs_config_reset — no callers (factory-reset gesture was removed earlier; NVS wipe now goes through `idf.py erase-flash`) Helpers inlined or collapsed (each had one call site): - state_name / orientation_name — ternaries at the cJSON site - fmt_bytes / fmt_uptime — inline scope in local_screens_show_info - expected_dims (http_api) + effective_dims (local_screens) — merged into viewport_state_effective_dims() in viewport_state, one canonical source state_machine cleanup: - arm_idle_timer_unlocked / disarm_idle_timer collapsed to one arm_idle_timer(ms) that takes the snapshotted timeout (ms==0 disables). Removes the misleading name and the second viewport_state lock acquisition per painted frame. - state_machine_set + state_machine_frame_painted now snapshot state + idle_ms under one lock. mdns_service cleanup: - snapshot_state / apply_hostname / apply_txt collapsed into a single apply_state(include_hostname) helper; mdns_service_start grabs hostname once for hostname_set + log instead of going under the lock three times. http_api cleanup: - POST /config brightness-changed path uses the local `bright` it already validated instead of re-locking + re-reading st->brightness. - Trimmed verbose bring-up comments (stack-size justification, M5 saga, DSI shadow struct) to one line each — kept the load-bearing facts. Smoke-tested on hardware: boot clean, display + JPEG + touch all up ([DJT]), info screen renders, no crashes.
2026-06-14Drop VIEWPORT_STATE_UNCONFIGURED — state is just awake/asleepLuke Hoersten6-31/+14
\`state\` now reports only the screen's runtime state (awake or asleep). Whether a viewport is set up to talk to Scrypted is a separate \`configured\` flag, derived from \`viewport_name && scrypted_url\`. There's no third state. Behaviour changes: - POST /state always succeeds; the previous 409 "device unconfigured" path is gone. The screen toggles regardless of /config status. - POST /config now sets \`configured\` directly from the derived predicate instead of mutating the state enum. - Outbound state-client POST to Scrypted is still gated on a scrypted URL being present — that's the only thing the configured flag now actually controls in the runtime path. GET /state JSON unchanged in shape, but \`state\` is now never "unconfigured" — that's reported through the existing \`configured\` boolean instead.
2026-06-14M5 colors fixed: JPEG byte order + DPI memory layoutLuke Hoersten2-10/+13
Two channel-swap bugs were stacked, hiding each other on symmetric pixels (white text on black background) and showing up only on saturated colour JPEGs: 1. jpeg_decoder: rgb_order was JPEG_DEC_RGB_ELEMENT_ORDER_RGB which empirically emits the RGB565 word in big-endian byte order (the IDF header comment "color component in big endian" is correct; the enum's "RGB" name is misleading). Our painter reads native LE uint16_t, so the bytes were always swapped. Flipped to _BGR which emits LE — verified by dumping decoded bytes for solid red/green/blue JPEGs. 2. display: rgb565_to_rgb888 wrote bytes [R, G, B], but the ESP32-P4 DSI engine + TC358762 + Pi panel expect [B, G, R] in memory despite the "RGB888" label. (MIPI DSI defines bit order, not byte position in memory.) Verified with an embedded solid-blue test JPEG: with [R, G, B] the panel showed solid red; with [B, G, R] it shows solid blue. Diagnosed by embedding a 480x800 blue test JPEG in firmware, decoding + dumping the buffer at boot, and reading the actual bytes over USB serial (ethernet was disconnected mid-investigation). The temporary self-test + debug dump are removed; the fixes themselves are tiny.
2026-06-14Info screen: full /config + /state dump, replaces identity screenLuke Hoersten6-61/+165
Previously the 4-line "identity" screen showed only viewport name, mDNS host, IP, and state. Expanded to 15 lines covering the full GET /config + GET /state output (name, host, ip, state, configured, scrypted, orientation, brightness, idle, fw, uptime, frames, errs, free heap, free PSRAM), label/value pairs left-aligned with auto-scaled font. Renames "identity" → "info" throughout — symbol, log messages, README/TESTING references. Also: - Move the info-screen render's big locals (~1.6 KiB: lines[16][80] + scrypt[256] + vp_name[64]) to BSS. The touch task's 3 KiB stack was overflowing on every long-press, leaving whatever frame was previously on the panel — gave the appearance of a "blue screen" after the M5 test pattern. - Drop the ≥5 s touch factory-reset gesture and remove all stale references to it in docs and comments. NVS wipe is now a USB-side `idf.py erase-flash` operation only. - M5 follow-up TODO in jpeg_decoder.c: solid green renders ~black and solid blue renders green; not a byte-order issue (BGR setting turns red into blue). Tracked as a known M5 gap.
2026-06-14touch: drop factory-reset gestureLuke Hoersten1-22/+5
Holding the touch panel for 5 s to wipe NVS is dangerous (easy to trigger by accident — phone resting on the panel, kids, etc.) and useless as a recovery path (factory reset alone doesn't undo a bricked config; a USB reflash does). Factory reset should be an explicit API or USB-side action. Touch gestures now: tap = toggle wake/sleep, ≥1.5 s hold = identity overlay.
2026-06-14http_api: bump httpd stack to 8 KiB — POST /config was overflowingLuke Hoersten1-0/+5
POST /config has ~2.4 KiB of stack locals (2 KiB body buffer + the scrypted URL + viewport name + cJSON parser frames) which overran the default 4 KiB httpd task stack and tripped the stack-protect canary mid-handler. The request body was applied to RAM + NVS, but the handler crashed before sending the response, so curl saw a connection reset and the device rebooted into "Stack protection fault". M4 + M6 ✅ verified 2026-06-14 after the bump: - POST /config full / partial → 204; survives reboot via NVS - 5 validation failure modes → 400 - POST /state wake/sleep → 204; idempotent repeats → 204 - POST /frame while asleep → 409 with expected body - idle timer fires after idle_timeout_ms; 0 correctly disables
2026-06-14M3 done: drop boot test, fold BOOT button into touch long-pressLuke Hoersten5-165/+66
The Waveshare ESP32-P4-ETH board exposes BOOT on GPIO 35, but at runtime that pin is owned by the EMAC TXD1 signal. No usable GPIO is wired to a separate user button, so the BOOT-button task could never fire. Move both BOOT-button behaviours onto the touch panel: - ≥1.5s hold → 15s identity overlay (was BOOT short-press) - ≥5s hold → factory reset (was BOOT long-hold) Short tap (<500ms) still toggles wake/sleep. Long-press fires while the finger is still down so the user gets immediate feedback at each threshold. Also strip the R/G/B 6 s boot test sequence — the panel now renders correctly, so it's no longer useful diagnostically. Boot subsystem flags drop the trailing B column: [EMHDJT].
2026-06-14M3: panel renders — TC358762 bridge init + non-burst DSILuke Hoersten2-135/+222
The Hosyond 5" panel uses Pi 7" v1.1 architecture (ATTINY ID 0xC3 + TC358762 DSI-to-DPI bridge). Getting it to paint needed three things that ESP-IDF doesn't expose: 1. TC358762 bridge configuration — 16 register writes via DSI Generic Long Write (DT=0x29) packets, transcribed from Linux's tc358762.c. 2. Non-burst video mode — IDF hardcodes BURST_WITH_SYNC_PULSES, but TC358762 requires NON_BURST_WITH_SYNC_PULSES. Overridden via the mipi_dsi_host_ll_* API after esp_lcd_new_panel_dpi() and before panel_init(). 3. ATTINY v1.1 power-on sequence + SPI-proxy bridge wake. The legacy REG_POWERON(0x85) path doesn't apply to 0xC3 firmware; instead write PORTC/PORTA/PORTB/PORTC in order, then later release bridge reset and proxy-write TC358762 SYSPMCTRL=0 through the ATTINY's ADDR_H/L + WR_DATA_H/L registers. Reaching the LL/HAL APIs requires shadowing esp_lcd_dsi_bus_t so we can pick the mipi_dsi_hal_context_t out of the private struct. Documented the layout dependency at the shadow definition. Tuned config (observed stable on ESP32-P4 per embenix's reference): - 1 data lane @ 600 Mbps - DPI 26 MHz (Linux modeline is 25.98) - timings HSW=2 HBP=46 HFP=210 / VSW=20 VBP=4 VFP=22 - RGB888 end-to-end, R,G,B byte order (BGR was wrong) - disable_lp=0 so LP windows are available for the bridge writes
2026-06-14M3 WIP: DSI bring-up + state machine + identity overlayLuke Hoersten9-133/+282
DSI / panel: - LDO_VO3 acquired at 2500 mV (VDD_MIPI_DPHY) — without this the PHY PLL busy-waits forever inside esp_lcd_new_dsi_bus. - PSRAM bumped to 200 MHz (CONFIG_IDF_EXPERIMENTAL_FEATURES) to keep the DPI fed without underruns. - Pi-7"-style 800x480 panel init (REG_POWERON, PORTA/PORTB/PWM/PORTC) + 16-bit RGB565 framebuffer with portrait-mode rotation buffer in PSRAM. Currently shows garbled output — known issue, next commit switches to the TC358762-bridge-aware init sequence. State + UX: - viewport_state simplified to AWAKE/ASLEEP only; "unconfigured" is a flag, not a state. Tap always toggles; content choice (identity vs frame) is driven by the configured flag. - BOOT button arms a 15 s identity overlay via local_screens_overlay; expired callback returns to prior state. - Boot-done indicator: two backlight flashes (no usable LED GPIO on the Waveshare board — GPIO 35 BOOT is shared with EMAC TXD1). - Best-effort subsystem init in app_main; display init deferred to its own task so a panel hang can't block networking. Display test pattern: solid R/G/B for 2 s each on boot to characterize the garble independent of text rendering.
2026-06-14M1+M2 hardware bring-up: 3 fixes + elegant subsystem degradationLuke Hoersten3-58/+104
First flash to real hardware (Waveshare ESP32-P4-ETH on USB power, no LAN cable, no panel attached) exposed three bugs and the need to degrade gracefully when peripherals are missing. 1. net_eth.c: RXD0 / RXD1 GPIO swap. ESP32-P4's EMAC iomux table (components/soc/esp32p4/emac_periph.c) fixes RXD0 to GPIO 29 and RXD1 to GPIO 30. The Waveshare-wiki/ESPHome research had them transposed. Symptom was: E (esp.emac.gpio): invalid RXD0 GPIO number E (esp.emac): esp_eth_mac_new_esp32 failed -> ESP_ERROR_CHECK abort Fix: swap the two #defines. CRS_DV / TXD0 / TXD1 / TX_EN / REF_CLK pinout was already correct. 2. mdns_service.c: mdns_service_add() rejects with INVALID_ARG when the hostname isn't set yet (see mdns_responder.c:771 — first guard in mdns_service_add_for_host). We were setting hostname AFTER service_add inside apply_records(). Restructure mdns_service_start to: init -> hostname -> service_add -> txt. apply_hostname() and apply_txt() helpers reuse the same snapshot under viewport_state's mutex; mdns_service_refresh() re-applies both. 3. app_main.c: every subsystem now best-effort instead of ESP_ERROR_CHECK abort. A single missing peripheral can't take down the rest of the firmware. End-of-boot summary line: boot complete — subsystems [EMHdJ-B] ip=(no link) Uppercase letter = up, lowercase = down. E=Ethernet M=mDNS H=HTTP D=Display J=JPEG T=Touch B=BootButton. Touch shows '-' when display didn't come up (it shares the panel I2C bus). On a stripped board (just ESP32-P4-ETH on USB, no panel, no LAN) the line above prints and the device serves /state, /config, and mDNS over the loopback; plugging Ethernet in later picks up DHCP without a reboot. Side-effects: the DHCP timeout in app_main shortened from 30s to 15s so a no-cable boot finishes quickly. Reaching the "ready" state with no link is fine — the driver keeps the link-up event handler armed and gets the IP whenever a cable appears. Verified on hardware (bare ESP32-P4-ETH, no LAN, no panel): - ESP-IDF v5.4.1, esp32p4, 32 MB PSRAM detected, 16 MB flash config (actual 32 MB silkscreen — keeping 16 MB until access-beyond-16MB support lands in flash driver). - Ethernet driver started — MAC e8:f6:0a:e0:90:94. - mDNS up advertising viewport.local on _scrypted-viewport._tcp:80. - HTTP up listening :80. - JPEG decoder ready. - Display reports "panel MCU @0x45 unreachable" — exactly correct for no-panel state. - Touch skipped as designed. - BOOT button registered on GPIO 0 (still a guess; harmless if wrong). This is enough to flip M1's "code" status to ✅ on hardware once Ethernet is plugged in. M2 (mDNS browse + GET /state) ready to verify.
2026-06-14Identity screen: name + hostname + IP + state, auto-scaledLuke Hoersten2-14/+86
local_screens_show_ip() now renders four lines instead of two — all the device's runtime identity at once: line 1: viewport name ("mudroom" / "viewport" if unconfigured) line 2: mDNS hostname ("viewport-mudroom.local" / "viewport.local") line 3: IP ("192.168.1.42" / "no network") line 4: state ("awake" / "asleep" / "unconfigured") Same screen is shown on first boot, after factory reset, and as a 15s BOOT short-press overlay — operator always has the device's full identity one button-press away (find it on the LAN, confirm name + configuration without curling). Font scale is auto-picked: the largest integer scale (1×–6×) where the longest line fits within 90% of width AND all four lines plus inter-line spacing fit within 90% of height. Works for both portrait (480×800) and landscape (800×480) without separate code paths. Font expanded to cover the new strings: - All lowercase a–z (added b, f, h, j, k, m, q, s, u, x, y, z). - Punctuation: dash, slash (digits, period, colon already covered). Fallback policy unchanged: unsupported chars render as blank, which keeps the table tight and the failure mode visible rather than crashing. README "Local rendering" + TESTING.md M8 updated to describe the four-line identity layout and show both the unconfigured-boot and configured-overlay example outputs. Build verified clean against ESP-IDF 5.4 (binary ~873 KB, +1.6 KB for the expanded font).
2026-06-13M8: local screens (IP + Loading) + BOOT buttonLuke Hoersten6-6/+345
local_screens.{h,c}: - Embedded 8x8 bitmap font sized to a 95-char table; only the glyphs used by today's strings (viewport.local, the IPv4 string, Loading...) are populated. Unsupported chars render blank. - 768 KB PSRAM scratch FB at panel-native dims. - local_screens_show_ip() — two centered lines: "viewport.local" <current IP> Scale 3x portrait / 4x landscape; centered vertically. - local_screens_show_loading() — centered "Loading..." at scale 4x/5x. - local_screens_restore_for_state() — repaint after BOOT-overlay expiry: UNCONFIGURED -> IP screen; AWAKE/ASLEEP -> black FB (next /frame or sleep handles the rest). - All paths go through display_present_rgb565() so orientation rotation is automatic. button.{h,c}: - Polling task at 30ms, active-low with internal pull-up. - Short press (<5s, released) → backlight on, IP overlay for 15s via esp_timer one-shot, then restore. - Hold ≥ 5s → nvs_config_reset() + esp_restart(). - PIN_BOOT_BUTTON = GPIO 0 is a TODO placeholder. ESP32-P4 strap pin GPIO35 is owned by RMII TXD1 at runtime so Waveshare must expose a separate user button on a free pin; confirm against the schematic. Fail-soft if mis-wired: input reads stuck-high and the task never fires. state_machine on AWAKE transition now calls local_screens_show_loading() right after display_wake() — so every wake (tap or POST /state) flashes a clear "Loading..." until the next /frame paints over it. app_main on boot: - Calls local_screens_init() after display_init(). - UNCONFIGURED → paint IP screen (replaces the M3 test pattern). - ASLEEP → backlight off (unchanged). - After all subsystems: button_init(). Build clean against ESP-IDF 5.4 (binary ~870 KB; the font/glyph data is under 800 bytes). TESTING.md M8 documents the visual checks, the BOOT-pin placeholder caveat, the AWAKE-overlay-overwrite behavior, the open follow-up about post-overlay backlight when ASLEEP, and the font fallback policy.