src.nth.io/

summaryrefslogtreecommitdiff
path: root/scrypted/scrypted-viewport.ts
diff options
context:
space:
mode:
Diffstat (limited to 'scrypted/scrypted-viewport.ts')
-rw-r--r--scrypted/scrypted-viewport.ts131
1 files changed, 75 insertions, 56 deletions
diff --git a/scrypted/scrypted-viewport.ts b/scrypted/scrypted-viewport.ts
index 56b7186..7156fba 100644
--- a/scrypted/scrypted-viewport.ts
+++ b/scrypted/scrypted-viewport.ts
@@ -6,7 +6,7 @@
// short git hash of the commit that added this constant — if the
// hash in the log doesn't match the HEAD this file came from, the
// Scrypted Script editor is still on stale code.
-const SCRIPT_VERSION = "568de10";
+const SCRIPT_VERSION = "pending";
//
// Architecture
// ------------
@@ -366,6 +366,18 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
if (G.__viewportRegisterInterval) {
try { clearInterval(G.__viewportRegisterInterval); } catch {}
}
+ // Tear down any camera event listeners left over from a
+ // previous script load (same Scripts-sandbox lifecycle gap as
+ // the setInterval handle). Without this every re-paste stacks
+ // an extra callback on the camera, producing duplicate stream
+ // starts + concurrent snapshot transforms that race for the
+ // firmware decoder lock and visibly degrade quality.
+ if (Array.isArray(G.__viewportListenerCleaners)) {
+ for (const remove of G.__viewportListenerCleaners) {
+ try { remove(); } catch {}
+ }
+ }
+ G.__viewportListenerCleaners = [];
G.__viewportRegisterInterval = setInterval(() => {
for (const v of this.viewports.values()) {
this.registerViewport(v).catch(() => {});
@@ -539,6 +551,16 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
this.handleCameraEvent(v, details, data);
});
this.listeners.set(v.nativeId!, reg);
+ // Track on globalThis so script reload can remove this
+ // listener from the camera plugin. Without that, every
+ // re-paste leaves a dead callback subscribed to the camera
+ // and every motion/doorbell event triggers handleCameraEvent
+ // N times for N stacked reloads — observable as duplicate
+ // "stream start" log lines + two simultaneous pushSnapshots
+ // racing for the firmware decoder lock.
+ const G = globalThis as any;
+ if (!G.__viewportListenerCleaners) G.__viewportListenerCleaners = [];
+ G.__viewportListenerCleaners.push(() => { try { reg.removeListener(); } catch {} });
this.console.log(`viewport "${tag}": subscribed to "${cam.name}"`);
}
@@ -953,70 +975,67 @@ class ScryptedViewportProvider extends ScryptedDeviceBase
try { currentProc?.kill("SIGTERM"); } catch {}
});
- // Periodic stream-health log. Emits delivered fps + sustained
- // MB/s + socket.write p50/p95/max + drop count + backpressure
- // flag every 10s. drops here count frames the demux loop
- // threw away because the socket wasn't connected yet (initial
- // race at stream start) — once the socket is up we keep
- // writing past kernel backpressure and let the firmware's
- // FIONREAD skip drop the stale frames before decode.
- const skipLogger = setInterval(() => {
+ // Unified stream-health log every 10s: Scrypted-side sent fps
+ // + firmware-side painted fps side-by-side, so the user can
+ // see at a glance "we sent N, the panel showed M." The gap
+ // (sent - painted) is what the firmware's FIONREAD skip
+ // dropped to keep the panel showing the freshest frame.
+ // Includes firmware-side per-stage timings (recv/dec/paint/
+ // idle min/avg/max) + glass-to-glass age of the most recent
+ // painted frame. /state poll is folded in so there's one log
+ // line per window instead of two interleaved timelines.
+ const streamLogger = setInterval(async () => {
const now = Date.now();
const window = (now - lastLogUs) / 1000;
- if (window > 0 && (sentFrames > 0 || droppedFrames > 0)) {
- const sentRate = sentFrames / window;
- const dropRate = droppedFrames / window;
- const mbPerSec = (bytesSent / window) / (1024 * 1024);
- const sortedW = writeLatencies.slice().sort((a, b) => a - b);
- const p50 = sortedW.length ? sortedW[Math.floor(sortedW.length * 0.5)] : 0;
- const p95 = sortedW.length ? sortedW[Math.floor(sortedW.length * 0.95)] : 0;
- const max = sortedW.length ? sortedW[sortedW.length - 1] : 0;
- this.console.log(
- `stream "${v.name}": ${sentRate.toFixed(1)} fps, ${mbPerSec.toFixed(2)} MB/s ` +
- `over ${window.toFixed(1)}s ` +
- `(drops=${droppedFrames}=${dropRate.toFixed(1)} fps) ` +
- `socket.write p50=${p50}ms p95=${p95}ms max=${max}ms ` +
- `backpressured=${socketBackpressured}`);
- droppedFrames = 0;
- sentFrames = 0;
- bytesSent = 0;
- writeLatencies.length = 0;
- lastLogUs = now;
- }
- }, 10_000);
- abort.signal.addEventListener("abort", () => clearInterval(skipLogger));
-
- // Periodic /state poll to surface firmware-side window stats +
- // computed glass-to-glass. Runs every 5s while the stream is
- // active. g2g = (nowUsLow - last_paint_event_us_low) with
- // 32-bit wrap; clamped to a 30s sanity ceiling because event
- // timestamps from before the stream started are stale and
- // would yield gigantic apparent latencies.
- const fwPoller = setInterval(async () => {
+ if (window <= 0 || (sentFrames === 0 && droppedFrames === 0)) return;
+ const sentRate = sentFrames / window;
+ const dropRate = droppedFrames / window;
+ const mbPerSec = (bytesSent / window) / (1024 * 1024);
+ const sortedW = writeLatencies.slice().sort((a, b) => a - b);
+ const p50 = sortedW.length ? sortedW[Math.floor(sortedW.length * 0.5)] : 0;
+ const p95 = sortedW.length ? sortedW[Math.floor(sortedW.length * 0.95)] : 0;
+ const max = sortedW.length ? sortedW[sortedW.length - 1] : 0;
+
+ // Best-effort firmware-side snapshot. Timeout < 1s so a
+ // missed /state never wedges the logger.
+ let painted = "?", paintedMb = "?", g2g = "?", paintedNum = -1;
+ let recvStr = "?", decStr = "?", paintStr = "?", idleStr = "?";
try {
const st: any = await fetch(`http://${v.host}/state`, {
- signal: AbortSignal.timeout(1500),
+ signal: AbortSignal.timeout(800),
}).then(r => r.json());
const fs = st?.stream;
- if (!fs || !fs.frames) return;
- const fps = fs.window_us > 0 ? (fs.frames / (fs.window_us / 1e6)) : 0;
- const mb = fs.window_us > 0 ? ((fs.bytes / (fs.window_us / 1e6)) / (1024 * 1024)) : 0;
- let g2gMs = -1;
- if (fs.last_paint_event_us_low) {
+ if (fs?.frames && fs.window_us > 0) {
+ paintedNum = fs.frames / (fs.window_us / 1e6);
+ painted = paintedNum.toFixed(1);
+ paintedMb = ((fs.bytes / (fs.window_us / 1e6)) / (1024 * 1024)).toFixed(2);
+ recvStr = `${fs.recv_min_us}/${fs.recv_avg_us}/${fs.recv_max_us}`;
+ decStr = `${fs.dec_min_us}/${fs.dec_avg_us}/${fs.dec_max_us}`;
+ paintStr = `${fs.paint_min_us}/${fs.paint_avg_us}/${fs.paint_max_us}`;
+ idleStr = `${fs.idle_min_us}/${fs.idle_avg_us}/${fs.idle_max_us}`;
+ }
+ if (fs?.last_paint_event_us_low) {
const nowUsLow = (Date.now() * 1000) >>> 0;
const diff = (nowUsLow - fs.last_paint_event_us_low) >>> 0;
- if (diff < 30_000_000) g2gMs = diff / 1000;
+ if (diff < 30_000_000) g2g = (diff / 1000).toFixed(0) + "ms";
}
- this.console.log(
- `firmware "${v.name}": fps=${fps.toFixed(1)} ${mb.toFixed(2)}MB/s | ` +
- `recv=${fs.recv_min_us}/${fs.recv_avg_us}/${fs.recv_max_us}us | ` +
- `dec=${fs.dec_min_us}/${fs.dec_avg_us}/${fs.dec_max_us}us | ` +
- `paint=${fs.paint_min_us}/${fs.paint_avg_us}/${fs.paint_max_us}us | ` +
- `idle=${fs.idle_min_us}/${fs.idle_avg_us}/${fs.idle_max_us}us | ` +
- `g2g=${g2gMs >= 0 ? g2gMs.toFixed(0) + "ms" : "(no event yet)"}`);
- } catch { /* /state timeout is fine, try next tick */ }
- }, 5_000);
- abort.signal.addEventListener("abort", () => clearInterval(fwPoller));
+ } catch { /* keep the local stats; firmware-side just shows ? */ }
+
+ const skipped = paintedNum >= 0 ? Math.max(0, sentRate - paintedNum).toFixed(1) : "?";
+ this.console.log(
+ `stream "${v.name}": sent=${sentRate.toFixed(1)}fps painted=${painted}fps ` +
+ `(fw-skipped=${skipped}fps, drops=${droppedFrames}) ` +
+ `${mbPerSec.toFixed(2)}MB/s sent / ${paintedMb}MB/s painted | ` +
+ `socket.write p50=${p50}ms p95=${p95}ms max=${max}ms backpressured=${socketBackpressured} | ` +
+ `recv=${recvStr}us dec=${decStr}us paint=${paintStr}us idle=${idleStr}us | g2g=${g2g}`);
+
+ droppedFrames = 0;
+ sentFrames = 0;
+ bytesSent = 0;
+ writeLatencies.length = 0;
+ lastLogUs = now;
+ }, 10_000);
+ abort.signal.addEventListener("abort", () => clearInterval(streamLogger));
const timeoutMs = v.idleTimeoutMs > 0 ? v.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS;
const timeout = setTimeout(() => {