src.nth.io/

summaryrefslogtreecommitdiff
path: root/scrypted
AgeCommit message (Collapse)AuthorFilesLines
2026-06-19scrypted: stamp SCRIPT_VERSION = 1063a4eLuke Hoersten1-1/+1
2026-06-19scrypted: user-controllable JPEG quality (1–31) in the viewport SettingsLuke Hoersten1-2/+22
Adds a "JPEG quality" field under Display alongside frame_interval_ms, brightness, etc. ffmpeg -q:v range 1..31 — 1 = highest quality + biggest JPEG (~140KB at panel-native), higher numbers = smaller + lossier. Plumbed through both the live-stream encoder and the snapshot one-shot encoder. Reads from viewport storage; defaults to 1 if unset; clamps to [1, 31] on read so an out-of-range input from the UI doesn't crash ffmpeg. Changing the value triggers the existing onBindingChanged debounce which already restarts the live stream — picks up the new quality within ~300ms of Save.
2026-06-19scrypted: HTTP keep-alive via node:http Agent + bump JPEG quality 2→1Luke Hoersten1-74/+115
Two changes in one commit because the keep-alive refactor reshapes the fetch surface and the quality bump piggybacks naturally on it. #1 — Keep-alive ScryptedViewportProvider now uses node:http (always available, no require sandbox dance like undici) with a per-host Agent: keepAlive: true, keepAliveMsecs: 30s, maxSockets: 2. Pool size 2 matches the firmware's two-socket pipelining capacity so frame N+1 can begin uploading on socket B while frame N is still being decoded on socket A. Reusing the socket skips the SYN+SYN-ACK+ ACK round-trip on every POST — on a quiet LAN that's ~1ms saved per frame, and far more on the p95 tail where TCP slow-start was driving 26-35ms net_up spikes in the measured data. httpRequest() wraps http.request to expose tHeaders + tDone (so we keep the existing per-stage script-side timing intact) and returns { status, headers, tHeaders, tDone }. Both pushStreamFrame and postJSON migrated. The remaining fetch() calls (GET /state, GET /config from the UI, and the one-shot snapshot POST) are non-hot paths — left as fetch for now. #2 — JPEG quality ffmpeg -q:v 2 was the de facto "visually lossless" setting in earlier notes; bumping to -q:v 1 squeezes one more notch of quality out of the mjpeg encoder. JPEG size grows ~10-15%; with NODELAY landed and fw_recv at ~22ms for 128KB we have plenty of headroom for the bigger bodies. Applied to both the live-stream encoder and the one-shot snapshot encoder.
2026-06-19scrypted: stamp SCRIPT_VERSION = 504360aLuke Hoersten1-1/+1
2026-06-19disable Nagle on /frame socket + add min/max/worst-frame to script logLuke Hoersten1-14/+34
#1: TCP_NODELAY for /frame ESP-IDF lwIP defaults Nagle ON. /frame is the worst Nagle workload: ~140KB body POST followed by an empty 204 response, no follow-up data either way. Both the final partial-MTU body packet and the response packet can sit in the kernel send buffer up to 40ms waiting for an ACK that the other side is delaying-ACKing — silently adding tens of ms to every frame's wall time and showing up as a fat net_up bucket on the Scrypted side. setsockopt(TCP_NODELAY) at handler entry on every /frame. Cheap and idempotent. Includes the /state and /config sockets too — those are infrequent but small responses also benefit. #2: min/max + worst-frame decomposition in the Scrypted log The p50/p95 line answered "what's typical" but not "what happened in the worst frame this window". Now every 10-fetch log adds: - min and max columns alongside p50/p95 per bucket - a worst-frame breakdown row: identifies the single slowest fetch in the window and decomposes its wall time across all stages. Answers "did the slow frame get gated by emit→post (ffmpeg backed up), net_up (TCP/handshake spike), or fw_dec (large JPEG)?" directly, instead of us inferring from percentiles. Together these are the prerequisites for evaluating whether further optimization (HTTP keep-alive, chunked streaming) is worth pursuing.
2026-06-19scrypted: stamp SCRIPT_VERSION = 846e4dbLuke Hoersten1-1/+1
2026-06-19unified end-to-end per-frame instrumentation + version stampsLuke Hoersten1-12/+79
Cross-side timing was opaque: script saw "req=70ms" but couldn't split TCP/dispatch overhead from firmware decode time, and firmware serial logs and Scrypted console logs couldn't be correlated. Firmware /frame handler now emits Server-Timing on every response with per-stage breakdown: Server-Timing: recv;dur=X, dec;dur=Y, paint;dur=Z, post;dur=W, handle;dur=total Script parses it, joins by X-Frame-Seq implicitly (one POST per seq), derives net_up = req − fw_total, and logs a multi-line p50/p95 breakdown every 10 fetches: fetch "kitchen" #10 (jpeg=137KB) wall p50=65ms p95=140ms emit→post p50=0ms p95=2ms (queue wait) req p50=62ms p95=135ms (fetch → Response headers) net_up p50=43ms p95=110ms (TCP + body wire + dispatch) fw_recv p50=12ms p95=18ms (body off the wire) fw_dec p50=6ms p95=8ms (hardware JPEG) fw_paint p50=0.1ms p95=0.2ms (backbuffer flip) fw_post p50=0.4ms p95=0.6ms body-read p50=1ms (drain — empty body) inflight d0=8 d1=2 d2=0 stale-drops=0 Plus version stamps on both sides for the "is the user on the right code" question: - CMakeLists: PROJECT_VER = git short hash + -dirty marker if dirty. esp_app_get_description()->version exposes it at runtime. Boot log: "Scrypted Viewport boot (v0.1.0 build=4cf36e2-dirty)". - TS: SCRIPT_VERSION const at the top, bumped per commit, logged at script-eval: "Scrypted Viewport up (script=4cf36e2). Callback ..."
2026-06-19scrypted: snapshot-then-stream for sub-second first paint on wakeLuke Hoersten1-0/+79
ffmpeg cold-start + RTSP connect + first-keyframe-wait puts 0.5–3 seconds of dead air between a tap/event and the first stream frame landing on the panel. Most of that is unavoidable for the live stream, but Scrypted can almost always produce a snapshot in 50–300ms via camera.takePicture() (often a cache hit). New pushSnapshot path: - Fires in parallel with the main stream spawn — does NOT delay the stream path even if takePicture is slow. - takePicture → quick one-shot ffmpeg with the same transpose+scale filter chain → POST /frame, all under a 2s hard cap. - Uses the shared X-Frame-Seq counter: if a stream frame beats the snapshot to the firmware decoder (unlikely on cold start, possible on warm reconnect), the firmware silently stale-drops the snapshot. Logs "beaten by stream frame" when that happens so we can see it. - Errors are swallowed silently — a camera that doesn't support snapshots just falls through to the normal stream-only path. Net effect: the panel shows the camera near-instantly on every wake, then the snapshot gets replaced by the first ffmpeg frame whenever it lands.
2026-06-15scrypted: make undici dispatcher optional, fall back to per-POST socketsLuke Hoersten1-17/+34
Scrypted's plugin sandbox doesn't always expose 'undici' as a require()-able module — it's bundled into the runtime but only reachable through globalThis.fetch internals, not as a CommonJS module. require('undici') threw and broke registerViewport entirely. Wrap the require in try/catch and cache the result. When undici is available we still get keep-alive + 2 socket pool for pipelining; when it isn't, dispatcherFor returns undefined and we fall back to plain fetch with a fresh socket per POST. The X-Frame-Seq deduplication on the firmware side still works regardless — even without socket pooling, two fetch() calls can be in flight on two separate ephemeral sockets concurrently.
2026-06-15scrypted: per-fetch latency breakdown + p50/p95 windowed logLuke Hoersten1-11/+70
Replaces the single-sample "fetch #N: Xms" log with an aggregated window every 10 fetches showing where the wall-clock budget actually goes: fetch "kitchen" #10 (jpeg=147KB) wall p50=52ms p95=78ms | emit→post p50=0ms p95=14ms | req p50=51ms p95=76ms | body-read p50=0ms | inflight d0=4 d1=5 d2=1 | stale-drops=0 Buckets: - emit→post: ffmpeg pushed JPEG to stdout → fetch() actually called. Nonzero = inFlight queue was full, frame waited. - req: fetch() → Response headers (body upload + firmware ttfb + decode start + status line). - body-read: Response → drained. Should be ~0 (empty body responses). - wall: total of above three (matches the old single-sample number but with p95 visible). Plus inflight depth histogram (d0/d1/d2 = how many other POSTs were in flight when this frame queued) — d0 dominating means pipelining isn't doing anything; d1/d2 mass means real overlap. stale-drops counts X-Frame-Drop responses so we can see how often the firmware rejected an out-of-order pipelined frame.
2026-06-15add X-Frame-Seq monotonic ordering for pipelined /frame POSTsLuke Hoersten1-6/+14
With two /frame POSTs in flight on separate sockets, the firmware's JPEG decoder mutex serialises decode but FreeRTOS semaphore acquisition is not FIFO — under jitter the later-arriving older frame can grab the lock first and paint over the newer one, producing brief "panel travels backward in time" glitches. Scrypted side: - Per-viewport monotonic frameSeq counter, sent as X-Frame-Seq on every /frame POST. Reset on stopStream so each new stream starts at 1. Firmware side: - s_last_painted_seq tracks the highest seq we've painted. Frame arrives, mutex acquired, body received — if seq <= s_last_painted_seq the frame is dropped (200 OK + X-Frame-Drop: stale-seq header) and the mutex released without touching the back buffer. - s_last_painted_seq resets to 0 on POST /state {wake} so the next stream's seq=1 isn't rejected because the previous session reached a higher counter. - Missing/zero X-Frame-Seq (legacy clients) skips the check entirely — preserves pre-pipelining behaviour.
2026-06-15pipeline two /frame POSTs + HTTP keep-alive on the Scrypted sideLuke Hoersten1-6/+46
Decouples network upload from firmware decode-and-paint. Before, the inFlight boolean guard meant Scrypted sent frame N, waited for the full ~80ms response (~40ms body upload + ~20ms decode + ~50µs paint + ack), THEN sent frame N+1. The next ffmpeg frame arriving during that window got dropped. After: - ScryptedViewportProvider keeps a per-host undici Agent with keepAliveTimeout 30s, connections:2, pipelining:0. Sockets stay open across /frame, /state, /config — saves the SYN+SYN-ACK+ACK round-trip every POST (small on LAN but real on Wi-Fi). - Stream loop's inFlight boolean is now a counter capped at 2 so frame N+1 can begin uploading on socket B while frame N is still being decoded on the device via socket A. Roughly doubles effective throughput when body upload time dominates. Firmware side: - esp_http_server max_open_sockets bumped 7→4 explicitly: 2 for pipelined /frame + 2 spare for concurrent /state and /config slots. The JPEG decoder mutex still serialises decode (only one /frame can be decoding at a time); this change only unblocks the network half of the pipeline.
2026-06-15scrypted: distinguish backpressure vs source-limited stream rateLuke Hoersten1-3/+12
The single warning conflated two unrelated symptoms: - HTTP/POST backpressure (drops accumulating because the firmware or network can't keep up) — fix is to raise frame_interval_ms. - Source-limited rate (ffmpeg's fps filter or the camera substream produces fewer frames than requested) — raising the interval makes this worse, not better. Now: drops > 25% of target prints "backpressure" advice; otherwise delivered < 60% of target prints "source-limited" with the correct hint that the camera/filter is the limit, not the firmware. The 600ms-interval case where we get 1.5 fps delivered with zero drops no longer prints the (wrong) "raise interval" suggestion.
2026-06-15scrypted: judge stream health by delivered fps not raw drop countLuke Hoersten1-10/+14
Previous threshold was "log if drops > 25% of target rate". At 500ms interval (2 fps target) that fired at 0.5+ dropped fps — but a displayed rate of 1.5 fps is fine, the panel still updates every ~666ms. The warning told the user to "raise frame_interval_ms" when nothing was actually wrong. Track sentFrames separately and only warn when delivered rate falls under 75% of target. Drop rate is now informational context in the log line, not the trigger.
2026-06-15scrypted: only warn about dropped frames when drops exceed 25% of target rateLuke Hoersten1-3/+11
ffmpeg's fps filter sometimes emits timestamp-clumped pairs at low rates; the single-flight POST guard correctly drops the second one and we logged a "raise frame_interval_ms" warning for every such event. At 500ms interval (2 fps target) a single drop produced a 0.6 fps warning even though we were still painting the requested rate. Suppress the message unless drops exceed a quarter of the configured rate. Above that threshold there's real backpressure worth knowing about; below it's just decimation noise.
2026-06-15scrypted: cut ~10s glass-to-glass latency by picking the low-latency ↵Luke Hoersten1-5/+25
substream + dropping queued frames Two compounding causes of the lag: - destination:"remote-recorder" returned the high-bitrate main encoder with the camera's own ~10s prebuffer baked in. Walk substreams low-resolution → medium-resolution → local → remote → remote-recorder and take the first that resolves. The low-res preview is what Scrypted itself uses for grid views — it's near-realtime. - ffmpeg's output queue had no drop policy, so whenever the encode + push pipeline fell behind for a second the queue just kept growing, and we'd play back a steadily-older view of the world. Added -fps_mode drop so late frames hit the floor. Plus a few input-side latency knobs (-probesize 32, -analyzeduration 0, -avioflags direct, -fflags +discardcorrupt) so ffmpeg stops sitting on the first chunk to probe the stream layout.
2026-06-15scrypted: render wake/sleep as boolean toggles instead of unrendered buttonsLuke Hoersten1-4/+11
type:"button" Settings get silently dropped by Scrypted's renderer — they were in the model but never appeared in the UI. Switch both to type:"boolean" so they show as toggles. The toggle acts as a one-shot trigger: flipping it on fires the wake/sleep action and the next getSettings() call returns value:false so it's armed again.
2026-06-15scrypted: log the exact ffmpeg filter chain on stream startLuke Hoersten1-0/+8
Triage signal for the persistent "expected 800x480, got 480x800" dimension mismatch. The error has survived two filter rewrites; logs on the user's side still reference old script.js line numbers, which suggests the Scrypted Script editor isn't picking up re-pasted code. Emit the orientation + panel dims + actual `-vf` string once per stream start. If the log shows up, we know the latest code is live and the problem is the filter; if it doesn't, the script never updated.
2026-06-15scrypted: rotate-before-scale so portrait JPEGs report 800x480Luke Hoersten1-2/+10
The portrait filter chain was scale=480:800,transpose=1. Output frame dimensions came out 800x480 (correct) but the mjpeg encoder was occasionally writing the pre-transpose 480x800 into the JPEG SOF marker, so the firmware's dimension check rejected the frame: expected 800x480, got 480x800 Reordering to transpose=1,scale=800:480,setsar=1 makes the post-filter dims explicit and unambiguous — transpose first changes the frame to 800x480, scale re-asserts it, setsar clears any leftover non-1:1 aspect ratio metadata. The encoder now writes 800x480 into SOF every frame.
2026-06-15scrypted: manual wake/sleep buttons in the viewport Settings pageLuke Hoersten1-4/+36
Adds an "Actions" group with two buttons: - Wake now: starts a stream for the bound camera right now, bypassing trigger filters. Useful for verifying the panel + camera link without having to walk in front of a motion sensor. - Sleep now: tears down the active stream and POSTs /state {sleep}. Drops the private modifier from streams/streamStarting/startStream/ stopStream so the child Viewport can drive them — same package, no encapsulation lost.
2026-06-15scrypted: restart live stream when settings changeLuke Hoersten1-3/+16
onBindingChanged previously stopped the active stream and waited for the next camera event to relaunch. Changing orientation, frame interval, or camera meant the user saw no immediate effect — the display sat dark until the next motion/doorbell trigger. Now if a stream was live at change-time we relaunch it right after registerViewport pushes the new /config. Guarded by streamStarting so the relaunch doesn't race with a concurrent camera-event-triggered start.
2026-06-15scrypted: 5s HTTP timeout + don't relaunch active stream on every eventLuke Hoersten1-2/+25
Two changes to handle a burst of motion events without piling up startStream calls and tripping the 1s /state-POST timeout: - HTTP_TIMEOUT_MS 1000 → 5000. The firmware's single httpd task processes one TCP connection at a time; under heavy /frame streaming a /state {wake} can queue behind 1–3 in-flight /frames before landing. 1s was too tight. - handleCameraEvent now ignores repeat triggers if a stream is already live for that viewport (this.streams.has(name)) or already starting (streamStarting set). Sustained motion fires MotionSensor every ~500ms; we used to launch a fresh startStream each time, racing with the previous one's ffmpeg spawn + state POST.
2026-06-15instrumentation: firmware idle gap between frames + Scrypted per-fetch ↵Luke Hoersten1-0/+12
wall-clock
2026-06-15scrypted: debounce onBindingChanged so a multi-field Settings save registers ↵Luke Hoersten1-6/+17
once
2026-06-15scrypted: auto-restart ffmpeg if it exits while the stream timer is aliveLuke Hoersten1-58/+86
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.
2026-06-15scrypted: rewrite single-flight drop log — firmware isn't the ceiling anymoreLuke Hoersten1-1/+1
2026-06-15scrypted: don't reset idle timer on each painted frame + finer timingLuke Hoersten1-9/+6
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.
2026-06-15firmware: zero-copy JPEG → BGR888 → DSI hot path; Scrypted pre-rotatesLuke Hoersten1-3/+31
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.
2026-06-15scrypted: suppress ffmpeg SIGTERM-flush noise from teardownLuke Hoersten1-1/+7
2026-06-15scrypted: live ffmpeg streaming replaces snapshot pollingLuke Hoersten1-89/+139
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.
2026-06-15scrypted: snapshot quality — fetch native res, resize + re-encode via ffmpegLuke Hoersten1-5/+42
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.
2026-06-14scrypted: default frame interval 500 ms (~2 fps) — measured ceiling for ↵Luke Hoersten1-1/+1
snapshot-based pipeline
2026-06-14scrypted: persist display_name across script reloads + prefer it over v.nameLuke Hoersten1-7/+13
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.
2026-06-14scrypted: single-flight pushFrame + better error reportingLuke Hoersten1-3/+22
2026-06-14scrypted: drop stale mDNS UI text from create form + commentsLuke Hoersten1-7/+8
2026-06-14scrypted: per-viewport frame interval + trigger picker + live status; drop ↵Luke Hoersten2-84/+135
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.
2026-06-14scrypted: register child viewports as SmartDisplay typeLuke Hoersten1-2/+2
2026-06-14scrypted: guard against empty v.name in register + log pathsLuke Hoersten1-6/+25
After the first camera event fires on a freshly-created viewport, v.name sometimes resolves as "" (Scrypted device-record load racing with event delivery). registerViewport then POSTs /config with viewport="" and the firmware returns 400 "viewport must be a non-empty string". The handler retries on the next callback so it eventually self-heals, but the log is noisy and we'd needlessly fail one register cycle. Mirror the canonical display name into device storage (display_name) on createDevice + on every successful register, and have registerViewport + attachListener fall back to that storage value when v.name is empty. No more empty-name POSTs to the firmware.
2026-06-14scrypted/diagnostic: probe for mDNS modules ahead of auto-discoveryLuke Hoersten1-48/+22
2026-06-14scrypted: re-discover children at start so their storage rehydrates on ↵Luke Hoersten1-4/+16
script reload
2026-06-14scrypted: use nativeId (not numeric id) for ↵Luke Hoersten1-1/+5
endpointManager.getInsecurePublicLocalEndpoint
2026-06-14scrypted: discover device before writing its storage in createDeviceLuke Hoersten1-13/+13
2026-06-14scrypted: revert to declare-const for SDK names — diagnostic confirmed ↵Luke Hoersten1-13/+10
they're injected The probe in scrypted/diagnostic.ts confirmed @scrypted/core 0.3.147 injects every name we need (ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, systemManager, deviceManager, mediaManager, endpointManager, log, device, require, exports) as top-level scope variables. Notably sdk.ScryptedDeviceBase IS undefined — the classes are separate injections, not properties on the sdk object. So the correct pattern is `declare const X: any` for each (declarations erase at compile time → free identifiers at runtime → bound to the scryptedEval-injected values), not `const X = sdk.X` (which was undefined and tripped "Class extends value undefined").
2026-06-14scrypted: add diagnostic.ts to probe scriptedEval scope on user's core 0.3.147Luke Hoersten1-0/+51
2026-06-14scrypted: pull SDK classes off the sdk global instead of relying on each ↵Luke Hoersten1-10/+13
being a separate injection
2026-06-14scrypted: declare runtime SDK names directly as globalsLuke Hoersten1-21/+19
The scryptedEval sandbox pre-injects SDK names (ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, systemManager, deviceManager, mediaManager, endpointManager, log, device, sdk, require) into the script's scope. The previous attempt destructured them from `sdk`, but on user's Scrypted 0.143.0 / @scrypted/core 0.3.147 something in the compile pipeline was still emitting a `require('@scrypted/sdk')` that failed to resolve. Switch to `declare const <name>: any` for each runtime value — the declarations fully erase at compile time, no require is emitted, and the values come straight from the sandbox scope.
2026-06-14scrypted: rewrite top to use globals — Scripts plugin can't resolve ↵Luke Hoersten1-17/+33
@scrypted/sdk import The Scripts plugin sandbox (in @scrypted/core) doesn't resolve ESM imports of npm modules — saving the script with `import sdk, {...} from '@scrypted/sdk'` produced: Error: Cannot find module '@scrypted/sdk' ... at t.scryptedEval (.../scrypted-eval.ts:102:29) Fix: destructure runtime values from the `sdk` global the plugin injects, use `require('dns').promises` for the Node mDNS lookup, and declare runtime type aliases as `any` so the file still parses on a machine without @scrypted/sdk installed locally. Also drop the `as ScryptedInterface` cast in handleCameraEvent — it was the only place an SDK enum was used as a type rather than a value, and just storing the string is fine. Editor-only diagnostics (NodeJS namespace, Buffer) remain — they resolve in the Scrypted Node runtime and only show up in clangd/tsc without @types/node locally.
2026-06-14scrypted/README: drop stale BOOT-factory-reset + IP-screen refsLuke Hoersten1-2/+2
2026-06-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 Hoersten4-251/+447
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.