<feed xmlns='http://www.w3.org/2005/Atom'>
<title>luke/esp32-poe-scrypted-viewport/main/CMakeLists.txt, 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:31:23+00:00</updated>
<entry>
<title>firmware: build fixes — esp_app_format component + forward-declare s_last_painted_seq</title>
<updated>2026-06-20T00:31:23+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-20T00:31:23+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=30a317dcb6dd40d2eca64aa09fdbe9031c37996f'/>
<id>urn:sha1:30a317dcb6dd40d2eca64aa09fdbe9031c37996f</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>M3: panel renders — TC358762 bridge init + non-burst DSI</title>
<updated>2026-06-14T21:36:46+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-14T21:36:46+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=6c1a26bdae63d152caa45023e8255561a22fc0e2'/>
<id>urn:sha1:6c1a26bdae63d152caa45023e8255561a22fc0e2</id>
<content type='text'>
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
</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>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>
<entry>
<title>M5: POST /frame — hardware JPEG decode + orientation-aware paint</title>
<updated>2026-06-14T03:48:48+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-14T03:48:48+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=6cbdd4ed7466da28e524c9e0804722428a4b9698'/>
<id>urn:sha1:6cbdd4ed7466da28e524c9e0804722428a4b9698</id>
<content type='text'>
jpeg_decoder.{h,c} wraps esp_driver_jpeg (ESP32-P4 hardware decoder).
- One-time engine + DMA-aligned PSRAM scratch buffer setup (1 MB input,
  768 KB output @ panel native 800x480 RGB565).
- try_lock + unlock so concurrent /frame POSTs get 503 instead of
  queueing, per spec.
- jpeg_decoder_get_info() reports the dimensions; the http handler
  validates them against the effective resolution before painting.

display.h adds display_present_rgb565(src, w, h):
- Landscape: src is 800x480, memcpy 1:1 into the panel framebuffer.
- Portrait: src is 480x800, software rotate 90° CW into the 800x480
  panel framebuffer. (PPA / 2D-DMA hardware rotation is a later
  optimization if portrait latency matters.)

http_api.c adds POST /frame:
- Content-Type must be image/jpeg → else 400.
- Empty body → 400. &gt; 1 MB → 413. Display not initialized → 500.
- jpeg_decoder_try_lock(0) for concurrency: second post returns 503.
- Body streamed into the decoder's input buffer in chunks.
- Decode failure or dimension mismatch → 400 + decode_errors++.
- Paint failure → 500.
- Success → frames_received++, last_frame_us = esp_timer_get_time(),
  204 No Content.

app_main initializes the JPEG decoder after display_init(). Both are
best-effort: failures log a warning and leave the rest of the firmware
running.

CMakeLists.txt: add esp_driver_jpeg to REQUIRES.

Known gap (M6 closes it): /frame currently paints regardless of
wake/sleep state. The 409-when-asleep rule lands with POST /state
in M6.

Build clean against ESP-IDF 5.4 (binary ~640 KB).

TESTING.md M5 expanded with portrait/landscape test commands,
ImageMagick test-image recipes, the full negative matrix (wrong
Content-Type, oversize, wrong dims, concurrent, garbage), and the
M6 dependency note.
</content>
</entry>
<entry>
<title>M3: display driver (Pi-style DSI panel: TC358762 + ATTINY MCU)</title>
<updated>2026-06-14T03:35:21+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-14T03:35:21+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=965f88596f8c1272a8c60f2aa0fd2a5ab88c9025'/>
<id>urn:sha1:965f88596f8c1272a8c60f2aa0fd2a5ab88c9025</id>
<content type='text'>
display.{h,c} brings up the Hosyond 5\" 800x480 panel via the Raspberry
Pi 7\" touchscreen architecture:

- I2C init to the panel-side MCU at 0x45 (Pi 7\" register map ported
  from drivers/gpu/drm/panel/panel-raspberrypi-touchscreen.c).
- POWERON write + 120ms settle.
- ESP32-P4 MIPI-DSI bring-up in DPI video mode: 2-lane, 480 Mbps,
  800x480 @ 60Hz, RGB565, canonical Pi 7\" timings (HSW=18/HBP=20/HFP=62,
  VSW=4/VBP=27/VFP=18, 30 MHz pixel clock).
- Gamma-corrected (^2.2) PWM brightness via REG_PWM (0x86).
- display_sleep / display_wake hooks for M6.
- display_fill() and display_test_pattern() (8 vertical color bars)
  for M3 acceptance and reuse by M8 local screens.

