src.nth.io/

summaryrefslogtreecommitdiff
AgeCommit message (Collapse)AuthorFilesLines
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.
2026-06-13Tighten protocol; add /wake, callback types, and orientationLuke Hoersten2-219/+338
State model is now strictly wake/sleep, owned by the device: - Local triggers: tap (toggle), idle timeout (sleep only). - Remote triggers: POST /wake, POST /sleep. Scrypted decides why (doorbell, motion, person, schedule) — the device does not know. - /frame is pure paint: 204 when awake, 409 when asleep, no implicit wake. Eliminates the race where an in-flight frame could re-wake a device that just slept on a tap. Orientation: - Configurable via /config; default portrait. - Effective resolution: 480x800 portrait, 800x480 landscape. - Scrypted must render at the effective resolution. Device does not rotate or scale JPEG content. - Orientation flows through mDNS TXT, /health, IP/Loading screens. Callbacks: - Add `type` field carrying the cause: tap or timeout (forward-compat for swipes etc). - Drop wall-clock timestamp (no RTC, no SNTP). Other: - Boot: configured device boots to sleep (backlight off). - /health adds state, last_frame_ms_ago, frame counters, error counters, brightness, orientation, idle_timeout_ms. - idle_timeout_ms: 0 disables; non-zero must be >= 5000; else 400. - First-boot brightness default 80; apply gamma curve to PWM. - Watchdog reboots hung tasks; recovers state from NVS. - On-board status LED indicates network/config state. - mDNS hostname-collision warning; ops notes. - Idempotency table summarizing endpoint safety. - Each viewport is bound 1:1 to a camera in Scrypted-side code. - Reframe "no rendering engine" honestly as "no general-purpose UI framework"; IP and Loading screens are the only locally-drawn UI. Doc sync: v2 implementation guide endpoints, modules, milestones, acceptance criteria, error handling, and tests all updated to match.
2026-06-13Spec wake/sleep, idle timeout, IP screen, BOOT buttonLuke Hoersten2-69/+194
- Couple backlight and Scrypted streaming as a single wake/sleep state owned by the device. - Callback events are idempotent imperatives: wake = "start streaming", sleep = "stop streaming". Scrypted does not track viewport state. - Each viewport is bound to one camera in Scrypted-side code; camera events (doorbell, person, motion) drive streaming. - idle_timeout_ms set by Scrypted via /config (default 60000). Same value on both sides; timers run independently and either can end a session. - Tap input only (long_press/swipes out of scope for v1). Tap is internal; callback carries the resulting state. - IP screen on first boot, after factory reset, and as a 15s BOOT short-press overlay. - Loading screen between wake and the next /frame. - BOOT: short press = IP overlay, hold 5s = factory reset. - Note that Scrypted side is code (Script for v1, custom plugin for v2 with /stream), not configuration.
2026-06-13Flesh out protocol details and add implementation guideLuke Hoersten2-18/+995
Expand README with per-endpoint contracts (response codes, persistence, size limits, idle/wake semantics) and a Scrypted integration section covering registration, frame push, and touch handling. Add v2 implementation guide adapted from the DoorView spec. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-06-13Initial Scrypted Viewport scaffoldLuke Hoersten8-0/+177
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.