<feed xmlns='http://www.w3.org/2005/Atom'>
<title>luke/esp32-poe-scrypted-viewport/main/display.c, branch main</title>
<subtitle>ESP32-POE Scrypted viewport (private)
</subtitle>
<id>https://src.nth.io/luke/esp32-poe-scrypted-viewport/atom?h=main</id>
<link rel='self' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/atom?h=main'/>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/'/>
<updated>2026-07-17T23:56:03+00:00</updated>
<entry>
<title>main: code-review fixes — brightness units, OTA stall cap, stats + locking</title>
<updated>2026-07-17T23:56:03+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-07-17T23:56:03+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=9d94f710f7b00d109bf6f6ee57996f43bce5c67f'/>
<id>urn:sha1:9d94f710f7b00d109bf6f6ee57996f43bce5c67f</id>
<content type='text'>
- 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-&lt;name&gt; 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-&gt;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.
</content>
</entry>
<entry>
<title>display: tear-free frame path via triple buffering + scan tracking</title>
<updated>2026-07-16T00:10:57+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-07-16T00:10:57+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=659da7f36473a21494693f031a39fb6bb977b90a'/>
<id>urn:sha1:659da7f36473a21494693f031a39fb6bb977b90a</id>
<content type='text'>
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 -&gt; 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.
</content>
</entry>
<entry>
<title>firmware: double-buffer the panel + zero-copy decode → ~22 fps ceiling</title>
<updated>2026-06-15T12:50:23+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T12:50:23+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=3f53ced44ea112a39eec70216c8c72fa6867726e'/>
<id>urn:sha1:3f53ced44ea112a39eec70216c8c72fa6867726e</id>
<content type='text'>
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.
</content>
</entry>
<entry>
<title>firmware: zero-copy JPEG → BGR888 → DSI hot path; Scrypted pre-rotates</title>
<updated>2026-06-15T12:23:47+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T12:23:47+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=51b816025183801f5953d849fe4ea83cd9385582'/>
<id>urn:sha1:51b816025183801f5953d849fe4ea83cd9385582</id>
<content type='text'>
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.
</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: retry + hard-reset when FT5426 boots into wedged 0xff state</title>
<updated>2026-06-15T00:43:13+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T00:43:13+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=5bed2f0ccc9ca3bab80e1d2ed2a9dddc621428c6'/>
<id>urn:sha1:5bed2f0ccc9ca3bab80e1d2ed2a9dddc621428c6</id>
<content type='text'>
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 &gt; 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 &gt;5 sanity check. Wake
still works via POST /state from Scrypted; this only disables touch
gestures on a hardware-stuck boot.
</content>
</entry>
<entry>
<title>Tidy: delete dead code, inline single-use helpers, fix double-lock</title>
<updated>2026-06-14T23:30:44+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-14T23:30:44+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=186fd85422f99dfb59d89ae9338dc50627f7c3e5'/>
<id>urn:sha1:186fd85422f99dfb59d89ae9338dc50627f7c3e5</id>
<content type='text'>
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-&gt;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.
</content>
</entry>
<entry>
<title>M5 colors fixed: JPEG byte order + DPI memory layout</title>
<updated>2026-06-14T23:08:50+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-14T23:08:50+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=11ad249e4675cec22e924af83feaf0eba31d72e7'/>
<id>urn:sha1:11ad249e4675cec22e924af83feaf0eba31d72e7</id>
<content type='text'>
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.
</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>
</feed>
