# Testing & Verification This file is the self-contained reference for verifying the firmware on real hardware. It has three layers: 1. **Status snapshot** โ€” at-a-glance state of every milestone. 2. **Hardware prerequisites** โ€” what to confirm or order before flashing anything. 3. **Recommended bench order** โ€” the sequence to flip ๐ŸŸก โ†’ โœ…, stage by stage. 4. **Per-milestone entries** (M1โ€“M9) โ€” acceptance criteria + exact commands/actions + status. 5. **Integration & system tests** โ€” to run after every per-milestone test passes. Status legend: - โฌœ not verified โ€” code not yet exercised at all. - ๐ŸŸก partial โ€” compiles cleanly, builds against ESP-IDF 5.4 for `esp32p4`, but not yet run on a board. - โœ… verified โ€” confirmed on hardware. Annotate with the date and board rev. > **Iterating on an already-flashed device:** `make ota [VIEWPORT=]` > builds with a fresh git stamp, pushes over the LAN, and verifies the > `pending-verify -> valid` OTA sequence (auto-retrying the known > first-push silent rollback). The USB commands below are for first > flash / bring-up only. --- ## Status snapshot | # | Milestone | Code | HW | | --- | --- | --- | --- | | M1 | Board Bring-Up โ€” Ethernet + DHCP | โœ… | โœ… | | M2 | HTTP + mDNS (`GET /state`) | โœ… | โœ… | | M3 | Display Bring-Up (Hosyond panel) | โœ… | โœ… | | M4 | Config Persistence (NVS, partial updates) | โœ… | โœ… | | M5 | JPEG Frame Push (`POST /frame`) | โœ… | โœ… | | M6 | State + Idle Timer (`POST /state`, 409 guard) | โœ… | โœ… | | M7 | Touch + Outbound `/state` POST | โœ… | โœ… | | M8 | Local Screens + touch long-press | โœ… | โœ… | | M9 | Live Stream (`POST /stream`) | โฌœ | โฌœ | --- ## Hardware prerequisites Before flashing anything, confirm or acquire: | Item | Status as of this writing | How to resolve | | --- | --- | --- | | Waveshare ESP32-P4-ETH board | shipped | check silkscreen for flash size (16 vs 32 MB) โ€” adjust `sdkconfig.defaults` if 32 | | Hosyond 5" 800ร—480 panel | shipped | n/a | | 15-pin Pi-FPC โ†’ Waveshare-side DSI adapter cable | **unknown โ€” order before M3** | check Waveshare board DSI connector pin count, buy matching adapter | | 4ร— jumper wires (5V / GND / SDA / SCL) | trivial | bench supply | | Ethernet cable + DHCP-serving switch | trivial | LAN already in place | | (optional) PoE injector | nice-to-have | for M1/M2 a USB-only power path is fine | Hardware unknowns โ€” resolved: 1. **DSI FPC pin count on the Waveshare board.** โœ… 22-pin DSI on board; bridge to the panel's 15-pin Pi FPC via a 15โ†’22 adapter cable. 2. **IยฒC jumper destinations.** โœ… `PIN_I2C_SDA=7`, `PIN_I2C_SCL=8` confirmed by ATTINY ack at 0x45 + FT5426 ack at 0x38. 3. **BOOT button GPIO.** โœ… No usable GPIO โ€” ESP32-P4 GPIO 35 strap pin is owned by EMAC TXD1 at runtime; no separate user button is wired on the Waveshare ESP32-P4-ETH. Info-overlay behaviour moved onto touch long-press (โ‰ฅ1.5 s). No factory-reset gesture โ€” that path goes through USB reflash + `idf.py erase-flash`. 4. **Flash size silkscreen.** `sdkconfig.defaults` declares 16 MB. If your SKU shipped with 32 MB the build still works but you waste half the flash. --- ## Recommended bench order Work through these stages in order. Each stage builds on the previous one's verified state. ### Stage 1 โ€” board comes alive (M1 + M2) No display, no panel, no jumpers. Just board on Ethernet over USB. ```bash source ~/Dev/code/git/esp32/env.sh idf.py -p /dev/cu.usbmodem* flash monitor ``` Check serial for `net_eth: got ip` then `curl http:///state | jq .` and `dns-sd -B _scrypted-viewport._tcp local.`. If both pass, flip M1 + M2 to โœ…. Stop here if you hit any issue โ€” debug Ethernet before adding more variables. ### Stage 2 โ€” display panel (M3) โœ… Look for `panel MCU id 0xc3 โ€” Pi v1.1 architecture ack'd`, `TC358762 bridge configured`, and `DSI up: 800x480 26 MHz, 1-lane 600 Mbps, non-burst` in the serial log. Info screen visible on tap-wake. ### Stage 3 โ€” protocol (M4 + M5 + M6) No new wiring. POST `/config`, send a JPEG, exercise wake/sleep, watch the idle timer fire. Run through the per-milestone curls below. ### Stage 4 โ€” touch (M7) Tap the screen. Confirm the FT5426 acks on IยฒC in the boot log. Stand up the flask receiver, configure the device to point at it, and watch outbound `/state` POSTs arrive. ### Stage 5 โ€” local screens + BOOT (M8) Visual: fresh boot shows IP screen; wake shows loading screen; tap-press of the BOOT button overlays IP. If BOOT GPIO is wrong, the screens still work โ€” only the button feature stays cold. ### Stage 6 โ€” end-to-end with Scrypted Paste `scrypted/scrypted-viewport.ts` into Scrypted's Scripts plugin, add a viewport device with the binding (camera picker dropdown), trigger the bound camera, watch the panel light up. ### Stage 7 โ€” integration Run the suite at the bottom of this file (races, failures, longevity, etc.). --- ## M1 โ€” Board Bring-Up **Acceptance**: device gets a DHCP lease over Ethernet and logs its IP. **How to verify** ```bash source ~/Dev/code/git/esp32/env.sh idf.py -p /dev/cu.usbmodem* flash monitor ``` Expected log within ~5โ€“10s of boot: ``` I (xxx) viewport: Scrypted Viewport boot I (xxx) net_eth: ethernet driver started, waiting for link + DHCP I (xxx) net_eth: link up, mac xx:xx:xx:xx:xx:xx I (xxx) net_eth: got ip 192.168.x.x gw 192.168.x.1 netmask 255.255.255.0 I (xxx) viewport: online at 192.168.x.x ``` Cross-check from another host: ```bash ping 192.168.x.x ``` **Status**: โœ… verified 2026-06-14 on Waveshare ESP32-P4-ETH (Amazon B0FN7JQ2V8, 32 MB PSRAM, 32 MB flash silkscreen, IDF v5.4.1). DHCP lease landed at 10.0.13.83. Three fixes were needed during bring-up โ€” see commit 220ee4c: > 1. **RXD0 / RXD1 GPIO swap**: ESP32-P4 EMAC iomux fixes RXD0=29, RXD1=30. The original pin map had them transposed. > 2. **mDNS hostname order**: `mdns_service_add()` returns INVALID_ARG if hostname isn't set yet. Now init โ†’ hostname โ†’ service_add โ†’ TXT. > 3. **`app_main` best-effort everywhere**: any one missing subsystem (no LAN, no panel) used to abort the boot. Now each is independent; final log line summarizes which letters of `EMHDJTB` came up. **Link-loss & recovery also verified 2026-06-14** โ€” important for PoE-only deployments where a switch reboot or briefly down VLAN won't kill power but will yank the link: ``` W (748316) net_eth: link down # cable pulled I (916316) net_eth: link up, mac e8:f6:0a:e0:90:94 # cable plugged back in (3 min later) I (924816) net_eth: got ip 10.0.13.83 gw 10.0.13.1 netmask 255.255.255.0 # DHCP renewed ~8.5s after link-up ``` Same boot throughout (uptime kept climbing past the disconnect, no reset). mDNS responder task and HTTP server stayed alive through the outage and resumed serving the moment DHCP came back. `free_heap` / `free_psram` identical to pre-disconnect โ€” no leak from the cycle. --- ## M2 โ€” HTTP + mDNS **Acceptance**: `GET /state` returns JSON; mDNS service is discoverable. **How to verify** After flash, from a host on the same LAN: ```bash # Direct call by IP (always works): curl http:///state | jq . # mDNS hostname (requires OS-level .local resolution: macOS Bonjour, Linux nss-mdns): curl http://viewport.local/state | jq . # Service discovery browse (preferred โ€” what Scrypted will do, no OS .local resolver needed): dns-sd -B _scrypted-viewport._tcp local. # macOS avahi-browse -r _scrypted-viewport._tcp # Linux ``` Expected `/state` body on a fresh device (no `/config` posted yet): ```json { "name": null, "version": "0.1.0", "configured": false, "state": "asleep", "uptime_ms": 12345, "last_frame_ms_ago": null, "frames_received": 0, "decode_errors": 0, "state_post_failures": 0, "resolution": "480x800", "ip": "192.168.x.x", "free_heap": 200000, "free_psram": 30000000 } ``` Expected mDNS browse output should show a `_scrypted-viewport._tcp` instance with TXT records `version=`, `resolution=`, `orientation=`, `name=` (MAC-derived default until `/config` names it), and `mac=` (since v1.4.0 โ€” the stable identity the Scrypted script's discovery/auto-heal matches on). **Status**: โœ… verified 2026-06-14 alongside M1. - `GET http://10.0.13.83/state` returned the full spec JSON: `name=null, configured=false, state=asleep, resolution=480x800, ip=10.0.13.83, free_psram=31730048, ...`. - `dns-sd -B _scrypted-viewport._tcp local.` on macOS surfaced one instance named `viewport`. Bare-board behavior (no panel, no ethernet) was also verified โ€” the device boots cleanly and prints a summary like `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). --- ## M3 โ€” Display Bring-Up **Acceptance**: panel powers on, MCU at I2C 0x45 responds, color-bar test pattern renders, brightness control works. **Wiring** (Hosyond 5" 800x480 panel โ†” Waveshare ESP32-P4-ETH) The Hosyond panel exposes only the 15-pin Pi DSI FPC โ€” no auxiliary GPIO header. **Everything goes through the FPC**: DSI data lanes, panel power (3.3V / 5V / GND), and the IยฒC bus (SDA / SCL) shared by the on-panel ATTINY MCU at `0x45` and the FT5426 touch at `0x38`. No jumper wires required. Verified board-side mapping (Waveshare ESP32-P4-ETH wiki + ESPHome device page): | Signal | DSI FPC | ESP32-P4 GPIO | | --- | --- | --- | | DSI data lanes / clock | dedicated DSI PHY | (fixed silicon pins) | | IยฒC SDA | FPC pin | **GPIO 7** | | IยฒC SCL | FPC pin | **GPIO 8** | | 3.3V / 5V / GND | FPC pins | board rails (no GPIO) | | Backlight | IยฒC register `0x96` on the panel MCU | (no GPIO) | | Panel reset / power-on | IยฒC `REG_POWERON` (`0x85`) on the panel MCU | (no GPIO) | `PIN_I2C_SDA=7` / `PIN_I2C_SCL=8` in `display.c` already match this. **No code changes needed before flashing.** DSI connector: 22-pin (Pi 5 / CM4 style). The 15-pin Pi DSI FPC on the panel plugs into a **15โ†’22 pin adapter cable** that goes into the board. **Bring-up sequence** 1. Power the ESP32-P4 board (USB is fine for bench; PoE/12V in production). 2. Plug the **15-pin** side of the DSI cable into the Hosyond panel. 3. Plug the **22-pin** side into the Waveshare DSI connector. (Pi FPCs are easy to install upside-down โ€” make sure the contact side faces the connector lock.) 4. Reset the board (or `idf.py flash monitor` if you've changed firmware): ```bash idf.py -p /dev/cu.usbmodem* monitor ``` **Expected log** ``` I (xxx) display: panel MCU id 0xC3 โ€” Pi 7" architecture ack'd I (xxx) display: panel powered on I (xxx) display: DSI up: 800x480 30 MHz, 2-lane 480 Mbps I (xxx) viewport: display up โ€” test pattern on screen ``` **Visual check**: 8 vertical color bars (white, yellow, cyan, green, magenta, red, blue, black) across the panel. Brightness should look perceptually mid-range (default 80/100 with gamma). **Failure-mode signals** - `panel MCU @0x45 unreachable` โ†’ either the FPC seating / orientation is wrong (most common โ€” Pi FPCs are easy to install upside-down on either end), the adapter cable doesn't pass IยฒC straight through, or the panel is one of the rare Waveshare-bundled units with **GT911 touch at 0x5D/0x14** instead of the FT5426 Pi 7" architecture this driver targets (in which case the *panel MCU* check still fails because that variant has no ATTINY at all). No need to suspect DSI yet. - IยฒC ack but no image โ†’ DSI cable orientation on the 22-pin side, or `LCD_COLOR_PIXEL_FORMAT_RGB565` byte order if there's something on the panel but garbled. - Image but wrong colors โ†’ RGB565 byte order; flip to BGR variant. - Image but vertical/horizontal sync issues โ†’ adjust the `PANEL_*SYNC_*` timings in `display.c`; Pi 7" canonical values are the defaults. - Touch ack at `0x38` but no taps โ†’ polling task not running, or panel reset state wrong. **Status**: โœ… verified 2026-06-14 on Waveshare ESP32-P4-ETH + Hosyond 5" panel. ATTINY v1.1 firmware (ID 0xC3) acks; TC358762 bridge configured via 16 DSI Generic Long Writes; DSI 1-lane @ 600 Mbps non-burst; DPI 26 MHz RGB888; info screen renders correctly. Bring-up notes captured in commit 6c1a26b. --- ## M4 โ€” Config Persistence **Acceptance**: `POST /config` persists across reboot; partial-update semantics work. **How to verify** ```bash # Full config: curl -X POST -H "Content-Type: application/json" \ -d '{"viewport":"mudroom","scrypted":"http://host/endpoint/scrypted-viewport","orientation":"landscape"}' \ http:///config # Read back: curl http:///config | jq . # Partial update (only brightness): curl -X POST -H "Content-Type: application/json" \ -d '{"brightness":50}' \ http:///config # Reboot, then re-read โ€” values should survive: curl http:///config | jq . ``` Also verify validation: ```bash # idle_timeout_ms below 5000 (and non-zero) โ€” expect 400: curl -i -X POST -H "Content-Type: application/json" \ -d '{"idle_timeout_ms":1000}' http:///config # bogus orientation โ€” expect 400: curl -i -X POST -H "Content-Type: application/json" \ -d '{"orientation":"sideways"}' http:///config # brightness out of range โ€” expect 400: curl -i -X POST -H "Content-Type: application/json" \ -d '{"brightness":150}' http:///config # scrypted without http:// โ€” expect 400: curl -i -X POST -H "Content-Type: application/json" \ -d '{"scrypted":"scrypted.local"}' http:///config # Garbage JSON โ€” expect 400: curl -i -X POST -H "Content-Type: application/json" \ -d 'not json' http:///config ``` Idle-timer disable (`idle_timeout_ms: 0`) is intentionally allowed. Side-effects to confirm: - After `POST /config` with `brightness`: panel brightness changes immediately (if display is up). - After `POST /config` with `viewport` or `orientation`: mDNS TXT records update; `viewport-.local` resolves; browse shows new TXT. - After `POST /config` with both `viewport` and `scrypted` (any order, on any subsequent call): `GET /state` shows `configured: true`, `state: "asleep"`. **Status**: โœ… verified 2026-06-14. Full POST + partial POST + 5 validation failure modes all returned correctly. Config survives reboot (NVS persistence). `configured: true` was set automatically once both `viewport` and `scrypted` were supplied. Required raising httpd stack from 4 KiB to 8 KiB โ€” the handler's 2 KiB body buffer plus locals were overflowing into the protect page. --- ## M5 โ€” JPEG Frame Push **Acceptance**: `POST /frame` paints a 480x800 (portrait, default) or 800x480 (landscape) JPEG. Orientation determines the expected dimensions and the panel-side rotation. **How to verify** ```bash # Default orientation (portrait) โ€” send 480x800: curl -X POST -H "Content-Type: image/jpeg" \ --data-binary @test-480x800.jpg \ http:///frame # Then switch to landscape and re-test with an 800x480 JPEG: curl -X POST -H "Content-Type: application/json" \ -d '{"orientation":"landscape"}' http:///config curl -X POST -H "Content-Type: image/jpeg" \ --data-binary @test-800x480.jpg \ http:///frame ``` Visual: image fills the panel correctly oriented. After paint, `GET /state` shows `frames_received` incremented and `last_frame_ms_ago` populated. Generate test JPEGs with ImageMagick: ```bash # 8 vertical color bars at 480x800 portrait: convert -size 60x800 gradient:white-black -duplicate 7 +append test-480x800.jpg # or just any 480x800 JPEG: convert -size 480x800 plasma: test-480x800.jpg convert -size 800x480 plasma: test-800x480.jpg ``` Negative tests: ```bash # Wrong Content-Type โ€” expect 400: curl -i -X POST -H "Content-Type: image/png" --data-binary @anything \ http:///frame # Oversize โ€” expect 413: dd if=/dev/urandom of=big.bin bs=1M count=2 curl -i -X POST -H "Content-Type: image/jpeg" --data-binary @big.bin \ http:///frame # Wrong dimensions โ€” expect 400 with "expected WxH, got WxH": convert -size 640x480 plasma: test-640x480.jpg curl -i -X POST -H "Content-Type: image/jpeg" --data-binary @test-640x480.jpg \ http:///frame # Concurrent posts โ€” second gets 503: curl -X POST -H "Content-Type: image/jpeg" --data-binary @test-480x800.jpg \ http:///frame & curl -i -X POST -H "Content-Type: image/jpeg" --data-binary @test-480x800.jpg \ http:///frame # expect 503 if first is still in flight # Garbage bytes claiming to be JPEG โ€” expect 400: curl -i -X POST -H "Content-Type: image/jpeg" -d 'not a jpeg' \ http:///frame ``` After every error: `decode_errors` in `GET /state` should increment. **Known gap (M6 closes this)**: M5 paints regardless of wake/sleep state. The `/frame` โ†’ `409` when asleep rule is added with `POST /state` in M6. Until then, a configured device boots ASLEEP per spec but `/frame` still paints because the state guard isn't wired yet. **Status**: โœ… verified 2026-06-14. Hardware JPEG decode + paint works end-to-end. Fixing it surfaced two stacked channel-swap bugs (commit 11ad249): the IDF JPEG decoder's `_RGB` element-order emits the RGB565 word in big-endian byte order (we now use `_BGR` for LE), and the ESP32-P4 DSI + TC358762 + Pi panel pipeline wants `[B, G, R]` in memory (we now pack the RGB888 framebuffer accordingly). Symmetric pixels like the white-on-black info screen masked both bugs; saturated solid colours exposed them. --- ## M6 โ€” State + Idle Timer **Acceptance**: `POST /state` toggles wake/sleep; `/frame` is 409 when asleep; idle timer fires after `idle_timeout_ms`. **How to verify** First make sure the device is configured (M4 must be done): ```bash curl -X POST -H "Content-Type: application/json" \ -d '{"viewport":"mudroom","scrypted":"http://host/cb","idle_timeout_ms":10000}' \ http:///config ``` Wake / sleep: ```bash # Backlight off: curl -i -X POST -H "Content-Type: application/json" \ -d '{"state":"sleep"}' http:///state # expect: 204 # Backlight on (loading-screen placeholder until M8): curl -i -X POST -H "Content-Type: application/json" \ -d '{"state":"wake"}' http:///state # expect: 204 # Idempotency: repeating either is also 204 with no side effects: curl -X POST -H "Content-Type: application/json" \ -d '{"state":"wake"}' http:///state curl -X POST -H "Content-Type: application/json" \ -d '{"state":"wake"}' http:///state # Both: 204 ``` `/frame` while asleep returns 409: ```bash curl -X POST -H "Content-Type: application/json" \ -d '{"state":"sleep"}' http:///state curl -i -X POST -H "Content-Type: image/jpeg" \ --data-binary @test-480x800.jpg \ http:///frame # expect: HTTP/1.1 409 Conflict, body "device asleep โ€” POST /state ..." ``` Idle timer: ```bash curl -X POST -H "Content-Type: application/json" \ -d '{"state":"wake"}' http:///state sleep 12 # idle_timeout_ms was set to 10000 above curl http:///state | jq .state # expect: "asleep" ``` When the timer fires, the serial log should print: ``` I (xxx) state: idle timer expired โ€” sleeping I (xxx) state: ASLEEP ``` Disabling the idle timer: ```bash curl -X POST -H "Content-Type: application/json" \ -d '{"idle_timeout_ms":0}' http:///config curl -X POST -H "Content-Type: application/json" \ -d '{"state":"wake"}' http:///state sleep 70 curl http:///state | jq .state # expect: still "awake" ``` Resetting on `/frame`: every successful paint restarts the idle timer. ```bash # Set short idle, then keep painting to keep awake: curl -X POST -d '{"idle_timeout_ms":10000}' \ -H "Content-Type: application/json" http:///config curl -X POST -d '{"state":"wake"}' \ -H "Content-Type: application/json" http:///state for i in 1 2 3 4 5; do sleep 8 curl -X POST -H "Content-Type: image/jpeg" \ --data-binary @test-480x800.jpg http:///frame done curl http:///state | jq .state # expect "awake" ``` `POST /state` always works regardless of whether `/config` has been posted โ€” `state` is just the screen's awake/asleep, decoupled from the `configured` flag. Outbound POST-to-Scrypted is the part gated on a scrypted URL being present. Bad input: ```bash curl -i -X POST -d '{"state":"middle"}' \ -H "Content-Type: application/json" http:///state # 400 curl -i -X POST -d 'not json' \ -H "Content-Type: application/json" http:///state # 400 ``` **Known gap (M7 closes this)**: when the idle timer fires the device transitions to ASLEEP locally but does NOT yet POST `{viewport,state:sleep}` to `/state`. That outbound POST lands with `state_client` in M7. **Status**: โœ… verified 2026-06-14. Wake/sleep toggle 204, idempotent repeats 204, `/frame` while asleep returns 409 with expected body, bad inputs 400, idle timer fires after `idle_timeout_ms`, `idle_timeout_ms:0` correctly disables the timer. --- ## M7 โ€” Touch + Outbound `/state` POST **Acceptance**: tap toggles wake/sleep locally; idle timer firing posts sleep; device POSTs `{viewport,state}` to `/state` for every local transition. **Test receiver** (run on the host you'll point `scrypted` at): ```bash # Single-file flask receiver that echoes every POST: pip install flask cat > /tmp/recv.py <<'EOF' from flask import Flask, request app = Flask(__name__) @app.post("/state") def state(): print("RX", request.get_json()) return "", 204 app.run(host="0.0.0.0", port=11080) EOF python3 /tmp/recv.py ``` Configure the device: ```bash curl -X POST -H "Content-Type: application/json" \ -d '{"viewport":"mudroom","scrypted":"http://:11080","idle_timeout_ms":10000}' \ http:///config ``` **Tap dispatch** - Tap while asleep โ†’ device wakes; receiver prints `RX {'viewport': 'mudroom', 'state': 'wake'}`. - Tap while awake โ†’ device sleeps; receiver prints same with `state: 'sleep'`. - Idle timer expires (10s here) โ†’ receiver prints `state: 'sleep'`. Each successful POST also logs on serial: ``` I (xxx) state_client: POST http://:11080/state {state:wake} -> 204 ``` **Failure path** - Stop the receiver, then tap. Serial: ``` W (xxx) state_client: POST http://:11080/state failed: err=ESP_OK status=-1 ``` Then `GET /state` shows `state_post_failures` incremented. - Local state change still happens (backlight toggles). The POST is best-effort, not a precondition. **Depth-1 queue with replace-on-full** Tap rapidly (faster than the receiver can ack) and confirm the receiver only sees the most recent state transition between in-flight POSTs. Intermediate flips are coalesced. With a 1s receiver delay, only the final state of each ~1s window should hit the receiver. **No POST when Scrypted-initiated** - `curl -X POST -d '{"state":"sleep"}' /state` โ€” receiver should print **nothing** (Scrypted already knows it initiated). - Same for `/state {wake}` and `/frame` (asleep โ†’ 409, no callback either). **No POST when no scrypted URL is set** - Before `/config` provides a Scrypted URL, tapping the screen does nothing outbound (no Scrypted URL to call). No queue entries dropped to disk. **Status**: โœ… verified 2026-06-14. FT5426 acks at 0x38; short tap toggles awake/sleep, โ‰ฅ1.5s hold opens the info overlay. Outbound `/state` POST gated on configured flag (untested without Scrypted). --- ## M8 โ€” Local Screens + touch long-press **Acceptance**: info screen on first boot; loading screen on every wake; long-press shows the info overlay. **Visual checks (panel-attached)** - Fresh flash โ†’ screen shows the info screen (~17 lines of `label value` pairs, white on black, auto-scaled). With no `/config` posted it reports the MAC-derived default name, `config no`, `state asleep`, `scrypt none`. - `POST /config` with viewport + scrypted โ†’ device transitions to ASLEEP, backlight off. - `POST /state {state:wake}` (or tap) โ†’ backlight on, `Loading...` centered until the first `/frame` lands. - `POST /frame` while AWAKE โ†’ loading screen replaced by the JPEG. - **Long-press (โ‰ฅ1.5s)** at any state โ†’ info overlay for 15 s with the full current config + state dump (name, host, ip, state, config, scrypt, orient, bright, idle, fw, up, frames, errs, heap, psram). - `POST /state {state:sleep}` (or idle timeout, or tap-while-awake) โ†’ backlight off. **Touch gestures** (no hardware button โ€” see Hardware note) - Short tap (<500 ms): toggle wake / sleep. - Long-press (โ‰ฅ1.5 s): info overlay for 15 s. - No factory-reset gesture. Use USB + `idf.py erase-flash` to clear NVS. **Hardware note**: ESP32-P4's strap pin GPIO35 is owned by EMAC TXD1 at runtime, and the Waveshare ESP32-P4-ETH doesn't expose any separate user button on a free GPIO. Both BOOT-button behaviours moved onto the touch panel (see M7). **Negative / edge** - Info overlay during AWAKE: the overlay paints over the live frame. Scrypted's next `/frame` (within ~1s in a normal stream) overwrites it. Acceptable: the operator sees the info for โ‰ค 1 frame interval then the live view resumes. - Info overlay during ASLEEP: backlight comes on for the overlay, then expires back to sleep when the 15 s timer fires. - Font fallback: any character outside the supported set (digits, period, colon, dash, slash, `L`, lowercase aโ€“z, space) renders as blank. Info-screen labels and IPv4 strings are all covered. **Status**: โœ… verified 2026-06-14. Info screen renders the full config + state dump; loading screen shown on wake when configured; info overlay returns to prior state when its 15 s timer expires. --- ## M9 โ€” Live Stream (`POST /stream`) **Acceptance**: โ‰ฅ 10 fps multipart MJPEG over a single chunked POST. **How to verify**: ffmpeg-driven test stream from a host, measure fps from the device's frame counter. **Status**: โฌœ pending. --- ## Integration & system tests (post-M9) Run these once all milestones are implemented and individually verified. The point is to exercise races, edge cases, and longevity that single-milestone tests miss. ### A. End-to-end with Scrypted - Real Scrypted-side Script (M7's scope): doorbell-event โ†’ `/state {wake}` โ†’ frame stream โ†’ idle timer fires โ†’ `/state {sleep}` callback โ†’ Scrypted stops. - Bound camera switching: change the binding in Scrypted, confirm next wake routes to the new camera. ### B. Race conditions - Concurrent tap-while-Scrypted-POSTs-`/state`: spam both, confirm the device converges (mutex serializes; last-write-wins). Use the wake/sleep counters in `/state` to track transitions. - Mid-stream tap-to-sleep: while Scrypted is streaming frames, tap to sleep. Inflight `/frame` should return 409. Confirm no half-painted frames, no re-wake. - Idle-timeout coincides with a fresh tap-wake: the second event wins. Verify by repeating with timing jitter. - Stale Scrypted sleep timer races a fresh wake callback: Scrypted-side `cancelPendingSleep` must work โ€” if a stale sleep lands after a wake, viewport sleeps; user taps again; recovery in one extra tap. ### C. Failure modes - Cable pull mid-frame: device idle-sleeps after `idle_timeout_ms`. On reconnect, mDNS re-advertises; Scrypted re-finds and continues. (Link-loss/recovery part verified 2026-06-14 with the device idle โ€” see M1 section. Mid-frame variant still pending.) - Scrypted unreachable on tap: device still toggles backlight, `state_post_failures` increments. Recovery: Scrypted comes back, next tap syncs. - DHCP lease change: device gets new IP, re-advertises via mDNS. The script's next 5-minute register fails against the stale host, which triggers its mDNS auto-heal โ€” browse, match by `mac` TXT, rewrite host, retry. Verified 2026-07-18 by saving a wrong IP: `host 10.0.13.99 -> 10.0.13.83 (mdns auto-heal)`. - `/state_post_failures` count should be observable via `GET /state`. ### D. Longevity - 24h soak with a slow camera stream (~1 frame every 10s): verify no memory leaks (`free_heap` / `free_psram` stable in `/state`), no decode_errors growing. - Frame storm: 30 minutes at max sustainable fps. Verify watchdog never fires, no resets. ### E. Power & boot - PoE injector cycle: device boots clean, gets DHCP, re-registers via mDNS, no manual intervention needed. - Brown-out (PoE on edge of spec): device should reboot cleanly, not corrupt NVS. ### F. Negative protocol - Garbage JSON to `/config` โ†’ 400. - `/frame` with `Content-Type: image/png` โ†’ 400. - `/state` with `{"state":"middle"}` โ†’ 400. - `/frame` with body > 1 MB โ†’ 413. - Two concurrent `/frame` posts โ†’ one wins, the other 503. ### G. Multi-viewport Once at least two physical units are configured: confirm each routes its callback to Scrypted with its own `viewport` name; confirm Scrypted's discovery map has both IPs; confirm a camera event on the wrong-named viewport is correctly ignored on the right one. ## Performance review playbook Repeatable methodology for "is the device still hitting its baseline" and "where did the time go." Run after every commit that touches the data plane. ### Per-session capture Three log streams, all timestamped: ```sh # Firmware serial โ€” 30-frame window summaries idf.py monitor | tee fw-$(date +%s).log & # /state poll โ€” windowed stats + glass-to-glass anchor while true; do curl -s http://VIEWPORT_IP/state echo sleep 1 done | tee state-$(date +%s).log & # Scrypted plugin console โ€” copy/paste from the web UI when done. ``` Trigger a wake event (motion or "Wake now" button). Let it run two minutes. Stop captures. ### What the firmware serial says Every 30 painted frames: ``` 30 frames over 1.8s: 16.5fps 2.95MB/s avg-jpeg=180KB | lock min/avg/max=8/9/12us | recv min/avg/max=21000/35000/48000us | dec min/avg/max=5100/5600/6200us | paint min/avg/max=38/45/61us | idle min/avg/max=120/8500/40000us ``` ### What `/state` says (Phase 4 onward) ```json { "stream": { "frames": 30, "bytes": 5400000, "window_us": 1800000, "recv_min_us":21000,"recv_avg_us":35000,"recv_max_us":48000, "dec_min_us":5100,"dec_avg_us":5600,"dec_max_us":6200, "paint_min_us":38,"paint_avg_us":45,"paint_max_us":61, "idle_min_us":120,"idle_avg_us":8500,"idle_max_us":40000, "last_paint_event_us_low": 2148503712 } } ``` `last_paint_event_us_low` is the script's monotonic ยตs low 32 bits at the wake event that produced the most recent painted frame. Glass-to-glass is `(scripts_now_us_low - last_paint_event_us_low)` with 32-bit wrap. ### What the Scrypted console says On wake: ``` event MotionSensor -> "kitchen": fired at +0ms (wake) stream "kitchen": start +0ms stream "kitchen": socket connect requested +1ms snapshot "kitchen": start +1ms snapshot "kitchen": takePicture +320ms stream "kitchen": socket connect open +24ms snapshot "kitchen": transform +458ms via sharp (217KB) snapshot "kitchen": post sent +459ms snapshot "kitchen": post acked +551ms โ† first user-visible paint stream "kitchen": first ffmpeg frame +780ms (jpeg=210KB) stream "kitchen": first socket.write +781ms ``` Every 5s during an active stream: ``` firmware "kitchen": fps=16.7 2.99MB/s | recv=...us | dec=...us | paint=...us | idle=...us | g2g=140ms ``` ### Investigation thresholds (alarms) | Symptom | Probable cause | First check | |---|---|---| | Painted fps sustained < 12 | Upstream starvation | `idle_avg_us` in firmware log โ€” high = ffmpeg behind | | `decode_p95 > 8ms` | HW decoder pathology | Recent `decode_errors` delta; PSRAM cache pressure | | `paint_p95 > 200ยตs` | DPI or DMA stall | Look for `DSI` warnings in serial; PSRAM bandwidth | | `idle_avg > 50ms` | ffmpeg falling behind | Check Scrypted CPU; try lower-rate camera substream | | `g2g_p95 > 500ms` | User-facing regression | Bisect: is the gap in script-side or firmware-side? | | Any non-zero `decode_errors` delta | JPEG corruption | TCP retransmit storm or memory corruption | ### Tools `grep`, `awk`, `gnuplot`. Anything beyond that is over-engineering for a single device's logs.