app_main calls display_init() as best-effort — if the panel isn't
attached or wiring is wrong, the rest of the firmware (Ethernet, mDNS,
/state) keeps running. On success it paints the test pattern.

Pin assignments (PIN_I2C_SDA=7, PIN_I2C_SCL=8) match Waveshare's
bundled-panel BSP convention but are flagged TODO until the user's
specific FPC adapter wiring is confirmed.

main/CMakeLists.txt: add driver, esp_driver_i2c, esp_lcd to REQUIRES.

Build verified clean against ESP-IDF 5.4 for target esp32p4. M3
acceptance (color bars on screen) cannot be verified without a
flashed board + connected panel.
</content>
</entry>
<entry>
<title>M2: HTTP server + mDNS service discovery + shared state</title>
<updated>2026-06-14T03:08:02+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-14T03:08:02+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=4756daedaefd18919e96fadaabc7f1a00b383b4c'/>
<id>urn:sha1:4756daedaefd18919e96fadaabc7f1a00b383b4c</id>
<content type='text'>
- viewport_state.{h,c}: shared state struct (config, run-state, counters,
  orientation, timestamps) behind a FreeRTOS mutex. Modules update it
  under viewport_state_lock(); GET /state serializes a snapshot.
- http_api.{h,c}: starts esp_http_server on :80, registers GET /state.
  Returns the full JSON shape from the spec (name, version, configured,
  state, uptime_ms, last_frame_ms_ago, frames_received, decode_errors,
  state_post_failures, resolution, ip, free_heap, free_psram).
- mdns_service.{h,c}: mdns_init + advertise _scrypted-viewport._tcp.local
  on :80 with version/resolution/orientation/name TXT records.
  mdns_service_refresh() reapplies hostname + TXT after /config writes
  (called from M4 onward).
- main/CMakeLists.txt: add esp_http_server, esp_timer, json, mdns to
  REQUIRES. espressif/mdns added as managed component via
  main/idf_component.yml.

app_main calls viewport_state_init early so any module can read defaults
before /config arrives.

Also adds TESTING.md tracking per-milestone verification status and an
integration test plan to run after M9 (races, failure modes, longevity,
power cycles, negative protocol, multi-viewport).

M1 promoted to "🟡 builds clean" in TESTING.md; combined HW verification
of M1+M2 will be one flash session. Build verified against ESP-IDF 5.4
for target esp32p4 (binary ~550 KB).
</content>
</entry>
<entry>
<title>M1: Ethernet bring-up</title>
<updated>2026-06-14T02:57:47+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-14T02:57:47+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=e2ac22e58901e18b0b567b85d93a8f68335180bc'/>
<id>urn:sha1:e2ac22e58901e18b0b567b85d93a8f68335180bc</id>
<content type='text'>
net_eth module brings up the Waveshare ESP32-P4-ETH-POE Ethernet
interface (internal EMAC + IP101GRI PHY) and waits for a DHCP lease.

Pin map confirmed via Waveshare wiki + ESPHome's working config:
  MDC=31, MDIO=52, REF_CLK=50 (CLK_EXT_IN from PHY)
  TX_EN=49, TXD0=34, TXD1=35
  CRS_DV=28, RXD0=30, RXD1=29
  PHY reset/enable=51, PHY addr=1

app_main initializes NVS, netif, the default event loop, then starts
the Ethernet driver and waits up to 30s for an IP. On success it logs
the IP; on timeout it logs a warning and proceeds (the driver keeps
retrying in the background).

sdkconfig: declare 16 MB flash so partitions.csv (6.1 MB) fits.
main/CMakeLists.txt: explicit REQUIRES esp_eth esp_event esp_netif
nvs_flash.

Acceptance (per M1 in the impl guide): device gets a DHCP lease over
Ethernet and prints its IP. Build is clean against ESP-IDF 5.4 for
target esp32p4.
</content>
</entry>
<entry>
<title>Initial Scrypted Viewport scaffold</title>
<updated>2026-06-13T23:59:25+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-13T23:57:54+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=3ba136c96263c8f7760e73ea1e55361bee526416'/>
<id>urn:sha1:3ba136c96263c8f7760e73ea1e55361bee526416</id>
<content type='text'>
Plain ESP-IDF project targeting Waveshare ESP32-P4-ETH-POE with a 5" 800x480
MIPI-DSI capacitive touch panel. Stub app_main with TODOs for Ethernet, mDNS,
HTTP API (/health, /config, /frame, /sleep, /brightness), JPEG decode, and
touch callback delivery per the v1 spec in README.md.
</content>
</entry>
</feed>
