src.nth.io/

summaryrefslogtreecommitdiff
AgeCommit message (Collapse)AuthorFilesLines
2026-06-14scrypted: register child viewports as SmartDisplay typeLuke Hoersten1-2/+2
2026-06-14scrypted: guard against empty v.name in register + log pathsLuke Hoersten1-6/+25
After the first camera event fires on a freshly-created viewport, v.name sometimes resolves as "" (Scrypted device-record load racing with event delivery). registerViewport then POSTs /config with viewport="" and the firmware returns 400 "viewport must be a non-empty string". The handler retries on the next callback so it eventually self-heals, but the log is noisy and we'd needlessly fail one register cycle. Mirror the canonical display name into device storage (display_name) on createDevice + on every successful register, and have registerViewport + attachListener fall back to that storage value when v.name is empty. No more empty-name POSTs to the firmware.
2026-06-14scrypted/diagnostic: probe for mDNS modules ahead of auto-discoveryLuke Hoersten1-48/+22
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-14scrypted: re-discover children at start so their storage rehydrates on ↵Luke Hoersten1-4/+16
script reload
2026-06-14scrypted: use nativeId (not numeric id) for ↵Luke Hoersten1-1/+5
endpointManager.getInsecurePublicLocalEndpoint
2026-06-14scrypted: discover device before writing its storage in createDeviceLuke Hoersten1-13/+13
2026-06-14scrypted: revert to declare-const for SDK names — diagnostic confirmed ↵Luke Hoersten1-13/+10
they're injected The probe in scrypted/diagnostic.ts confirmed @scrypted/core 0.3.147 injects every name we need (ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, systemManager, deviceManager, mediaManager, endpointManager, log, device, require, exports) as top-level scope variables. Notably sdk.ScryptedDeviceBase IS undefined — the classes are separate injections, not properties on the sdk object. So the correct pattern is `declare const X: any` for each (declarations erase at compile time → free identifiers at runtime → bound to the scryptedEval-injected values), not `const X = sdk.X` (which was undefined and tripped "Class extends value undefined").
2026-06-14scrypted: add diagnostic.ts to probe scriptedEval scope on user's core 0.3.147Luke Hoersten1-0/+51
2026-06-14scrypted: pull SDK classes off the sdk global instead of relying on each ↵Luke Hoersten1-10/+13
being a separate injection
2026-06-14scrypted: declare runtime SDK names directly as globalsLuke Hoersten1-21/+19
The scryptedEval sandbox pre-injects SDK names (ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, systemManager, deviceManager, mediaManager, endpointManager, log, device, sdk, require) into the script's scope. The previous attempt destructured them from `sdk`, but on user's Scrypted 0.143.0 / @scrypted/core 0.3.147 something in the compile pipeline was still emitting a `require('@scrypted/sdk')` that failed to resolve. Switch to `declare const <name>: any` for each runtime value — the declarations fully erase at compile time, no require is emitted, and the values come straight from the sandbox scope.
2026-06-14scrypted: rewrite top to use globals — Scripts plugin can't resolve ↵Luke Hoersten1-17/+33
@scrypted/sdk import The Scripts plugin sandbox (in @scrypted/core) doesn't resolve ESM imports of npm modules — saving the script with `import sdk, {...} from '@scrypted/sdk'` produced: Error: Cannot find module '@scrypted/sdk' ... at t.scryptedEval (.../scrypted-eval.ts:102:29) Fix: destructure runtime values from the `sdk` global the plugin injects, use `require('dns').promises` for the Node mDNS lookup, and declare runtime type aliases as `any` so the file still parses on a machine without @scrypted/sdk installed locally. Also drop the `as ScryptedInterface` cast in handleCameraEvent — it was the only place an SDK enum was used as a type rather than a value, and just storing the string is fine. Editor-only diagnostics (NodeJS namespace, Buffer) remain — they resolve in the Scrypted Node runtime and only show up in clangd/tsc without @types/node locally.
2026-06-14scrypted/README: drop stale BOOT-factory-reset + IP-screen refsLuke Hoersten1-2/+2
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 Hoersten8-49/+25
\`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-14TESTING: M5 ✅ verified 2026-06-14Luke Hoersten1-2/+2
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 Hoersten8-101/+198
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 Hoersten2-4/+9
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-14TESTING: M3 / M7 / M8 ✅ verified 2026-06-14Luke Hoersten1-12/+12
Hardware bring-up of the Hosyond panel, FT5426 touch, and local screens are all confirmed working on the Waveshare ESP32-P4-ETH. Also retire the resolved hardware unknowns (DSI pin count, I²C pins, BOOT button) — they're answered in the body now. M4 / M5 / M6 still need ethernet + a curl-driven run to flip.
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 Hoersten11-154/+314
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: link-loss & recovery verified 2026-06-14Luke Hoersten1-1/+11
Real production failure mode for PoE-only deployments: a switch reboot or briefly down VLAN yanks Ethernet without cutting power. Confirmed the device handles it cleanly (no panic, no reset, no state loss) and re-acquires DHCP on link-up. Serial trace from the bench: W (748316) net_eth: link down # cable pulled I (916316) net_eth: link up, mac e8:f6:0a:e0:90:94 # plugged back in I (924816) net_eth: got ip 10.0.13.83 ... # DHCP renewed ~8.5s later Same boot throughout — uptime kept climbing past the disconnect, no ESP reset. The HTTP server stayed bound and the mDNS responder task kept running through the outage; both resumed serving the moment DHCP came back. free_heap and free_psram identical to pre-disconnect (no leak from the cycle). TESTING.md M1 entry now captures this with the actual log line sequence. The integration-suite "Cable pull mid-frame" item gets a ✅ for the idle/no-Scrypted variant; the actual mid-frame Scrypted variant still pending.
2026-06-14M1 + M2 hardware-verified 2026-06-14 (Waveshare ESP32-P4-ETH)Luke Hoersten2-7/+13
First end-to-end bench verification. Board is the Waveshare ESP32-P4-ETH (Amazon B0FN7JQ2V8), 32 MB PSRAM, 32 MB flash silkscreen, ESP-IDF v5.4.1. No panel attached, just board on USB power + LAN. What passed ----------- M1 — Ethernet driver up; DHCP lease landed at 10.0.13.83 (gw 10.0.13.1, netmask 255.255.255.0). MAC e8:f6:0a:e0:90:94. 31.7 MB PSRAM free. Boot completes in ~4s after the kernel handoff. M2 — GET http://10.0.13.83/state returns the full spec JSON shape (name=null, configured=false, state=unconfigured, resolution=480x800, ip=10.0.13.83, free_heap=520995, free_psram=31730048, all counters at 0). mDNS browse from macOS (dns-sd -B _scrypted-viewport._tcp local.) finds one instance named "viewport" within ~100ms. Bare-board behavior (Ethernet unplugged) verified too: boot completes cleanly with summary line `boot complete — subsystems [EMHdJ-B] ip=(no link)`. Lowercase d = display down because no panel attached; - for touch because it shares the panel I²C bus. README status snapshot updated to reflect M1+M2 ✅ on hardware; TESTING.md M1 and M2 sections gain the 2026-06-14 verification note plus the three fixes that bring-up exposed (RXD0/RXD1 swap, mDNS hostname order, elegant subsystem degradation — already shipped in commit 220ee4c).
2026-06-14M1+M2 hardware bring-up: 3 fixes + elegant subsystem degradationLuke Hoersten4-59/+107
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 Hoersten4-22/+103
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-14Consolidate docs: merge DESIGN.md into README; sharpen status + roadmapLuke Hoersten3-1128/+169
DESIGN.md was ~80% redundant with README.md (project summary, hardware, API contract, callback contract, scrypted integration, milestone acceptance criteria) and ~20% implementation guidance that's now better served by the source code itself + TESTING.md. Removed it and folded the unique parts into README. README.md additions: - "Status" table at the top: firmware, Scrypted side, hardware — each with where-we-are + what's-pending columns. - "Firmware implementation notes" section: source map (one-line per main/*.{h,c} module), memory strategy, display strategy, error handling, coding standards. Replaces DESIGN.md sections 7-11, 15, 17. - "What's next" section before Philosophy: nine items in priority order, each linking the specific TESTING.md or scrypted/ section. Makes "pick up cold" trivial. - Philosophy section absorbed DESIGN.md's "Guiding Rule" as a blockquote at the bottom. - "Related docs" rewritten — TESTING.md is now described as *the* self-contained verification reference. TESTING.md additions: - Preamble now describes the five-layer structure and the ⬜/🟡/✅ legend. - New "Status snapshot" table — milestones + code + HW at a glance. - New "Hardware prerequisites" — table of what to confirm/order, plus a numbered list of the four open unknowns (DSI FPC pin count, I²C jumper destinations, BOOT GPIO, flash silkscreen). - New "Recommended bench order" — seven stages from "board on Ethernet over USB" through "integration suite," each with the commands or where-to-look. DESIGN.md deleted. Single source of truth is now README.md (spec + implementation notes + roadmap) and TESTING.md (verification reference).
2026-06-14Rename implementation guide to DESIGN.mdLuke Hoersten1-0/+0
2026-06-14scrypted/script: auto-populate viewport host via mDNSLuke Hoersten2-5/+66
The host field is now optional in "+ Add Device". The script tries dns.promises.lookup("viewport-<name>.local") via the OS resolver (Bonjour on macOS, nss-mdns on Linux, host networking on Docker) on: - every registerViewport call (plugin start, child instantiation, settings change, periodic 5-min refresh), - right after createDevice so a fresh viewport's host field is populated by the time the operator opens its settings page. On a successful lookup that differs from the stored host, the resolved IP is written back to the child's storage. POST /config and POST /frame then use the resolved value. Falls back gracefully to the operator-entered host (with a one-line warning if both are empty). A per-viewport "Auto-resolve via mDNS" toggle (default on) opts out — useful for cross-VLAN setups or hosts where mDNS doesn't reach. scrypted/README.md adds a "How mDNS auto-resolve works" section covering the per-OS resolver requirements and the Docker host- networking note.
2026-06-14Path B: per-viewport Scrypted devices with UI-driven settingsLuke Hoersten5-251/+451
Convert the Scripts-plugin script from a hardcoded BINDINGS constant into a DeviceProvider + DeviceCreator + HttpRequestHandler. Each viewport is now a child Scrypted device under the parent script with its own Settings page; operators add, edit, and delete viewports entirely through the Scrypted UI. Parent (ScryptedViewportProvider): - DeviceProvider: getDevice(nativeId) instantiates a Viewport, attaches its camera event listener, and posts /config. releaseDevice tears down stream + listener + child storage entry. - DeviceCreator: "+ Add Device" on the parent's page shows a small form (name / host / camera picker filtered to Camera interfaces / orientation choice). createDevice() pre-populates the child's storage via deviceManager.getDeviceStorage(nativeId) and registers it under the parent. - Tracks known child nativeIds in its own storage as a JSON array so it can eagerly instantiate every child on plugin start (each registration + camera subscription happens at load time, not lazily). - 5-min re-register loop catches devices that rebooted or got new DHCP leases. - HttpRequestHandler routes POST <base>/state on the parent's endpoint; body {viewport, state} is matched against child names. Honors the spec race rules: every callback cancels any prior stream + safety timer for that viewport before applying the new state, and a /frame 409 stops the stream without echoing sleep back. - Global tuning (frame_interval_ms) lives on the parent's Settings. Child (Viewport): - Settings: host (string), camera (type=device with deviceFilter for the Camera interface — the UX win), orientation (choices), idle timeout, brightness. All persisted via this.storage. - putSetting fires onBindingChanged() on the parent so re-register + re-subscribe happen immediately when any field changes. scrypted/README.md rewritten for the UI-driven flow — install + add device + edit + remove + global tuning + smoke test — no more "edit BINDINGS and re-save." scrypted/package.json + tsconfig.json: optional `npm install` so editors can resolve @scrypted/sdk types. Nothing here ships — install remains "paste into Scripts plugin." node_modules ignored.
2026-06-14M9-precursor: v1 Scrypted Script (snapshot-rate end-to-end)Luke Hoersten3-1/+422
scrypted/scrypted-viewport.ts — single-file TypeScript script for Scrypted's Scripts plugin. Binds N viewports to N Scrypted cameras and implements the full Scrypted side of the protocol: Outbound (Scrypted -> device): - POST /config on script load + every 5 min (re-syncs after device reboot or DHCP renumber). - POST /state {wake} when a bound camera fires an event or when the device's own POST /state {wake} arrives (operator tap). - POST /frame at FRAME_INTERVAL_MS (1 fps default) via camera.takePicture({picture:{width,height}}). - POST /state {sleep} when the Scrypted-side per-stream timer expires. Inbound (device -> Scrypted POST <base>/state): - {state:wake} -> startStream() (cancels pending sleep timer + any prior interval, then begins pushing frames). - {state:sleep} -> stopStream(sendSleep=false) (no echo back). - Unknown viewport -> 404. Bad body -> 400. Race rules from the spec are honored: - Every callback cancels prior pending state for that viewport before applying the new state (cancelPendingSleep semantics). - startStream / stopStream are idempotent — re-entering is safe. - On /frame returning 409, stream stops immediately without echoing /state {sleep} back (the device already slept). Configuration is a single BINDINGS array at the top of the file — edit name/host/cameraId/orientation per viewport and save in the Scrypted UI. No package install. Tuning constants (IDLE_TIMEOUT_MS, FRAME_INTERVAL_MS, REREGISTER_INTERVAL_MS, etc.) are right above the class. scrypted/README.md walks through Scripts-plugin install, the BINDINGS schema, what each constant controls, an event-by-event description of the script's behavior, the v1 limitations (snapshot-rate, manual IP, camera must respect picture dims), and an end-to-end smoke test. Top-level README's Scrypted Integration section now links the script file and the install README directly.
2026-06-14README: link Amazon SKUs + note Pi-style panel architectureLuke Hoersten1-2/+2
2026-06-13M8: local screens (IP + Loading) + BOOT buttonLuke Hoersten7-13/+369
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.
2026-06-13M7: touch + outbound /state POST to ScryptedLuke Hoersten11-18/+346
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 <scrypted>/state with Content-Type application/ json, User-Agent ScryptedViewport/<version>, Connection: close, 1s timeout. Body: {"viewport":"<name>","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.
2026-06-13M6: state machine — POST /state, idle timer, /frame 409 guardLuke Hoersten5-17/+285
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 <scrypted>/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.
2026-06-13M5: POST /frame — hardware JPEG decode + orientation-aware paintLuke Hoersten8-11/+345
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. > 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.
2026-06-13M4: NVS-backed /config with partial updates + validationLuke Hoersten5-18/+437
nvs_config.{h,c} — persist the runtime config (viewport, scrypted, idle_timeout_ms, orientation, brightness) under a single NVS namespace. nvs_config_load() applies persisted values over the in-RAM defaults on boot and flips state from UNCONFIGURED to ASLEEP once both name and Scrypted URL are present. nvs_config_save() commits the whole record atomically. http_api.c — add GET /config and POST /config: - GET serializes viewport_state to the spec's JSON shape, with null for unset string fields and defaults filled in for the rest. - POST is partial: each field is optional; only present fields are validated and applied. Validation runs on a staged copy and errors short-circuit with 400 + reason before any state mutation, so a rejected request leaves the device untouched. - Validation rules: viewport non-empty <64 chars; scrypted starts with http:// and <256 chars; idle_timeout_ms 0 or >=5000; orientation in {portrait,landscape}; brightness 0..100. - Side-effects fire after the lock + save: brightness change pushes PWM to the panel MCU; viewport/orientation change reapplies mDNS hostname + TXT. - 204 on success; 400 with a single-line reason on validation error. app_main calls nvs_config_load() right after viewport_state_init(), so mdns_service_start() and display_init() see the persisted hostname, orientation, and brightness from the first packet/PWM. Build clean against ESP-IDF 5.4 (binary ~620 KB). TESTING.md M3 now documents the Hosyond jumper wiring (5V/GND/SDA=GPIO7/ SCL=GPIO8 from board to panel header; DSI FPC carries only the high- speed lanes). M4 entry expands the validation matrix and side-effects to verify.
2026-06-13Untrack Emacs lock files; ignore .#* and *~Luke Hoersten2-1/+5
2026-06-13M3: display driver (Pi-style DSI panel: TC358762 + ATTINY MCU)Luke Hoersten5-2/+318
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.
2026-06-13M2: HTTP server + mDNS service discovery + shared stateLuke Hoersten10-6/+579
- 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).
2026-06-13M1: Ethernet bring-upLuke Hoersten5-12/+201
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.
2026-06-13Switch scaffold to C; update boot TODOs to match current specLuke Hoersten1-4/+4
2026-06-13Rename M7: Touch Callback -> Touch + Outbound /state POSTLuke Hoersten1-1/+1
2026-06-13Reframe protocol as REST peers; mDNS-SD discovery; explicit no-ackLuke Hoersten2-165/+367
Three structural shifts: 1. Discovery section spells out mDNS-SD. The ESP32 runs the ESP-IDF mdns responder and serves all .local records itself — A, SRV, PTR, TXT. Scrypted browses _scrypted-viewport._tcp.local with a Node mDNS-SD library that hits the multicast layer directly. Scrypted uses the IP from the SRV/A record, NOT OS-level .local hostname resolution. Re-browse every few minutes for DHCP renumbering. Manual host:port fallback for non-mDNS deployments. 2. Drop "callback" framing. The device and Scrypted are HTTP peers, both exposing POST /state with the same body shape {viewport,state}. Either side can push to the other to set state. Section is renamed "Device -> Scrypted POST /state". No new vocabulary or semantics — just the truth about what the protocol is. - state_post_failures (was: callback_failures) - state_client.c (was: callback_client.c) - "callback" replaced with "outbound POST" / "inbound POST" / just "POST /state" throughout. 3. Explicit no-application-level-ack clause. HTTP 2xx is transport- only. Device does not retry, does not block subsequent state changes on the response, does not treat 5xx as anything beyond a counter increment. Idempotency + /frame 409 + each side's idle timer cover every failure mode. Scrypted-side logic must not wait for the device to confirm a state change. Also: Scrypted Integration code in v2 guide rewritten to discover via bonjour-service browse rather than hardcoded viewport-<name>.local URLs. urlFor(name) function resolves the discovered IP per call. mdns_service.c module spec now explicitly notes Scrypted uses the browse, not the hostname.
2026-06-13Consolidate REST API: 4 endpoints, partial /config, /state, GET /configLuke Hoersten2-195/+224
API surface is now: GET /state runtime status (replaces /health) GET /config persisted config POST /config partial update; brightness is now a field here POST /state {state: wake|sleep} (replaces /wake and /sleep) POST /frame unchanged POST /config now does a partial merge — only present fields are written. To tweak only brightness, POST {"brightness": 50}. POST /state replaces POST /wake and POST /sleep with a single endpoint that takes {state: "wake"|"sleep"}. Idempotent: no-op if already in the requested state. Brightness is no longer its own endpoint; it lives in /config with all the other persisted settings. PWM remains gamma-corrected. Callback body renamed: was: {event: wake|sleep, type: tap|timeout} now: {state: wake|sleep, event: tap|timeout} The new naming matches POST /state: "state" is the resulting wake/ sleep state and the imperative for Scrypted; "event" is the cause (tap, timeout, future swipes). Doc sync: README API/Wake-Sleep/Idempotency/Integration sections and v2 impl guide API contract, modules, milestones, acceptance criteria all updated to the new model.
2026-06-13Reorder milestones so Config Persistence comes before Frame/Wake/SleepLuke Hoersten1-23/+30
Previously M5 (Wake/Sleep + idle timer) and M4 (Frame) implicitly depended on NVS values (orientation, idle_timeout_ms) that did not exist until M6 (Config Persistence). Moving Config to M4 lets the downstream endpoints read their parameters from NVS instead of working around hardcoded defaults. New order: M1 Board Bring-Up M2 HTTP + mDNS M3 Display Bring-Up M4 Config Persistence (was M6) M5 JPEG Frame Push (was M4) M6 Wake/Sleep/Brightness (was M5) M7 Touch Callback M8 Local Screens + BOOT button M9 Live Stream (/stream) M4 now also validates orientation and idle_timeout_ms per spec, applies orientation immediately (mDNS TXT + /health reflect it), and establishes the brightness default (80) on first boot. M5's test image is 480x800 by default (portrait); switch to 800x480 if /config sets landscape. M6's idle timer reads idle_timeout_ms from NVS.