| Age | Commit message (Collapse) | Author | Files | Lines |
|
wall-clock
|
|
once
|
|
Cameras occasionally drop their H.264 stream mid-event — RTSP source
rotation, brief network glitch, etc. ffmpeg exits clean and the script
previously logged the exit and stopped feeding /frame, leaving the
panel stuck on the last successful frame until the next camera event
fired a fresh startStream.
Restructure the spawn into a closure-captured spawnFfmpeg() that the
on('close') handler can call again. Rolling restart cap of 5 per 60 s
prevents tight loops if the source is genuinely down.
On clean exit during an active stream: log + setTimeout(spawnFfmpeg, 250).
On too-many restarts: log a warning, stopStream, wait for the next
camera event.
The streams map no longer stores the ffmpeg proc (it changes across
restarts); abort.signal kills whatever's current via its listener.
|
|
|
|
Bumping WND/SND_BUF to 32 KiB + RECVMBOX to 16 + SACK on regressed
ttfb from ~350µs to ~1.2ms across every frame and introduced periodic
250+ms body stalls (TCP retransmit on the larger window). Mean body
unchanged. Reason: Scrypted's per-frame fetch() opens a fresh TCP
socket, so the larger receive window just slows slow-start on every
new connection rather than helping.
Reverted to lwIP defaults. The real fix is HTTP keep-alive on the
Scrypted side; will revisit window tuning once persistent connections
are in place. README backlog updated with what we learned.
|
|
body is now the only big lever
|
|
Per-frame paint cost drops from ~24 ms to ~45 µs (≈500× faster) by
enabling num_fbs=2 on the DPI panel and decoding straight into the
back framebuffer. Measured on the bench:
before: lock=7us ttfb=370us body=40ms dec=6ms paint=24ms post=35us = ~70ms / ~14fps
after : lock=7us ttfb=330us body=38ms dec=6ms paint=42us post=25us = ~45ms / ~22fps
How the win actually lands:
- num_fbs=2 in the esp_lcd_dpi_panel_config_t makes the IDF driver
allocate two framebuffers and stream from one while we fill the
other.
- display_back_buffer() returns the inactive fb pointer + its size.
- jpeg_decoder_decode() now accepts a caller-provided destination
buffer instead of owning its own scratch. http_api passes the panel
back-fb so the hardware JPEG decoder writes BGR888 pixels straight
into where the DSI will eventually scan from. Zero memcpy in the
hot path.
- display_flip_back_buffer() calls esp_lcd_panel_draw_bitmap with the
fb pointer. Because the buffer is inside the panel's own fb range,
the IDF driver skips its memcpy and just does a cache writeback +
swaps cur_fb_index. The actual flip happens on the next vsync,
asynchronously — the call returns in microseconds.
The remaining ceiling is network body time (~38 ms for ~210 KB JPEGs)
and the hardware decoder (~6 ms). Per-viewport JPEG quality (smaller
files = shorter body) is the next lever; everything firmware-side is
already at or near floor.
Also drop the old static jpeg output scratch + JPEG_DECODER_MAX_OUTPUT_BYTES
constant — nothing references them anymore.
|
|
Two fixes plus a README refresh:
1. scrypted: pushStreamFrame previously reset the per-stream idle timer
on every successful /frame response. That made the timer anchored to
"frames are flowing" rather than to "the camera event that triggered
the stream", so a continuously-streaming source would never let the
stream time out. Removed the reset. The startStream → stopStream(false)
cancel-and-replace path on repeated events still keeps the stream
alive while the event keeps firing; idle (no new events) now actually
ends the stream at idle_timeout_ms.
2. firmware: break the previous coarse recv/dec/paint timing into
lock : try_lock returned
ttfb : first httpd_req_recv chunk landed
body : remaining bytes received
dec : hardware JPEG decode
paint : esp_lcd_panel_draw_bitmap returned
post : state-counter bookkeeping + unlock
Logged every 10 frames at INFO. Splits the previously-fat recv bucket
into TCP/HTTP handshake overhead (ttfb) vs wire-time (body), and
surfaces any tail bookkeeping cost.
3. README: replace the stale "5 fps ceiling caused by CPU RGB conversion"
guess with the actual measured per-phase budget and re-rank the
backlog accordingly. Double-buffering the panel (paint 24 ms → ~2 ms)
is now the highest-value next move; the previously-listed DMA-2D
rewrite is moot because the CPU loop is already gone.
|
|
|
|
Architectural rework of the /frame hot path. Scrypted now ships every
JPEG already scaled + rotated to the panel's native dimensions (read
from the firmware's /state response so nothing is hardcoded on the
Scrypted side); the firmware decodes the JPEG straight into a BGR888
buffer that's directly draw_bitmap'able by the DSI driver, with zero
CPU pixel work and zero rotation work in between.
Firmware
- jpeg_decoder now uses JPEG_DECODE_OUT_FORMAT_RGB888 +
JPEG_DEC_RGB_ELEMENT_ORDER_BGR. Output buffer sized for 800*480*3.
- New display_present_bgr888() is a one-liner that hands the decoder's
output straight to esp_lcd_panel_draw_bitmap.
- /frame handler validates dimensions against the panel-native
VIEWPORT_PANEL_WIDTH x VIEWPORT_PANEL_HEIGHT (was effective_dims
branching on orientation). Returns 400 if it's anything else.
- /state JSON adds panel_width + panel_height so Scrypted can read them
without hardcoding board-specific knowledge.
- display_present_rgb565 + s_rot_buf stay for the local-screens cold
path (info screen, loading) which still does its own CPU conversion +
rotation — infrequent enough that it's not worth the rewrite.
Scrypted
- startStream() GETs /state at stream-start time, caches panel_width
and panel_height in viewport storage, and uses them as the ffmpeg
scale target. Falls back to cached or 800x480 if /state is mid-reboot.
- For portrait viewports the ffmpeg pipeline now does
scale=H:W:flags=lanczos,transpose=1 so the JPEG arrives pre-rotated
90° CW into panel-native dimensions. Landscape is just scale=W:H.
- No more in-firmware rotation; Scrypted is the single source of truth
for "how do I get this camera frame into a panel-shaped JPEG".
Expected ceiling lift: ~5 fps → ~10 fps, gated by the JPEG decoder
hardware throughput instead of the CPU rgb565→bgr888 + rotation loop.
|
|
first, OTA next, ...)
|
|
|
|
Replace the setInterval(cam.takePicture) loop with a single ffmpeg
child per active stream that:
1. pulls the camera's video stream via getVideoStream + converts to
ffmpeg input args via ScryptedMimeTypes.FFmpegInput,
2. transcodes inline: scale=W:H:flags=lanczos, fps=N, c:v=mjpeg q:v=2,
-f image2pipe to stdout,
3. demuxes JPEG frames from stdout (FFD8…FFD9 boundaries),
4. POSTs each frame to the firmware's existing /frame endpoint with
the same single-flight semantics as before — surplus frames are
dropped silently and counted for a periodic skip-rate log so the
operator can dial frame_interval_ms to where the firmware sustains.
Wins vs. the snapshot path:
- The camera's main H.264 encoder is producing keyframes for free; we
pay near-zero on the source side instead of triggering a full snapshot
fetch per frame (the cost that capped us at ~2 fps).
- ffmpeg sustains the requested fps as long as the firmware can decode
+ paint at that rate. Measured ceiling is the firmware, not the
source, so the actual fps lands near the panel's true limit.
- Lanczos + q:v 2 keeps the picture sharp end-to-end.
stopStream now SIGTERMs the ffmpeg child alongside aborting outstanding
fetches. Each successful paint also nudges the Scrypted-side idle timer
so a healthy stream stays open as long as frames are landing.
Drop the now-unused pushFrame + resizeJpegHQ + the old setInterval
machinery.
|
|
Stopped asking cam.takePicture for a specific dimension. Most camera
plugins default snapshot JPEGs to q≈75 and do a quick bilinear downscale
from native (1920x1080 or so) to whatever we requested, which looked
visibly worse than the H.264 keyframe from the same camera at the same
panel resolution.
pushFrame now:
1. takePicture without picture.{width,height} → native-res JPEG
2. spawn ffmpeg child_process: scale w:h:flags=lanczos, mjpeg q:v 2
3. POST the result.
q:v 2 is near-lossless; Lanczos is a sharper downscale than the camera
plugin's default. Adds ~10-20 ms ffmpeg work per frame on a real CPU
(noise-level on a Pi 4), well under the snapshot-source ceiling that
already gates pushFrame at ~500 ms.
|
|
(33 ms)
|
|
On wake, display_wake() turned the backlight on first and then the
local_screens_show_loading / show_info call repainted the framebuffer.
For a few ms the user saw the previous /frame's contents (the last
camera snapshot from the prior wake cycle) before the new screen
landed. Swap the order: paint the placeholder first, then turn the
backlight on.
|
|
snapshot-based pipeline
|
|
The script-reload re-discovery passed `name: nativeId` to
onDeviceDiscovered, which Scrypted honored by renaming the existing
"kitchen" device to its vp_xxx nativeId. The subsequent registerViewport
fallback then saw a non-empty `v.name` (the nativeId) and used it as the
viewport name when POSTing /config, sending the firmware /config with
viewport="vp_mqek8i55_3z3s" instead of "kitchen".
Fix two things:
1. start()'s re-discovery now passes the persisted display_name (set on
createDevice + every Settings save) as the device's `name`, so the
user-chosen kitchen name survives script reloads.
2. registerViewport now prefers storage's display_name over v.name —
the storage value is the authoritative one, v.name is just a render
that can briefly drift to the nativeId during reload.
|
|
|
|
|
|
mDNS auto-resolve
- Move "frame push interval" from the parent's global setting to a
per-viewport child setting (different cameras want different rates).
Clamped to ≥33 ms (~30 fps max). Default still 1000 ms (1 fps).
- New "Wake triggers" multi-select on each viewport: doorbell, motion,
person. Default = all three. Clear all of them for tap-only mode.
handleCameraEvent now gates by this set; clearing it cleanly turns a
viewport into a tap-only display with no auto-wake.
- Add a "Status (live)" group at the bottom of each viewport's Settings
page that fetches /state + /config in parallel (1.5 s timeout) and
surfaces every field — name, mac, ip, awake/asleep, configured,
uptime, frame + error counters, resolution, free heap/psram, firmware
version, registered scrypted URL. Offline devices say so cleanly.
- Drop mDNS auto-resolve: no more lookupMdns / refreshHostFromMdns,
no per-viewport "auto-resolve via mDNS" toggle, no `dns` require.
The host field is just an operator-set string now. The README
documents the manual lookup commands (dns-sd, avahi-resolve) so
finding the device IP stays a one-liner.
- Parent's Settings page is now informational-only (viewport count +
callback base URL); per-viewport tuning lives on each child.
|
|
|
|
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.
|
|
|
|
- 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.
|
|
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.
|
|
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.
|
|
script reload
|
|
endpointManager.getInsecurePublicLocalEndpoint
|
|
|
|
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").
|
|
|
|
being a separate injection
|
|
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.
|
|
@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.
|
|
|
|
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.
|
|
\`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.
|
|
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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].
|
|
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
|
|
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.
|
|
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.
|
|
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).
|
|
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.
|