<feed xmlns='http://www.w3.org/2005/Atom'>
<title>luke/esp32-poe-scrypted-viewport/main/app_main.c, 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>
<entry>
<title>unified end-to-end per-frame instrumentation + version stamps</title>
<updated>2026-06-20T00:07:55+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T00:07:55+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=846e4dbfed188154332fa01058e13df8336f0476'/>
<id>urn:sha1:846e4dbfed188154332fa01058e13df8336f0476</id>
<content type='text'>
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()-&gt;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 ..."
</content>
</entry>
<entry>
<title>Default name = MAC; brief wake on boot; drop dead touch defences</title>
<updated>2026-06-15T01:11:21+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T01:11:21+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=865c4859d710870245ae7954e729edcaf442a921'/>
<id>urn:sha1:865c4859d710870245ae7954e729edcaf442a921</id>
<content type='text'>
- 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.
</content>
</entry>
<entry>
<title>touch: wake on boot — FT5426 only reports while LCD is streaming + lit</title>
<updated>2026-06-15T00:58:45+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T00:58:45+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=8e2de89efb540fd6a3dce2086216d3afc49e6831'/>
<id>urn:sha1:8e2de89efb540fd6a3dce2086216d3afc49e6831</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>Info screen: full /config + /state dump, replaces identity screen</title>
<updated>2026-06-14T22:37:51+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-14T22:37:51+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=cbc4904aa35191f59aff93df5ed36f09f4cfa943'/>
<id>urn:sha1:cbc4904aa35191f59aff93df5ed36f09f4cfa943</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>M3 done: drop boot test, fold BOOT button into touch long-press</title>
<updated>2026-06-14T21:41:03+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-14T21:41:03+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=c6ce4b3f46a1f1adf700306dadc9034a6a2ac0d3'/>
<id>urn:sha1:c6ce4b3f46a1f1adf700306dadc9034a6a2ac0d3</id>
<content type='text'>
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 (&lt;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].
</content>
</entry>
<entry>
<title>M3 WIP: DSI bring-up + state machine + identity overlay</title>
<updated>2026-06-14T20:53:33+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-14T20:53:33+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=d5cdb6b4e4424d80ac9bfd8e12c129334c23d6f2'/>
<id>urn:sha1:d5cdb6b4e4424d80ac9bfd8e12c129334c23d6f2</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>M1+M2 hardware bring-up: 3 fixes + elegant subsystem degradation</title>
<updated>2026-06-14T17:11:43+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-14T17:11:43+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=220ee4cdc9b372e2a2b41ee48471ad0893b3a68a'/>
<id>urn:sha1:220ee4cdc9b372e2a2b41ee48471ad0893b3a68a</id>
<content type='text'>
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
     -&gt; 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 -&gt; hostname -&gt; service_add -&gt; 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.
</content>
</entry>
<entry>
<title>M8: local screens (IP + Loading) + BOOT button</title>
<updated>2026-06-14T04:15:27+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-14T04:15:27+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=fdb8a1299eafdedf68902c4c785f9fb0390e80c4'/>
<id>urn:sha1:fdb8a1299eafdedf68902c4c785f9fb0390e80c4</id>
<content type='text'>
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"
    &lt;current IP&gt;
  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 -&gt; IP screen; AWAKE/ASLEEP -&gt; 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 (&lt;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.
</content>
</entry>
<entry>
<title>M7: touch + outbound /state POST to Scrypted</title>
<updated>2026-06-14T04:01:23+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-14T04:01:23+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=1d6cbb222ced226fcb482c0b19130774d181c8f8'/>
<id>urn:sha1:1d6cbb222ced226fcb482c0b19130774d181c8f8</id>
<content type='text'>
state_client.{h,c}:
- Worker task drains a depth-1 queue (xQueueOverwrite gives the
  replace-on-full semantics from the spec — in-flight POST is never
  cancelled; the next queued entry is overwritten by newer state).
- esp_http_client POST to &lt;scrypted&gt;/state with Content-Type application/
  json, User-Agent ScryptedViewport/&lt;version&gt;, Connection: close, 1s
  timeout. Body: {"viewport":"&lt;name&gt;","state":"wake"|"sleep"}.
- Any non-2xx or transport error increments state_post_failures and is
  otherwise ignored. Silently drops if no Scrypted URL is configured.

touch.{h,c}:
- FT5426 capacitive touch on the shared I2C bus at 0x38.
- 30ms polling task; tracks down/up transitions, detects taps as
  down-then-up within 500ms with a 150ms debounce.
- On tap, toggles wake/sleep via state_machine_set_local(), which drives
  the local transition AND fires state_client_post() at Scrypted.

state_machine adds state_machine_set_local(): runs the same transition
as state_machine_set() then enqueues an outbound POST. Idle-timer
expiry now uses this path so Scrypted sees idle-driven sleeps too
(the spec's "tighten the race with /frame 409" path stays in place as
the fallback).

display.h exposes display_i2c_bus(); touch.c uses it instead of
re-initializing the same I2C port.

app_main starts state_client right after the state machine and starts
touch after display init (touch is skipped if display isn't up since
they share the bus).

CMakeLists.txt: add esp_http_client to REQUIRES.

Build clean against ESP-IDF 5.4 (binary ~860 KB; jumped ~210 KB from
M6 because esp_http_client + cJSON path pulls in tcp_transport, mbedtls,
http_parser).

TESTING.md M7: flask-based test receiver, tap dispatch verification,
failure-path check (kill receiver, confirm counter increments),
queue-coalescing behavior, no-POST-on-Scrypted-initiated, no-POST-
when-unconfigured.
</content>
</entry>
</feed>
