| Age | Commit message (Collapse) | Author | Files | Lines |
|
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.
|
|
WND=23040 alone regressed: sender fps 20 -> 14, drop-oldest 3x,
recv bimodal (min 20ms but 200-450ms stalls; firmware wire_min
6.2Mbps / wire_max 75Mbps, recv_max 253ms). The stalls are RTO
recovery: the default EMAC RX pool (20 x 512B = 10KB) is smaller
than the 23KB the window now invites in flight, so a full-window
burst overruns the RX descriptors, the burst tail is dropped with
no dup-ACKs behind it, and the sender waits out a ~200ms min-RTO.
1600B buffers fit one MSS frame per buffer (1 descriptor/packet
instead of 3); 24 of them = 38.4KB >= window + slack. ~44KB more
internal RAM (491KB free).
|
|
Instrumented baseline at WND=5760 (kitchen, ~202KB frames, 20fps
sender) confirmed the receive window as the dominant throttle:
- wire 53.0/58.8 Mbps avg/max = the WND/RTT ceiling, vs ~94 Mbps
line rate on the 10/100 PHY
- recv_chunk_max pinned at exactly 5760 (drains window-quantized)
- recv_calls avg 60/frame; queued_at_body always 0
- sender backpressured 44-61% of frames, drop-oldest 17-54/window
- meanwhile pend_age avg 58us: decode idles, receive starves it
Expected: recv_avg ~30ms -> ~20ms, wire toward line rate, sender
bp%/drop-oldest down, g2g 73ms -> ~60ms. Tripwires (revert if hit):
pend_age or recv_dropped_oldest growing window-over-window, g2g
regressing. RECVMBOX scales with the window: 23040 = 16 segments
in flight, a 6-deep mbox would silently become the new cap.
|
|
Before touching CONFIG_LWIP_TCP_WND_DEFAULT, make the window question
decidable from the logs. New per-window metrics in the stream log,
/state, and stats struct:
- wire min/avg/max kbps: instantaneous throughput while each body
drained (jpeg_len/recv_us). Ceiling ~= TCP_WND/RTT, so it scales
with the window iff the window is the limiter.
- hdr_gap min/avg/max us: time blocked waiting for the next header
after finishing a body. Large = sender-paced; ~0 = receive path
is the bottleneck.
- pend_age min/avg/max us: publish->claim latency of painted frames.
Growing across windows = queue backlog building, the failure mode
that killed the previous WND=65535 attempt.
Together with recv/dec/paint the frame interval is now fully
decomposable: interval ~= hdr_gap + recv + pend_age + dec + paint.
Also stamp TCP_WND/TCP_MSS/RECVMBOX into the client-connect log line
so every capture is self-labeled with the config it ran under.
|
|
|
|
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 Wake/Sleep, race-handling, and idempotency sections still described the
old model where Scrypted streamed via POST /frame and learned of sleep from a
/frame 409. Streaming now runs over the TCP data socket (:81); the firmware
/frame endpoint remains only for one-shot snapshots/debug. Reframe the recovery
model accordingly:
- race-free property: frames arriving while asleep are discarded by the decode
task (stream socket) / rejected 409 (/frame) — neither re-wakes; sleep is
learned from the device's state=sleep callback + Scrypted's safety timer
- drop every "recovered by the next /frame returning 409" clause in favor of
the safety timer + discard-while-asleep
- idempotency table: add the stream-socket row (paints if awake, discarded if
asleep, skip-oldest)
- Loading screen: shown on wake AND each new stream connection until first paint
- Philosophy: list the MJPEG stream server (:81) and /firmware
Left the POST /frame endpoint's own API/semantics intact — it still exists.
|
|
The Scrypted Integration section still framed the snapshot POST /frame path as
v1 and MJPEG streaming as a future POST /stream milestone, contradicting the
already-current "What's next" perf section. Bring it in line:
- v1 is the live ffmpeg MJPEG stream over the raw TCP data socket (:81) with
prebuffer fast-start (~0.7s wake-to-video); POST /stream was never the shape
- replace the /frame streaming code example with the TCP socket + framed-MJPEG
sketch; brightness default 100 (was 80)
- sleep is learned via the device's sleep callback, not a /frame 409
- Status table: streaming + end-to-end are done/verified, not "not started"
- Idle: timer resets on any painted frame, not specifically a /frame POST
Left intact: the POST /frame API + protocol/idempotency sections — that
firmware endpoint still exists for one-shot snapshots/debug and its 409-when-
asleep semantics are unchanged.
|
|
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 panel used to hold its last framebuffer during the connect→first-frame gap,
flashing a stale old frame before video appeared — more visible now that the
Scrypted side no longer sends a bridging snapshot. The wake path already paints
the Loading screen, but state_machine_set(AWAKE) is a no-op when the device is
already awake (e.g. a stream restart), so the clear didn't happen there.
Tie the clear to the stream connection instead: recv-task paints
local_screens_show_loading() once per new conn_id, guarded by the decoder lock
so the RGB565 present can't race a trailing decode-task paint, and gated on
AWAKE so it never draws on a sleeping panel.
|
|
|
|
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.
|