<feed xmlns='http://www.w3.org/2005/Atom'>
<title>luke/esp32-poe-scrypted-viewport/main/state_machine.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>state_machine: wait one panel-refresh after paint before lighting backlight (33 ms)</title>
<updated>2026-06-15T02:13:01+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T02:13:01+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=7b04ad6faf3bb70a0c3783e8446275f6f152b61e'/>
<id>urn:sha1:7b04ad6faf3bb70a0c3783e8446275f6f152b61e</id>
<content type='text'>
</content>
</entry>
<entry>
<title>state_machine: paint placeholder before wake — no more stale-frame flash</title>
<updated>2026-06-15T02:10:26+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-15T02:10:26+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=0f0f5dfd4f2910eb8d0e4360d27b7e51a9a14fe7'/>
<id>urn:sha1:0f0f5dfd4f2910eb8d0e4360d27b7e51a9a14fe7</id>
<content type='text'>
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.
</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>Drop VIEWPORT_STATE_UNCONFIGURED — state is just awake/asleep</title>
<updated>2026-06-14T23:16:46+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-14T23:16:46+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=4a6bbdb075a5e4f9910cb9b85e615ff8c50aa4ec'/>
<id>urn:sha1:4a6bbdb075a5e4f9910cb9b85e615ff8c50aa4ec</id>
<content type='text'>
\`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 &amp;&amp; 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.
</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 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>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>
<entry>
<title>M6: state machine — POST /state, idle timer, /frame 409 guard</title>
<updated>2026-06-14T03:52:41+00:00</updated>
<author>
<name>Luke Hoersten</name>
<email>luke@hoersten.org</email>
</author>
<published>2026-06-14T03:52:41+00:00</published>
<link rel='alternate' type='text/html' href='https://src.nth.io/luke/esp32-poe-scrypted-viewport/commit/?id=57c93fd2978ed5b5e64f4620803b7a2cd7767ed2'/>
<id>urn:sha1:57c93fd2978ed5b5e64f4620803b7a2cd7767ed2</id>
<content type='text'>
state_machine.{h,c} — central wake/sleep transitions:
- state_machine_init() creates the esp_timer one-shot for the idle timer.
- state_machine_set(target) is idempotent and atomic. On AWAKE: backlight
  on, idle timer (re)armed. On ASLEEP: idle timer cancelled, backlight off.
  Rejects with INVALID_STATE when the device is unconfigured.
- state_machine_frame_painted() restarts the idle timer if awake; called
  by /frame after each successful paint.
- Idle-timer callback transitions to ASLEEP. TODO M7 hook: outbound POST
  {viewport, state:sleep} to &lt;scrypted&gt;/state.

http_api.c:
- POST /state: parse {state}, accept "wake"/"sleep", reject others 400.
  Unconfigured device → 409 "device unconfigured". Already-in-state → 204
  (idempotent no-op). Successful transition → 204.
- POST /frame: 409 Conflict when state != AWAKE. After successful paint,
  call state_machine_frame_painted() so the idle clock keeps resetting
  while frames stream.

app_main:
- Initialize state_machine before http_api so the route handler can drive
  it from request 0.
- After display_init(), reconcile the panel with the boot state:
    UNCONFIGURED → test pattern (placeholder until M8 IP screen)
    ASLEEP       → display_sleep() so a configured device boots dark
    AWAKE        → leave on (not reached on fresh boot)

Disable path: idle_timeout_ms=0 in /config means the timer is never armed
and a wake state persists until /state {sleep} or a power cycle.

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

TESTING.md M6 expands with idempotency checks, 409-when-asleep, 409-when-
unconfigured, idle-timer firing within idle_timeout_ms+slack, /frame
restarting the idle timer, idle-timer disable via idle_timeout_ms=0, and
the M7 dependency note about the missing outbound sleep POST.
</content>
</entry>
</feed>
