@camstack/system 1.2.62 → 1.2.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/addon-runner.js +1 -1
- package/dist/addon-runner.mjs +1 -1
- package/dist/addon-utils.js +1 -1
- package/dist/addon-utils.mjs +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +3 -3
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/block-package.d.ts +9 -27
- package/dist/builtins/core-blocks/block-supervisor.d.ts +15 -0
- package/dist/builtins/core-blocks/core-blocks.addon.js +91 -24
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +91 -25
- package/dist/builtins/device-manager/device-bindings-store.d.ts +28 -5
- package/dist/builtins/device-manager/device-linked-devices.d.ts +2 -2
- package/dist/builtins/device-manager/device-manager.addon.d.ts +3 -45
- package/dist/builtins/device-manager/device-manager.addon.js +123 -884
- package/dist/builtins/device-manager/device-manager.addon.mjs +123 -884
- package/dist/builtins/device-manager/device-meta-actions.d.ts +0 -8
- package/dist/builtins/device-manager/device-meta-store.d.ts +1 -28
- package/dist/builtins/device-manager/device-meta-types.d.ts +2 -6
- package/dist/builtins/device-manager/device-projection.d.ts +1 -2
- package/dist/builtins/device-manager/device-provider-context.d.ts +2 -13
- package/dist/builtins/device-manager/device-queries.d.ts +1 -1
- package/dist/builtins/device-manager/device-state-mirror.d.ts +8 -49
- package/dist/builtins/device-manager/device-sync-resolvers.d.ts +0 -11
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/homeassistant-integration.d.ts +7 -0
- package/dist/builtins/local-auth/index.d.ts +1 -0
- package/dist/builtins/local-auth/index.js +1 -0
- package/dist/builtins/local-auth/index.mjs +2 -2
- package/dist/builtins/local-auth/local-auth.addon.js +561 -497
- package/dist/builtins/local-auth/local-auth.addon.mjs +580 -517
- package/dist/builtins/local-auth/oauth-grants.d.ts +11 -0
- package/dist/builtins/local-auth/oauth-session-manager.d.ts +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +2 -2
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +2 -2
- package/dist/builtins/platform-probe/index.js +2 -2
- package/dist/builtins/platform-probe/index.mjs +2 -2
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +319 -20
- package/dist/builtins/snapshot/index.mjs +319 -20
- package/dist/builtins/snapshot/snapshot-keep-warm.d.ts +86 -0
- package/dist/builtins/snapshot/snapshot-media-handler.d.ts +48 -1
- package/dist/builtins/snapshot/snapshot.addon.d.ts +65 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/retired-settings-keys.d.ts +15 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +73 -7
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +73 -7
- package/dist/builtins/sqlite-storage/vector-index-vec.d.ts +19 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-DnKpFaP1.mjs → dist-CMb7aKS0.mjs} +1227 -1330
- package/dist/{dist-DgPUarcs.js → dist-DP3nHlNM.js} +1251 -1366
- package/dist/http/asset-compression.d.ts +93 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +64 -9
- package/dist/index.mjs +65 -11
- package/dist/kernel/heap-watch.d.ts +55 -0
- package/dist/kernel/moleculer/device-cap-proxy.d.ts +2 -2
- package/dist/{manifest-python-deps-d4acYXVV.js → manifest-python-deps-BE6l8Y4W.js} +21 -15
- package/dist/{manifest-python-deps-CX5tGRqf.mjs → manifest-python-deps-Dz1ryHIN.mjs} +21 -15
- package/dist/{model-download-service-D8B-4ktF.mjs → model-download-service-CTBHzORJ.mjs} +220 -2
- package/dist/{model-download-service-Cpc6SWzV.js → model-download-service-Caj1kaSi.js} +220 -2
- package/package.json +5 -1
- package/dist/builtins/device-manager/device-link-cycle.d.ts +0 -18
- package/dist/builtins/device-manager/device-link-overlay.d.ts +0 -33
- package/dist/builtins/device-manager/device-link-resolver.d.ts +0 -44
- package/dist/main-CVLq8IzA.js +0 -2356
- package/dist/main-CzH19GkU.mjs +0 -2353
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { At as nodePin, _t as DeviceType, dt as BaseAddon, ht as DeviceFeature, i as BatteryStatusSchema, ot as streamQualityLabel, rt as snapshotCapability, ut as errMsg, x as bareAddonId } from "../../dist-CMb7aKS0.mjs";
|
|
2
2
|
import { execFile, spawn } from "node:child_process";
|
|
3
3
|
//#region src/builtins/snapshot/snapshot-coalescing.ts
|
|
4
4
|
/**
|
|
@@ -261,10 +261,10 @@ function parseSnapshotMediaRequest(url) {
|
|
|
261
261
|
* `snapshot.getSnapshotOverview` advertises still matches the frame nobody
|
|
262
262
|
* asked to resize.
|
|
263
263
|
*/
|
|
264
|
-
function snapshotEtag(
|
|
265
|
-
const parts = [`${String(
|
|
266
|
-
if (
|
|
267
|
-
if (
|
|
264
|
+
function snapshotEtag(variant, capturedAt) {
|
|
265
|
+
const parts = [`${String(variant.deviceId)}-${String(capturedAt)}`];
|
|
266
|
+
if (variant.streamId !== void 0) parts.push(`s${variant.streamId}`);
|
|
267
|
+
if (variant.width !== void 0) parts.push(`w${String(variant.width)}`);
|
|
268
268
|
return `"${parts.join("-")}"`;
|
|
269
269
|
}
|
|
270
270
|
/**
|
|
@@ -283,6 +283,29 @@ function createSnapshotMediaHandler(deps) {
|
|
|
283
283
|
res.writeHead(404).end();
|
|
284
284
|
return;
|
|
285
285
|
}
|
|
286
|
+
const inm = req.headers["if-none-match"];
|
|
287
|
+
if (typeof inm === "string" && !parsed.force && deps.peekFresh !== void 0) {
|
|
288
|
+
let peeked = null;
|
|
289
|
+
try {
|
|
290
|
+
peeked = await deps.peekFresh(parsed.deviceId, parsed.streamId);
|
|
291
|
+
} catch {
|
|
292
|
+
peeked = null;
|
|
293
|
+
}
|
|
294
|
+
if (peeked !== null) {
|
|
295
|
+
const peekedEtag = snapshotEtag({
|
|
296
|
+
deviceId: parsed.deviceId,
|
|
297
|
+
streamId: parsed.streamId,
|
|
298
|
+
width: parsed.width
|
|
299
|
+
}, peeked.capturedAt);
|
|
300
|
+
if (inm === peekedEtag) {
|
|
301
|
+
res.writeHead(304, {
|
|
302
|
+
etag: peekedEtag,
|
|
303
|
+
"cache-control": `private, max-age=${Math.max(0, Math.floor(peeked.maxAgeS))}`
|
|
304
|
+
}).end();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
286
309
|
let media;
|
|
287
310
|
try {
|
|
288
311
|
media = await deps.getMedia(parsed.deviceId, parsed.streamId, parsed.force, parsed.width);
|
|
@@ -300,9 +323,13 @@ function createSnapshotMediaHandler(deps) {
|
|
|
300
323
|
res.writeHead(404).end();
|
|
301
324
|
return;
|
|
302
325
|
}
|
|
303
|
-
const etag = snapshotEtag(
|
|
326
|
+
const etag = snapshotEtag({
|
|
327
|
+
deviceId: parsed.deviceId,
|
|
328
|
+
streamId: parsed.streamId,
|
|
329
|
+
width: "servedWidth" in media ? media.servedWidth : parsed.width
|
|
330
|
+
}, media.capturedAt);
|
|
304
331
|
const cacheControl = `private, max-age=${Math.max(0, Math.floor(media.maxAgeS))}`;
|
|
305
|
-
if (
|
|
332
|
+
if (inm === etag) {
|
|
306
333
|
res.writeHead(304, {
|
|
307
334
|
etag,
|
|
308
335
|
"cache-control": cacheControl
|
|
@@ -325,7 +352,7 @@ function createSnapshotMediaHandler(deps) {
|
|
|
325
352
|
* per-device preference decides what it captures, and conflating it with an
|
|
326
353
|
* explicit request is how the original bug read. */
|
|
327
354
|
var AUTO = "auto";
|
|
328
|
-
function keyOf(deviceId, streamId) {
|
|
355
|
+
function keyOf$1(deviceId, streamId) {
|
|
329
356
|
return `${deviceId}:${streamId ?? AUTO}`;
|
|
330
357
|
}
|
|
331
358
|
var SnapshotCache = class {
|
|
@@ -333,10 +360,10 @@ var SnapshotCache = class {
|
|
|
333
360
|
/** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
|
|
334
361
|
keysByDevice = /* @__PURE__ */ new Map();
|
|
335
362
|
get(deviceId, streamId) {
|
|
336
|
-
return this.byKey.get(keyOf(deviceId, streamId));
|
|
363
|
+
return this.byKey.get(keyOf$1(deviceId, streamId));
|
|
337
364
|
}
|
|
338
365
|
set(deviceId, streamId, entry) {
|
|
339
|
-
const key = keyOf(deviceId, streamId);
|
|
366
|
+
const key = keyOf$1(deviceId, streamId);
|
|
340
367
|
this.byKey.set(key, entry);
|
|
341
368
|
const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
|
|
342
369
|
keys.add(key);
|
|
@@ -496,6 +523,123 @@ var SnapshotVariantCache = class SnapshotVariantCache {
|
|
|
496
523
|
}
|
|
497
524
|
};
|
|
498
525
|
//#endregion
|
|
526
|
+
//#region src/builtins/snapshot/snapshot-keep-warm.ts
|
|
527
|
+
/**
|
|
528
|
+
* Subscription bookkeeping for the snapshot keep-warm loop.
|
|
529
|
+
*
|
|
530
|
+
* The snapshot cache has exactly one writer — a successful capture — and every
|
|
531
|
+
* capture is triggered by somebody asking. Nothing refreshes a frame on its own,
|
|
532
|
+
* so with no viewer open every thumbnail in the system is as old as the last
|
|
533
|
+
* time somebody looked. Measured on the live hub: 15 of 16 cameras cached, all
|
|
534
|
+
* captured in one 55 ms burst, all 10 minutes old and not moving.
|
|
535
|
+
*
|
|
536
|
+
* The fix is a warm loop — but a blind one would dial 16 cameras forever for
|
|
537
|
+
* nobody. This module holds the thing that makes it bounded: a warm tick only
|
|
538
|
+
* touches cameras SOMEBODY IS LOOKING AT, and "looking at" is inferred from the
|
|
539
|
+
* requests themselves. One data-plane request for a device makes it an observer
|
|
540
|
+
* for {@link WARM_WINDOW_MS}; when the last viewer closes, the loop goes quiet
|
|
541
|
+
* on its own after that window and costs exactly nothing.
|
|
542
|
+
*
|
|
543
|
+
* Pure and side-effect-free (no timer, no I/O, `now` injected) — the addon owns
|
|
544
|
+
* the timer, this owns the decision. Unit-tested in
|
|
545
|
+
* `__tests__/snapshot-keep-warm.spec.ts`.
|
|
546
|
+
*/
|
|
547
|
+
/**
|
|
548
|
+
* How often the warm loop runs. Chosen so a tile is perceptibly fresh while
|
|
549
|
+
* watched; it is also the single biggest cost lever in the design (16 cameras ×
|
|
550
|
+
* 12 ticks/min against a pool of 3 ffmpeg). A chained timer, not `setInterval` —
|
|
551
|
+
* the next tick is scheduled only after the previous one settles, so a slow
|
|
552
|
+
* fleet cannot stack ticks on top of each other.
|
|
553
|
+
*/
|
|
554
|
+
var WARM_INTERVAL_MS = 5e3;
|
|
555
|
+
/**
|
|
556
|
+
* How long one request keeps a device warm. Two minutes of inertia after the
|
|
557
|
+
* last observer goes away: long enough that flipping between screens (or a
|
|
558
|
+
* phone locking briefly) does not go cold, short enough that a closed app stops
|
|
559
|
+
* costing captures well before anyone notices.
|
|
560
|
+
*/
|
|
561
|
+
var WARM_WINDOW_MS = 12e4;
|
|
562
|
+
function keyOf(deviceId, streamId) {
|
|
563
|
+
return `${deviceId}:${streamId ?? "auto"}`;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Who is being watched right now, and at what sizes.
|
|
567
|
+
*
|
|
568
|
+
* `note` on every data-plane request; `active` on every warm tick. `active`
|
|
569
|
+
* PRUNES as it walks rather than merely filtering — the map is fed by request
|
|
570
|
+
* traffic, so a filter-only read would leave every device ever requested in
|
|
571
|
+
* memory for the life of the process.
|
|
572
|
+
*/
|
|
573
|
+
var SnapshotSubscriptions = class {
|
|
574
|
+
entries = /* @__PURE__ */ new Map();
|
|
575
|
+
/** Record that somebody asked for this (device, stream) — at `width`, when the
|
|
576
|
+
* request carried one. */
|
|
577
|
+
note(deviceId, streamId, width, nowMs) {
|
|
578
|
+
const key = keyOf(deviceId, streamId);
|
|
579
|
+
const existing = this.entries.get(key);
|
|
580
|
+
if (existing) {
|
|
581
|
+
existing.lastRequestedAt = nowMs;
|
|
582
|
+
if (width !== void 0) existing.widths.set(width, nowMs);
|
|
583
|
+
this.entries.delete(key);
|
|
584
|
+
this.entries.set(key, existing);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
this.entries.set(key, {
|
|
588
|
+
deviceId,
|
|
589
|
+
streamId,
|
|
590
|
+
lastRequestedAt: nowMs,
|
|
591
|
+
widths: width === void 0 ? /* @__PURE__ */ new Map() : new Map([[width, nowMs]])
|
|
592
|
+
});
|
|
593
|
+
while (this.entries.size > 64) {
|
|
594
|
+
const oldest = this.entries.keys().next();
|
|
595
|
+
if (oldest.done === true) break;
|
|
596
|
+
this.entries.delete(oldest.value);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Refresh the window on subscriptions that ALREADY exist. Never creates one.
|
|
601
|
+
*
|
|
602
|
+
* This is what stops the design deadlocking, and the deadlock was measured on
|
|
603
|
+
* the live hub before it existed. The client re-requests an image only when
|
|
604
|
+
* the frame identity changes, and the identity changes only because the warm
|
|
605
|
+
* loop captured — so 120 s after the last image request the subscription
|
|
606
|
+
* expired, warming stopped, the identity froze, the client never asked again,
|
|
607
|
+
* and the tile was stuck. Ages went 13 s → 49.7 s at exactly t+120 s.
|
|
608
|
+
*
|
|
609
|
+
* The client's `getSnapshotOverview` poll is the heartbeat: asking "what is
|
|
610
|
+
* the state of these cameras" IS the statement that somebody is looking. It
|
|
611
|
+
* deliberately cannot CREATE a subscription, because that poll is app-wide —
|
|
612
|
+
* if it did, opening the app would warm every camera on the install, which is
|
|
613
|
+
* the blind poll this whole design avoids. Only a rendered surface subscribes.
|
|
614
|
+
*/
|
|
615
|
+
renew(deviceIds, nowMs) {
|
|
616
|
+
for (const deviceId of deviceIds) for (const entry of this.entries.values()) if (entry.deviceId === deviceId) entry.lastRequestedAt = nowMs;
|
|
617
|
+
}
|
|
618
|
+
/** The (device, stream) pairs still inside the warm window, dropping the rest. */
|
|
619
|
+
active(nowMs, windowMs = WARM_WINDOW_MS) {
|
|
620
|
+
const targets = [];
|
|
621
|
+
for (const [key, entry] of this.entries) {
|
|
622
|
+
if (nowMs - entry.lastRequestedAt >= windowMs) {
|
|
623
|
+
this.entries.delete(key);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
for (const [width, at] of entry.widths) if (nowMs - at >= windowMs) entry.widths.delete(width);
|
|
627
|
+
targets.push({
|
|
628
|
+
deviceId: entry.deviceId,
|
|
629
|
+
streamId: entry.streamId,
|
|
630
|
+
widths: [...entry.widths.keys()].sort((a, b) => a - b)
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
return targets;
|
|
634
|
+
}
|
|
635
|
+
get size() {
|
|
636
|
+
return this.entries.size;
|
|
637
|
+
}
|
|
638
|
+
clear() {
|
|
639
|
+
this.entries.clear();
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
//#endregion
|
|
499
643
|
//#region src/builtins/snapshot/snapshot.addon.ts
|
|
500
644
|
/** Default cache window for non-battery cams (seconds). 10s feels live. */
|
|
501
645
|
var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
|
|
@@ -562,8 +706,28 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
562
706
|
* strictly-additive delivery path). Disposed on shutdown.
|
|
563
707
|
*/
|
|
564
708
|
mediaDataPlane = null;
|
|
565
|
-
|
|
709
|
+
/**
|
|
710
|
+
* Who is being looked at, inferred from data-plane traffic. The keep-warm
|
|
711
|
+
* loop's entire bound — see `snapshot-keep-warm.ts`.
|
|
712
|
+
*/
|
|
713
|
+
subscriptions = new SnapshotSubscriptions();
|
|
714
|
+
/** Chained warm-tick timer (never `setInterval` — see `scheduleWarmTick`). */
|
|
715
|
+
warmTimer = null;
|
|
716
|
+
/** Set while a warm tick is running so ticks can never stack. */
|
|
717
|
+
warmTickRunning = false;
|
|
718
|
+
/** Flipped on shutdown so an in-flight tick does not reschedule itself. */
|
|
719
|
+
stopped = false;
|
|
720
|
+
/**
|
|
721
|
+
* How a full frame becomes a card-sized one. Defaults to the real ffmpeg
|
|
722
|
+
* resize; injectable so the wrapper's own logic is testable without spawning a
|
|
723
|
+
* process for every fixture. `JpegResizer` was declared for exactly this and
|
|
724
|
+
* had no injector — which is why the resize-failure path had never been
|
|
725
|
+
* exercised anywhere but production.
|
|
726
|
+
*/
|
|
727
|
+
resize;
|
|
728
|
+
constructor(resize = resizeJpeg) {
|
|
566
729
|
super({ staleTtlMs: 6e4 });
|
|
730
|
+
this.resize = resize;
|
|
567
731
|
}
|
|
568
732
|
async onInitialize() {
|
|
569
733
|
this.ctx.logger.info("Snapshot wrapper initialized");
|
|
@@ -577,6 +741,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
577
741
|
getSnapshotOverview: (input) => this.getSnapshotOverview(input)
|
|
578
742
|
};
|
|
579
743
|
await this.serveMediaDataPlane();
|
|
744
|
+
this.scheduleWarmTick();
|
|
580
745
|
return [{
|
|
581
746
|
capability: snapshotCapability,
|
|
582
747
|
provider
|
|
@@ -592,7 +757,10 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
592
757
|
* base64 path.
|
|
593
758
|
*/
|
|
594
759
|
async serveMediaDataPlane() {
|
|
595
|
-
const handler = createSnapshotMediaHandler({
|
|
760
|
+
const handler = createSnapshotMediaHandler({
|
|
761
|
+
getMedia: (deviceId, streamId, force, width) => this.resolveSnapshotMedia(deviceId, streamId, force, width),
|
|
762
|
+
peekFresh: (deviceId, streamId) => this.peekFreshFrame(deviceId, streamId)
|
|
763
|
+
});
|
|
596
764
|
try {
|
|
597
765
|
this.mediaDataPlane = await this.ctx.dataPlane?.serve({
|
|
598
766
|
prefix: "media",
|
|
@@ -614,6 +782,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
614
782
|
* effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
|
|
615
783
|
*/
|
|
616
784
|
async resolveSnapshotMedia(deviceId, streamId, force, width) {
|
|
785
|
+
this.subscriptions.note(deviceId, streamId, width, Date.now());
|
|
617
786
|
const image = await this.getSnapshot({
|
|
618
787
|
deviceId,
|
|
619
788
|
...streamId !== void 0 ? { streamId } : {},
|
|
@@ -623,14 +792,128 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
623
792
|
const capturedAt = this.cache.get(deviceId, streamId)?.ts ?? Date.now();
|
|
624
793
|
const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
|
|
625
794
|
const isBattery = (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false;
|
|
795
|
+
if (width === void 0) return {
|
|
796
|
+
bytes: Buffer.from(image.base64, "base64"),
|
|
797
|
+
contentType: image.contentType,
|
|
798
|
+
capturedAt,
|
|
799
|
+
maxAgeS: effectiveMaxAgeS(prefs, isBattery),
|
|
800
|
+
servedWidth: void 0
|
|
801
|
+
};
|
|
802
|
+
const thumb = await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs);
|
|
626
803
|
return {
|
|
627
|
-
bytes:
|
|
804
|
+
bytes: thumb.bytes,
|
|
628
805
|
contentType: image.contentType,
|
|
629
806
|
capturedAt,
|
|
630
|
-
maxAgeS: effectiveMaxAgeS(prefs, isBattery)
|
|
807
|
+
maxAgeS: effectiveMaxAgeS(prefs, isBattery),
|
|
808
|
+
servedWidth: thumb.width
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Cache-only freshness peek for the data-plane's conditional-GET fast path.
|
|
813
|
+
* Never captures, never resizes, never dials anything: a cached frame that is
|
|
814
|
+
* still inside its per-device max-age, or null.
|
|
815
|
+
*
|
|
816
|
+
* Returning null for a STALE entry is the important half. A 304 there would
|
|
817
|
+
* pin the tile to the old frame for as long as the client kept revalidating,
|
|
818
|
+
* which is the exact staleness this change exists to remove.
|
|
819
|
+
*/
|
|
820
|
+
async peekFreshFrame(deviceId, streamId) {
|
|
821
|
+
const hit = this.cache.get(deviceId, streamId);
|
|
822
|
+
if (!hit) return null;
|
|
823
|
+
const maxAgeS = effectiveMaxAgeS(await this.readDeviceSettings(deviceId).catch(() => ({})), (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false);
|
|
824
|
+
if (Date.now() - hit.ts >= maxAgeS * 1e3) return null;
|
|
825
|
+
return {
|
|
826
|
+
capturedAt: hit.ts,
|
|
827
|
+
maxAgeS
|
|
631
828
|
};
|
|
632
829
|
}
|
|
633
830
|
/**
|
|
831
|
+
* Schedule the next warm tick. A CHAINED timer, not `setInterval`: the next
|
|
832
|
+
* tick is armed only after the previous one settles, so a fleet that is slower
|
|
833
|
+
* than the interval degrades to "as fast as it can" instead of stacking ticks
|
|
834
|
+
* on top of each other and exhausting the ffmpeg pool.
|
|
835
|
+
*
|
|
836
|
+
* `unref` so the loop never holds the process open.
|
|
837
|
+
*/
|
|
838
|
+
scheduleWarmTick() {
|
|
839
|
+
if (this.stopped) return;
|
|
840
|
+
this.warmTimer = setTimeout(() => {
|
|
841
|
+
this.runWarmTick().finally(() => {
|
|
842
|
+
this.scheduleWarmTick();
|
|
843
|
+
});
|
|
844
|
+
}, WARM_INTERVAL_MS);
|
|
845
|
+
this.warmTimer.unref?.();
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Refresh the cache for every camera somebody is currently looking at.
|
|
849
|
+
*
|
|
850
|
+
* Deliberately NOT a blind poll of the whole fleet: with nobody watching this
|
|
851
|
+
* walks an empty list and costs nothing, which is the entire difference
|
|
852
|
+
* between this and a background poller.
|
|
853
|
+
*
|
|
854
|
+
* Battery cameras need no special case here and must not get one. A warm tick
|
|
855
|
+
* is a plain non-forced `getSnapshot`, so it meets the two gates that already
|
|
856
|
+
* exist — the 3600 s battery max-age (which makes the tick a cache hit) and
|
|
857
|
+
* the sleeping-battery gate (which refuses to capture without `force`). A
|
|
858
|
+
* second battery check here would be a rule that disagrees with the authority
|
|
859
|
+
* that already owns it.
|
|
860
|
+
*/
|
|
861
|
+
async runWarmTick() {
|
|
862
|
+
if (this.warmTickRunning || this.stopped) return;
|
|
863
|
+
this.warmTickRunning = true;
|
|
864
|
+
const logger = this.ctx.logger;
|
|
865
|
+
try {
|
|
866
|
+
const targets = this.subscriptions.active(Date.now());
|
|
867
|
+
if (targets.length === 0) return;
|
|
868
|
+
const failed = (await Promise.allSettled(targets.map((t) => this.warmOne(t, logger)))).filter((r) => r.status === "rejected").length;
|
|
869
|
+
logger.debug("snapshot: keep-warm tick", { meta: {
|
|
870
|
+
watched: targets.length,
|
|
871
|
+
failed
|
|
872
|
+
} });
|
|
873
|
+
} catch (err) {
|
|
874
|
+
logger.warn("snapshot: keep-warm tick failed", { meta: { error: errMsg(err) } });
|
|
875
|
+
} finally {
|
|
876
|
+
this.warmTickRunning = false;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Warm one (device, stream): refresh the frame, then pre-derive the widths
|
|
881
|
+
* that were actually requested.
|
|
882
|
+
*
|
|
883
|
+
* Deriving the variants matters as much as the capture. Warming only the full
|
|
884
|
+
* frame would leave every grid tile still paying an ffmpeg resize on the
|
|
885
|
+
* request path — the cache would be fresh and the tile would still be slow.
|
|
886
|
+
*/
|
|
887
|
+
async warmOne(target, logger) {
|
|
888
|
+
const work = this.warmOneUnbounded(target, logger);
|
|
889
|
+
work.catch(() => void 0);
|
|
890
|
+
if (!(await raceForResult(work, 5e3)).settled) logger.debug("snapshot: keep-warm still in flight past the tick — leaving it to land", { tags: { deviceId: target.deviceId } });
|
|
891
|
+
}
|
|
892
|
+
async warmOneUnbounded(target, logger) {
|
|
893
|
+
const { deviceId, streamId, widths } = target;
|
|
894
|
+
try {
|
|
895
|
+
if (this.stopped) return;
|
|
896
|
+
const image = await this.getSnapshot({
|
|
897
|
+
deviceId,
|
|
898
|
+
...streamId !== void 0 ? { streamId } : {},
|
|
899
|
+
force: false
|
|
900
|
+
}, WARM_INTERVAL_MS);
|
|
901
|
+
if (!image) return;
|
|
902
|
+
const capturedAt = this.cache.get(deviceId, streamId)?.ts;
|
|
903
|
+
if (capturedAt === void 0) return;
|
|
904
|
+
const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
|
|
905
|
+
for (const width of widths) {
|
|
906
|
+
if (this.stopped) return;
|
|
907
|
+
await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs);
|
|
908
|
+
}
|
|
909
|
+
} catch (err) {
|
|
910
|
+
logger.debug("snapshot: keep-warm failed for device", {
|
|
911
|
+
tags: { deviceId },
|
|
912
|
+
meta: { error: errMsg(err) }
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
634
917
|
* The frame at a card-sized width, derived once per (device, stream, width)
|
|
635
918
|
* per capture.
|
|
636
919
|
*
|
|
@@ -641,10 +924,13 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
641
924
|
*/
|
|
642
925
|
async thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs) {
|
|
643
926
|
const cached = this.variants.get(deviceId, streamId, width, capturedAt);
|
|
644
|
-
if (cached !== void 0) return
|
|
927
|
+
if (cached !== void 0) return {
|
|
928
|
+
bytes: cached,
|
|
929
|
+
width
|
|
930
|
+
};
|
|
645
931
|
const full = Buffer.from(image.base64, "base64");
|
|
646
932
|
try {
|
|
647
|
-
const resized = await
|
|
933
|
+
const resized = await this.resize(full, width);
|
|
648
934
|
this.variants.set(deviceId, streamId, width, capturedAt, resized);
|
|
649
935
|
if (prefs.snapshotDebug) this.ctx.logger.info("snapshot: thumbnail derived", {
|
|
650
936
|
tags: { deviceId },
|
|
@@ -654,7 +940,10 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
654
940
|
thumbBytes: resized.length
|
|
655
941
|
}
|
|
656
942
|
});
|
|
657
|
-
return
|
|
943
|
+
return {
|
|
944
|
+
bytes: resized,
|
|
945
|
+
width
|
|
946
|
+
};
|
|
658
947
|
} catch (err) {
|
|
659
948
|
this.ctx.logger.warn("snapshot: resize failed — serving the full frame", {
|
|
660
949
|
tags: { deviceId },
|
|
@@ -664,10 +953,19 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
664
953
|
error: errMsg(err)
|
|
665
954
|
}
|
|
666
955
|
});
|
|
667
|
-
return
|
|
956
|
+
return {
|
|
957
|
+
bytes: full,
|
|
958
|
+
width: void 0
|
|
959
|
+
};
|
|
668
960
|
}
|
|
669
961
|
}
|
|
670
962
|
async onShutdown() {
|
|
963
|
+
this.stopped = true;
|
|
964
|
+
if (this.warmTimer !== null) {
|
|
965
|
+
clearTimeout(this.warmTimer);
|
|
966
|
+
this.warmTimer = null;
|
|
967
|
+
}
|
|
968
|
+
this.subscriptions.clear();
|
|
671
969
|
if (this.mediaDataPlane) {
|
|
672
970
|
await this.mediaDataPlane.dispose().catch(() => void 0);
|
|
673
971
|
this.mediaDataPlane = null;
|
|
@@ -706,7 +1004,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
706
1004
|
})]
|
|
707
1005
|
}] });
|
|
708
1006
|
}
|
|
709
|
-
async getSnapshot(input) {
|
|
1007
|
+
async getSnapshot(input, warmAheadMs = 0) {
|
|
710
1008
|
const { deviceId } = input;
|
|
711
1009
|
const force = input.force === true;
|
|
712
1010
|
const meta = await this.lookupDeviceMeta(deviceId);
|
|
@@ -725,7 +1023,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
725
1023
|
meta: { stream: effectiveStreamId ?? "auto" }
|
|
726
1024
|
});
|
|
727
1025
|
const hit = this.cache.get(deviceId, effectiveStreamId);
|
|
728
|
-
const effectiveMaxAgeMs = effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3;
|
|
1026
|
+
const effectiveMaxAgeMs = Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3 - warmAheadMs);
|
|
729
1027
|
const decision = decideSnapshotServe({
|
|
730
1028
|
now,
|
|
731
1029
|
cachedAt: hit?.ts ?? null,
|
|
@@ -1087,6 +1385,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
|
|
|
1087
1385
|
*/
|
|
1088
1386
|
getSnapshotOverview(input) {
|
|
1089
1387
|
const now = Date.now();
|
|
1388
|
+
this.subscriptions.renew(input.deviceIds, now);
|
|
1090
1389
|
const rows = input.deviceIds.map((deviceId) => {
|
|
1091
1390
|
const hit = this.cache.latest(deviceId);
|
|
1092
1391
|
if (!hit) return {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription bookkeeping for the snapshot keep-warm loop.
|
|
3
|
+
*
|
|
4
|
+
* The snapshot cache has exactly one writer — a successful capture — and every
|
|
5
|
+
* capture is triggered by somebody asking. Nothing refreshes a frame on its own,
|
|
6
|
+
* so with no viewer open every thumbnail in the system is as old as the last
|
|
7
|
+
* time somebody looked. Measured on the live hub: 15 of 16 cameras cached, all
|
|
8
|
+
* captured in one 55 ms burst, all 10 minutes old and not moving.
|
|
9
|
+
*
|
|
10
|
+
* The fix is a warm loop — but a blind one would dial 16 cameras forever for
|
|
11
|
+
* nobody. This module holds the thing that makes it bounded: a warm tick only
|
|
12
|
+
* touches cameras SOMEBODY IS LOOKING AT, and "looking at" is inferred from the
|
|
13
|
+
* requests themselves. One data-plane request for a device makes it an observer
|
|
14
|
+
* for {@link WARM_WINDOW_MS}; when the last viewer closes, the loop goes quiet
|
|
15
|
+
* on its own after that window and costs exactly nothing.
|
|
16
|
+
*
|
|
17
|
+
* Pure and side-effect-free (no timer, no I/O, `now` injected) — the addon owns
|
|
18
|
+
* the timer, this owns the decision. Unit-tested in
|
|
19
|
+
* `__tests__/snapshot-keep-warm.spec.ts`.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* How often the warm loop runs. Chosen so a tile is perceptibly fresh while
|
|
23
|
+
* watched; it is also the single biggest cost lever in the design (16 cameras ×
|
|
24
|
+
* 12 ticks/min against a pool of 3 ffmpeg). A chained timer, not `setInterval` —
|
|
25
|
+
* the next tick is scheduled only after the previous one settles, so a slow
|
|
26
|
+
* fleet cannot stack ticks on top of each other.
|
|
27
|
+
*/
|
|
28
|
+
export declare const WARM_INTERVAL_MS = 5000;
|
|
29
|
+
/**
|
|
30
|
+
* How long one request keeps a device warm. Two minutes of inertia after the
|
|
31
|
+
* last observer goes away: long enough that flipping between screens (or a
|
|
32
|
+
* phone locking briefly) does not go cold, short enough that a closed app stops
|
|
33
|
+
* costing captures well before anyone notices.
|
|
34
|
+
*/
|
|
35
|
+
export declare const WARM_WINDOW_MS = 120000;
|
|
36
|
+
/**
|
|
37
|
+
* Hard bound on tracked subscriptions. The map is keyed by whatever device ids
|
|
38
|
+
* arrive over HTTP, so it is attacker-influenced: a sweep over ids must not be
|
|
39
|
+
* able to grow it without limit, nor make a warm tick dial an unbounded fleet.
|
|
40
|
+
* Well above any real install's simultaneously-watched camera count.
|
|
41
|
+
*/
|
|
42
|
+
export declare const MAX_WARM_TARGETS = 64;
|
|
43
|
+
/** One (device, stream) the warm loop should refresh, plus the widths a client
|
|
44
|
+
* actually asked for — so the tick can pre-derive exactly those variants and
|
|
45
|
+
* leave the next real request a hit at BOTH cache levels. */
|
|
46
|
+
export interface SnapshotWarmTarget {
|
|
47
|
+
readonly deviceId: number;
|
|
48
|
+
readonly streamId: string | undefined;
|
|
49
|
+
/** Requested widths still inside the window, ascending. Empty = full frame only. */
|
|
50
|
+
readonly widths: readonly number[];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Who is being watched right now, and at what sizes.
|
|
54
|
+
*
|
|
55
|
+
* `note` on every data-plane request; `active` on every warm tick. `active`
|
|
56
|
+
* PRUNES as it walks rather than merely filtering — the map is fed by request
|
|
57
|
+
* traffic, so a filter-only read would leave every device ever requested in
|
|
58
|
+
* memory for the life of the process.
|
|
59
|
+
*/
|
|
60
|
+
export declare class SnapshotSubscriptions {
|
|
61
|
+
private readonly entries;
|
|
62
|
+
/** Record that somebody asked for this (device, stream) — at `width`, when the
|
|
63
|
+
* request carried one. */
|
|
64
|
+
note(deviceId: number, streamId: string | undefined, width: number | undefined, nowMs: number): void;
|
|
65
|
+
/**
|
|
66
|
+
* Refresh the window on subscriptions that ALREADY exist. Never creates one.
|
|
67
|
+
*
|
|
68
|
+
* This is what stops the design deadlocking, and the deadlock was measured on
|
|
69
|
+
* the live hub before it existed. The client re-requests an image only when
|
|
70
|
+
* the frame identity changes, and the identity changes only because the warm
|
|
71
|
+
* loop captured — so 120 s after the last image request the subscription
|
|
72
|
+
* expired, warming stopped, the identity froze, the client never asked again,
|
|
73
|
+
* and the tile was stuck. Ages went 13 s → 49.7 s at exactly t+120 s.
|
|
74
|
+
*
|
|
75
|
+
* The client's `getSnapshotOverview` poll is the heartbeat: asking "what is
|
|
76
|
+
* the state of these cameras" IS the statement that somebody is looking. It
|
|
77
|
+
* deliberately cannot CREATE a subscription, because that poll is app-wide —
|
|
78
|
+
* if it did, opening the app would warm every camera on the install, which is
|
|
79
|
+
* the blind poll this whole design avoids. Only a rendered surface subscribes.
|
|
80
|
+
*/
|
|
81
|
+
renew(deviceIds: readonly number[], nowMs: number): void;
|
|
82
|
+
/** The (device, stream) pairs still inside the warm window, dropping the rest. */
|
|
83
|
+
active(nowMs: number, windowMs?: number): readonly SnapshotWarmTarget[];
|
|
84
|
+
get size(): number;
|
|
85
|
+
clear(): void;
|
|
86
|
+
}
|
|
@@ -9,6 +9,24 @@ export interface SnapshotMedia {
|
|
|
9
9
|
readonly capturedAt: number;
|
|
10
10
|
/** Effective per-device max cache age (seconds) → `Cache-Control: max-age`. */
|
|
11
11
|
readonly maxAgeS: number;
|
|
12
|
+
/**
|
|
13
|
+
* The width the returned BYTES are actually at, or undefined when they are the
|
|
14
|
+
* frame as captured.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately not the same thing as the requested width. A failed resize
|
|
17
|
+
* serves the full frame, and stamping the requested `-w320` on it poisons the
|
|
18
|
+
* ETag: a later SUCCESSFUL resize of the same capture mints the identical tag
|
|
19
|
+
* with completely different bytes, so a client holding the megabyte revalidates,
|
|
20
|
+
* gets a 304, and keeps it. The tag has to describe what went out.
|
|
21
|
+
*/
|
|
22
|
+
readonly servedWidth?: number | undefined;
|
|
23
|
+
}
|
|
24
|
+
/** What the cache can answer about a device WITHOUT capturing anything. */
|
|
25
|
+
export interface SnapshotPeek {
|
|
26
|
+
/** Epoch ms of the cached capture. */
|
|
27
|
+
readonly capturedAt: number;
|
|
28
|
+
/** Effective per-device max cache age (seconds). */
|
|
29
|
+
readonly maxAgeS: number;
|
|
12
30
|
}
|
|
13
31
|
export interface SnapshotMediaHandlerDeps {
|
|
14
32
|
/**
|
|
@@ -17,6 +35,27 @@ export interface SnapshotMediaHandlerDeps {
|
|
|
17
35
|
* produced (→ 404). Throwing produces an opaque 500 (caller logs).
|
|
18
36
|
*/
|
|
19
37
|
readonly getMedia: (deviceId: number, streamId: string | undefined, force: boolean, width: number | undefined) => Promise<SnapshotMedia | null>;
|
|
38
|
+
/**
|
|
39
|
+
* Cache-only lookup: what frame is cached for this (device, stream) right now,
|
|
40
|
+
* and is it still FRESH? Returns null when nothing is cached or the entry is
|
|
41
|
+
* past its max-age.
|
|
42
|
+
*
|
|
43
|
+
* This exists so a conditional GET can be answered before `getMedia` runs.
|
|
44
|
+
* Without it a 304 is only cheap in bytes — the ladder and the resize have
|
|
45
|
+
* both already happened by the time the ETags are compared, which on a camera
|
|
46
|
+
* whose live paths are failing is two 15 s ffmpeg timeouts to send zero bytes.
|
|
47
|
+
*
|
|
48
|
+
* Width is deliberately absent: the ETag the client presents already names the
|
|
49
|
+
* width it holds, and it can only hold a tag this server minted, so matching
|
|
50
|
+
* the frame identity is sufficient. A client holding the FULL frame after a
|
|
51
|
+
* failed resize presents `"<id>-<ts>"`, which cannot collide with the
|
|
52
|
+
* `"<id>-<ts>-w320"` a `?w=320` request computes — it falls through and
|
|
53
|
+
* re-derives, which is exactly right.
|
|
54
|
+
*
|
|
55
|
+
* Optional: a host that does not supply it keeps the previous behaviour
|
|
56
|
+
* exactly (run the ladder, then compare).
|
|
57
|
+
*/
|
|
58
|
+
readonly peekFresh?: (deviceId: number, streamId: string | undefined) => Promise<SnapshotPeek | null>;
|
|
20
59
|
}
|
|
21
60
|
/** Parsed `/<deviceId>[.jpg][?streamId=…&force=1&w=320]` request. */
|
|
22
61
|
export interface SnapshotMediaRequest {
|
|
@@ -59,7 +98,15 @@ export declare function parseSnapshotMediaRequest(url: string): SnapshotMediaReq
|
|
|
59
98
|
* `snapshot.getSnapshotOverview` advertises still matches the frame nobody
|
|
60
99
|
* asked to resize.
|
|
61
100
|
*/
|
|
62
|
-
export declare function snapshotEtag(
|
|
101
|
+
export declare function snapshotEtag(variant: SnapshotVariantIdentity, capturedAt: number): string;
|
|
102
|
+
/** The (device, stream, width) triple an ETag names. Structurally a
|
|
103
|
+
* {@link SnapshotMediaRequest}, but the width is the width SERVED — see
|
|
104
|
+
* {@link SnapshotMedia.servedWidth}. */
|
|
105
|
+
export interface SnapshotVariantIdentity {
|
|
106
|
+
readonly deviceId: number;
|
|
107
|
+
readonly streamId: string | undefined;
|
|
108
|
+
readonly width: number | undefined;
|
|
109
|
+
}
|
|
63
110
|
/**
|
|
64
111
|
* Create a data-plane handler that serves per-device snapshots as JPEG images.
|
|
65
112
|
* `deps.getMedia` is called once per request; null → 404, throw → 500. A
|