| Age | Commit message (Collapse) | Author | Files | Lines |
|
- Scrypted per-stream stats line gains temp=<c>C from /state temp_c —
free thermal trending under streaming load.
- README Display strategy rewritten for the tear-free triple-buffer
model: why the deferred fb-index reload tears under double
buffering, why scan-tracked buffer roles beat vsync-waiting, and
the tear_guard_engaged counter.
- New 'TCP window + EMAC tuning' section documenting the measured
window raise, the EMAC-RX-pool-below-window RTO regression, and
the pool >= TCP_WND invariant; stale 'window bump is safe but
won't help' backlog text updated.
- Memory strategy updated (3 fbs, decoder writes into them directly,
stream body ring).
|
|
Stream throughput tuning, measured on hardware (kitchen panel):
wire 53 -> 74 Mbps, per-frame recv 30.5 -> 21.4 ms, painted fps
19.8 -> 23.6, g2g 73 -> 62 ms, sender backpressure 61% -> 47%.
- lwip: TCP_WND 5760 -> 23040 (16 x MSS), RECVMBOX 6 -> 32
- eth: EMAC RX DMA pool sized above the TCP window (1600B x 24);
below-window pool caused silent burst tail-drop + ~200-400ms
sender RTO stalls
- stream: TCP-window decomposition instrumentation (wire kbps,
hdr_gap, pend_age) in the window log and /state; connect log
stamps TCP_WND/MSS/RECVMBOX
Set VIEWPORT_VERSION and scrypted/package.json to 1.3.1.
|
|
|
|
skip-oldest)
|
|
Unify firmware and plugin versions for the production release. Highlights
since v1.1.0/1.2.0:
- event wake fixed (self-healing camera-listener re-attach after the reload/
add storage race); doorbell/motion/person confirmed on hardware
- stop() fully tears down (stop+start == fresh load); stale childId pruning;
live-stream events ignored (no queue/relaunch)
- triggers default to person+doorbell; doorbell hidden for non-doorbell cams
- cold-start (wake -> live video) ~6s -> ~0.7s: prebuffered substream by id +
request >= GOP + burst-friendly ffmpeg flags; snapshot removed
- firmware: Loading screen on new stream connection (no stale-frame flash)
- logging trimmed for chatty cameras
- README/protocol docs rewritten for the TCP-socket streaming model
Set VIEWPORT_VERSION and scrypted/package.json to 1.3.0.
|
|
|
|
A chatty camera keeps a viewport streaming for long stretches, and the old
logging emitted ~5 cold-start stamps + a 10s window line per ~10s per stream.
Cut it down:
- keep a single cold-start stamp (first ffmpeg frame); drop spawned / first
stdout byte / first socket.write.
- gate the 10s window line on noteworthy windows only (socket-not-ready drops,
a buffer-cap flush this window, firmware shedding >=2fps, or painted <20fps);
healthy 24fps windows print nothing — the per-wake lines already show liveness.
- log "registered" only when the pushed config actually changes, not on every
5-minute reregister cycle. lastRegisterSig cleared on stop() for fresh-load
parity.
No behavior change beyond logging.
|
|
The v1 README still described the old snapshot-poll path. Update to match the
shipped script: live MJPEG over a TCP data socket (port 81, ~24fps) via ffmpeg,
not ~1fps snapshots. Fixes:
- intro + v1 limitations: MJPEG streaming is done, not a v2 "POST /stream" TODO
- Settings: replace the removed "Frame push interval" with the real Display
fields (JPEG quality, Max Scrypted-side buffer, Stream prebuffer); add the
Actions group; correct brightness default (100, was 80)
- Wake triggers default is person+doorbell (was "all three"), doorbell only for
doorbell-capable cameras
- drop the stale snapshot-source / snapshot-interval / POST /frame 409 mentions
- host field: no mDNS auto-resolve (was contradictory)
- smoke test: Loading -> live video, not "snapshots flowing"
|
|
Wake-to-live is ~0.7s only when the streamed substream keeps a rebroadcast
prebuffer. Add a "Fast wake — camera prebuffer (required)" section to
scrypted/README.md: enable Prebuffer on the STREAM: MEDIUM tab (default only
prebuffers High), keep duration >= the detected keyframe interval (~5s), leave
the viewport's Stream prebuffer (ms) at 6000, and how to verify via the log.
|
|
|
|
The prebuffered stream now paints in ~0.7s, so the takePicture→transform→POST
first-paint bridge was consistently slower than the video it was meant to
cover, while adding camera load and a stale-overpaint risk. Drop it: remove
pushSnapshot and its call, the shouldPaint gate, and the firstStreamFrameSeen
plumbing. On the slow paths (no/cold prebuffer) the panel holds its prior frame
until the stream's first frame lands. buildVf stays (used by the live path).
|
|
|
|
Now that the prebuffered stream paints in ~0.7s, the parallel snapshot usually
loses the race and, if slow (a Unifi takePicture cache-miss can be several
seconds), would land after live video and overpaint it with a staler still.
pushSnapshot takes a shouldPaint predicate; startStream passes
() => !firstStreamFrameSeen (flipped by the ffmpeg first-frame handler), so a
snapshot that finishes after the stream is already painting is dropped before
the POST. Preserves the snapshot as a gap-filler for the slow paths (live-edge
fallback / cold prebuffer) where the stream hasn't painted yet.
|
|
|
|
Remove the temporary instrumentation used to diagnose the event-wake and
cold-start work: the evtscan system-wide listener, the attach/trace/streamopt/
src/inputArgs logs, the StartStop entry logs, and ffmpeg -loglevel info/-nostats
(back to error). Keep the lightweight one-shot cold-start stamps (spawned →
first byte → first frame → first socket.write) and usingPrebuffer in the config
line — cheap regression signals — plus all the actual fixes and the prebuffered-
stream selection logic.
|
|
|
|
Event/lifecycle fixes:
- attachListener now self-heals: it's idempotent (tracks the bound camera
per viewport) and re-runs on registerViewport success, so the camera
subscription survives the reload/add storage-attach race that previously
left a bound viewport with no listener until a manual re-save.
- stop() always drains the global cleaner array (never gates on this.running)
and also cancels bindingDebounce timers + clears the stream idle timeout on
abort, so StartStop.stop()+start() == a fresh load with no orphaned ffmpeg,
sockets, listeners, or intervals.
- ignore all camera events while a stream is live or starting (guard moved to
the top of handleCameraEvent) — the wake window is anchored to the first
event; later events don't queue, relaunch, or extend it.
- prune stale childIds whose storage container is gone (deleted device).
Triggers:
- default to person + doorbell (motion opt-in; doorbell cameras are chatty);
doorbell only offered for doorbell-capable cameras. Reconcile stored
triggers + re-render settings live when the camera binding changes.
Cold-start (wake -> first live frame): ~6s -> ~0.7s.
- Root cause: Unifi GOP is ~5s and the rebroadcast RTSP serves live-edge, so
ffmpeg waited for the next keyframe.
- Fix: select the prebuffered substream by id (smallest that covers the panel,
not the 5MP High), request prebuffer >= GOP, and drop -avioflags direct /
-fflags nobuffer on the prebuffer path so ffmpeg gulps the buffered-keyframe
burst instead of trickling it. Live-edge fallback keeps the low-latency flags.
Steady-state g2g unchanged (~50-100ms).
Includes temporary diagnostics (evtscan, attach/trace/streamopt/src logs,
ffmpeg -loglevel info) to be stripped in the follow-up commit.
|
|
|
|
|
|
|
|
broken)
ScryptedDevice.listen() takes a single ScryptedInterface | string |
EventListenerOptions, not an array. We were passing
[BinarySensor, MotionSensor, ObjectDetector] which stringifies to a
comma-joined garbage filter, leaking unrelated events (Settings, ...)
through and apparently dropping BinarySensor entirely — bell rings
never surfaced. Also drops the child-device traversal: confirmed via
unifi-protect/src/main.ts that the bell BinarySensor lives on the
camera device itself for doorbells.
|
|
|
|
|
|
|
|
|
|
|
|
diagnostic
|
|
|
|
Scripts plugin)
The 'Unknown' text the user saw above the Status and Controls panel
is the device TYPE, not the lifecycle state. Scripts plugin
(plugins/core/src/script.ts:65) hardcodes type=ScryptedDeviceType.Unknown
when it registers any script device, regardless of what interfaces
the loaded class actually implements.
Override it from inside the script: call deviceManager.onDeviceDiscovered
ourselves with type=Bridge, which semantically fits our DeviceProvider
that bridges multiple child viewport devices. The override runs after
Scripts plugin's postRunScript-driven discovery so the later call wins.
Pass the full interface set explicitly (auto-detection found from
method names: Settings/DeviceProvider/DeviceCreator/HttpRequestHandler/
StartStop, plus Scripts base Scriptable+Program) — partial lists drop
interfaces.
The override is wrapped in try/catch — if the device record's
provider mapping changes in a future Scrypted version, we degrade
to the old Unknown label rather than the script failing to boot.
|
|
|
|
In v101fb3e we shipped StartStop + OnOff side-by-side to see which
the 'Status and Controls' panel actually wired to. The v44c7a63
diagnostic session confirmed: clicking STOP fires stop() (StartStop),
not turnOff() (OnOff). User's console showed:
lifecycle: start() called (running=false)
...
lifecycle: stop() called (running=true)
stop: tearing down 1 resources
So StartStop alone is the right interface. OnOff just produced a
duplicate 'Status and Controls' panel for the same lifecycle. Drop it.
Also dropping the verbose lifecycle: ... logging — we know the
binding now. The cleanup mechanism is the load-bearing observable
('stop: tearing down N resources' from drainShutdownCleaners).
The status text still rendering 'Unknown' (rather than Running/Stopped)
is a separate cosmetic concern — possibly the duplicate panels were
confusing the UI; with a single StartStop panel left, the displayed
status may now reflect this.running correctly. To be verified.
|
|
|
|
start() previously logged only at entry, so a silent throw between
'Scrypted Viewport up' and 'this.running = true' would leave us
guessing. Add explicit end-of-method logs with the post-write state,
plus a try/catch around bootstrap that surfaces the exception
message before re-throwing.
This will tell us whether start() reaches its set this.running=true
on script load (i.e. whether the prototype state-proxy write fires)
or whether bootstrap is silently failing somewhere mid-way.
|
|
|
|
panel binding
The 'Status and Controls' panel in @scrypted/core 0.3.147 was rendering
Unknown despite the previous commit adding StartStop. Reading the
SDK (sdk/src/index.ts:195 + plugins/core/src/script.ts:47) confirmed
how it should work:
- ScryptedDeviceBase installs Object.defineProperty getters/setters
for every interface property at runtime, so this.running = true
propagates through _lazyLoadDeviceState → getDeviceState proxy →
system state.
- Scripts plugin's mergeHandler auto-detects interfaces by mapping
method names: start/stop → StartStop, turnOn/turnOff → OnOff,
putSetting/getSettings → Settings, etc.
In theory the previous commit was sufficient. To pin down whether the
panel is calling something different (older Scrypted UIs lean toward
OnOff), this commit:
- Implements OnOff alongside StartStop. Both pairs are bound to the
same drain/bootstrap logic; whichever the panel calls, the user's
intent succeeds.
- Initialises this.running = false and this.on = false synchronously
in the constructor so the device state record carries a defined
value at registration time, instead of Scrypted defaulting to
Unknown on undefined.
- Logs 'lifecycle: X() called (...)' on every method entry. After
the user reloads + clicks STOP / START, the console will reveal
exactly which method Scrypted is invoking — or whether neither is.
If one of the methods fires, the other is dead code we can drop. If
neither fires on the panel's STOP/START button, the panel is a
Scripts-runtime control unrelated to device interfaces and we need
to look for a different lifecycle hook.
|
|
|
|
Silent early-return at 'if (!v.cameraId) return;' makes a brand-new
viewport with no camera selected look identical (in the console) to
one that subscribed successfully — there's no positive or negative
signal until you try to fire a camera event. After observing a fresh
viewport produce zero output on a doorbell press, switching the
early-return to a warning that says 'no camera assigned — open
Settings and pick a camera; subscription skipped' so the missing
configuration becomes self-evident.
|
|
|
|
The Scripts 'Status and Controls' panel previously rendered Unknown
because the Provider exposed no lifecycle interface. STOP / START in
that panel didn't do anything useful — Scrypted's only option was
to unload the script entirely, which left the user's re-paste flow
relying on the constructor-time globalThis cleanup hack (and even
that didn't cover in-flight streams until 1ce49d3).
This commit makes the panel real:
class ScryptedViewportProvider ... implements ..., StartStop
- async start() : drain shutdown cleaners, bootstrap, running=true
- async stop() : drain shutdown cleaners, clear viewport+listener+
stream maps, running=false
Both methods are idempotent (no-op when already in target state).
The constructor calls start() automatically so script load still
bootstraps without user action.
The private start() method that did the actual provisioning is
renamed to bootstrap() to free up the public name for the StartStop
contract. registerShutdownCleaners is renamed to drainShutdownCleaners
and now takes a 'reason' label so the log line distinguishes 'start:
tearing down N resources' (previous load) from 'stop: tearing down N
resources' (user-driven).
Effect on the workflow the user described:
1. Click STOP in the Scripts UI → stop() drains every resource
(streams, listeners, intervals) the provider currently holds.
2. Re-paste the new script.
3. New constructor runs → start() drains anything left behind →
bootstrap re-discovers children + re-attaches listeners → ready.
No more accumulating ghosts of prior loads.
|
|
|
|
Scrypted's Scripts sandbox doesn't release a previous load's resources
before constructing a new Provider instance, so anything long-lived
survives a re-paste. Previously we hand-rolled cleanup via two
separate globalThis hooks: __viewportListenerCleaners (camera event
listeners) and __viewportRegisterInterval (the 5-minute re-register
timer). Anything else — in-flight ffmpeg children, TCP sockets to
the firmware, the per-stream stats interval — leaked across reloads.
Visible symptom: re-pasting the script during an active stream left
the old stream's ffmpeg + socket running against a dead Provider
instance, accumulating across reloads until the host was restarted.
Unify all cleanup into one globalThis array, __viewportShutdownCleaners,
that every long-lived resource pushes onto. The next start() snapshots
and drains it before doing anything else. Resources covered:
- 5-minute re-register interval (clearInterval)
- Every camera + child-device event listener (reg.removeListener)
- Every in-flight stream's AbortController (abort.abort, which the
existing abort listeners chain into socket.destroy() + ffmpeg
SIGTERM + clearInterval on the stats logger)
Each cleanup is removed from the array when the resource naturally
ends (stream timeout / stopStream), so the list stays compact across
many stream cycles rather than growing forever.
Snapshot-before-walk avoids the iteration-skip bug where a stream
abort fires its abort listener, which splices the closure out of the
same array we're iterating. Snapshot the array, clear the original,
then walk the snapshot.
Doesn't address the 'Unknown' status panel in Scrypted Scripts UI —
that's a separate cosmetic issue (we don't implement a lifecycle
interface that reports running state).
|
|
|
|
The Settings 'Status (live)' section reaches the device via two
sequential GETs. A transient socket-level failure (httpd worker
pool briefly saturated by the active stream connection, mid-reboot
window, network jitter) used to leave the page showing
'device: offline / unreachable (fetch failed)' even though the
device was fine — a refresh would clear it.
One automatic 250 ms-backoff retry on either GET removes the
sporadic false positive. The 3 s per-request timeout stays the
same, so the worst-case page latency on a genuinely offline device
is ~6.5 s (3 + 0.25 + 3) instead of 3 s — acceptable for a
deliberate Settings open.
|
|
|
|
Unifi doorbell cameras expose the bell button as a child device of
the camera (separate nativeId, its own BinarySensor interface), not
as a property of the camera itself. The previous code did cam.listen()
on the parent camera only, so motion + person events arrived (those
fire on the camera itself) but bell-press events never reached
handleCameraEvent.
HomeKit kept working because Scrypted's HomeKit bridge auto-syncs all
child devices; our plugin was silently the only consumer missing the
event. Confirmed by the trace log in 3b0ab73 staying silent across a
bell press while motion events still printed.
Fix: in attachListener, walk systemManager.getDeviceIds() and pick
any device with providerId === cam.id as an additional listen target.
Subscribe on all of them with the same iface list — listen() no-ops
on unsupported ifaces, so we don't have to introspect each child.
Tracking changed from Map<nativeId, EventListenerRegister> to
Map<nativeId, EventListenerRegister[]> so detach/script-reload cleanup
removes every listener, not just the camera's.
|
|
|
|
handleCameraEvent now logs iface/typeof/data/details for every event
the camera fires on BinarySensor/MotionSensor/ObjectDetector. Helps
diagnose why a Unifi doorbell press isn't waking the viewport while
the same event reaches HomeKit. Temporary — remove once root cause is
confirmed.
|
|
Hold at most one pending frame; new ffmpeg frames arriving while the
socket is backpressured replace the held one (drop oldest) instead of
queueing. On 'drain', flush the held frame. The in-flight write stays
out of our hands. Steady-state Node-side buffer drops from up to 20MB
to ~1 frame, eliminating the cap-and-reconnect loop as the primary
shedding mechanism — it's now an emergency safety net for stuck
connections that never fire 'drain'.
Capture-time timestamp instead of write-time so a held frame keeps its
true age and g2g reflects any pending-slot hold latency.
New log fields: drop-oldest=N (frames shed from the pending slot this
window) and bp=N% (share of ffmpeg frames in the window that found the
link backpressured at decision time).
|
|
|
|
Two reasons the Status (live) section was showing
"offline / unreachable (fetch failed)" intermittently:
1. /state and /config were fetched in parallel, eating both available
httpd sockets simultaneously (Phase 2 dropped max_open_sockets
from 4 to 2). Any other inbound HTTP at the same instant would
either queue or error.
2. The 1.5s timeout was tight given the firmware now juggles a live
stream socket on port 81 (with occasional cap-flush reconnects)
alongside the httpd workers on port 80.
Sequence the two fetches and bump the timeout to 3s. Total worst-case
6s if both are slow; that's fine for a Settings page, far from
"feels offline."
|