@camstack/system 1.2.61 → 1.2.63
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/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 +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- 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-aggregation.d.ts +49 -2
- 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 +182 -888
- package/dist/builtins/device-manager/device-manager.addon.mjs +182 -888
- 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 +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- 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 +45 -5
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +45 -5
- 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-L6QPJWsr.mjs → dist-BJ2w3uoN.mjs} +1307 -1313
- package/dist/{dist-BN681PgO.js → dist-LDrOFrd-.js} +1255 -1273
- package/dist/index.d.ts +1 -1
- package/dist/index.js +209 -5
- package/dist/index.mjs +206 -7
- package/dist/kernel/addon-residue.d.ts +49 -0
- package/dist/kernel/index.d.ts +2 -0
- package/dist/kernel/moleculer/device-cap-proxy.d.ts +2 -2
- package/dist/{manifest-python-deps-CX5tGRqf.mjs → manifest-python-deps-CopL1_5C.mjs} +20 -14
- package/dist/{manifest-python-deps-d4acYXVV.js → manifest-python-deps-DNfmBu20.js} +20 -14
- 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
|
@@ -7,7 +7,17 @@ interface IssueCodeInput {
|
|
|
7
7
|
scopes: TokenScope[];
|
|
8
8
|
redirectUri: string;
|
|
9
9
|
hubUrl: string;
|
|
10
|
+
codeChallenge?: string;
|
|
10
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* PKCE S256 check (RFC 7636 §4.6): `BASE64URL(SHA256(verifier)) === challenge`.
|
|
14
|
+
*
|
|
15
|
+
* Keyed off the CLAIM, never off the descriptor: a code minted with a challenge
|
|
16
|
+
* stays unusable without the verifier even if the integration later stops
|
|
17
|
+
* requiring PKCE. Constant-time compare so a wrong verifier cannot be walked
|
|
18
|
+
* byte by byte inside the code's 60 s window.
|
|
19
|
+
*/
|
|
20
|
+
export declare function verifyPkceS256(challenge: string, verifier: string | undefined): boolean;
|
|
11
21
|
interface TokenPair {
|
|
12
22
|
accessToken: string;
|
|
13
23
|
refreshToken: string;
|
|
@@ -34,6 +44,7 @@ export declare function createOauthGrants(ssoBridge: ISsoBridgeProvider, session
|
|
|
34
44
|
oauthExchangeCode(input: {
|
|
35
45
|
code: string;
|
|
36
46
|
redirectUri: string;
|
|
47
|
+
codeVerifier?: string;
|
|
37
48
|
}): Promise<TokenPair | null>;
|
|
38
49
|
oauthRefresh(input: {
|
|
39
50
|
refreshToken: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
3
|
-
const require_dist = require("../../dist-
|
|
3
|
+
const require_dist = require("../../dist-LDrOFrd-.js");
|
|
4
4
|
let node_os = require("node:os");
|
|
5
5
|
node_os = require_chunk.__toESM(node_os);
|
|
6
6
|
//#region src/builtins/local-network/local-network.addon.ts
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Lt as EventCategory, W as localNetworkCapability, dt as BaseAddon } from "../../dist-BJ2w3uoN.mjs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
//#region src/builtins/local-network/local-network.addon.ts
|
|
4
4
|
/**
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-LDrOFrd-.js");
|
|
7
7
|
//#region src/builtins/loki-logging/loki-payload.ts
|
|
8
8
|
/**
|
|
9
9
|
* Loki rejects a label name that is not a valid Prometheus label
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { G as logDestinationCapability,
|
|
1
|
+
import { G as logDestinationCapability, dt as BaseAddon } from "../../dist-BJ2w3uoN.mjs";
|
|
2
2
|
//#region src/builtins/loki-logging/loki-payload.ts
|
|
3
3
|
/**
|
|
4
4
|
* Loki rejects a label name that is not a valid Prometheus label
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-LDrOFrd-.js");
|
|
7
7
|
let node_fs = require("node:fs");
|
|
8
8
|
node_fs = require_chunk.__toESM(node_fs);
|
|
9
9
|
let node_child_process = require("node:child_process");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { J as metricsProviderCapability, Lt as EventCategory, dt as BaseAddon, wt as createEvent } from "../../dist-BJ2w3uoN.mjs";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import { execFile, execFileSync } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-LDrOFrd-.js");
|
|
7
7
|
let node_fs = require("node:fs");
|
|
8
8
|
node_fs = require_chunk.__toESM(node_fs);
|
|
9
9
|
let node_child_process = require("node:child_process");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { o as __toESM } from "../../chunk-CNf5ZN-e.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { $ as platformProbeCapability, Et as emitReadiness, Lt as EventCategory, M as enumerateInferenceDevices, dt as BaseAddon, tt as scoreRuntimes, ut as errMsg } from "../../dist-BJ2w3uoN.mjs";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-LDrOFrd-.js");
|
|
7
7
|
//#region src/builtins/remote-access-orchestrator/enabled-providers-reconcile.ts
|
|
8
8
|
/**
|
|
9
9
|
* Reconcile the durable `enabledProviders` set against authoritative
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Lt as EventCategory, dt as BaseAddon } from "../../dist-BJ2w3uoN.mjs";
|
|
2
2
|
//#region src/builtins/remote-access-orchestrator/enabled-providers-reconcile.ts
|
|
3
3
|
/**
|
|
4
4
|
* Reconcile the durable `enabledProviders` set against authoritative
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-LDrOFrd-.js");
|
|
7
7
|
let node_child_process = require("node:child_process");
|
|
8
8
|
//#region src/builtins/snapshot/snapshot-coalescing.ts
|
|
9
9
|
/**
|
|
@@ -266,10 +266,10 @@ function parseSnapshotMediaRequest(url) {
|
|
|
266
266
|
* `snapshot.getSnapshotOverview` advertises still matches the frame nobody
|
|
267
267
|
* asked to resize.
|
|
268
268
|
*/
|
|
269
|
-
function snapshotEtag(
|
|
270
|
-
const parts = [`${String(
|
|
271
|
-
if (
|
|
272
|
-
if (
|
|
269
|
+
function snapshotEtag(variant, capturedAt) {
|
|
270
|
+
const parts = [`${String(variant.deviceId)}-${String(capturedAt)}`];
|
|
271
|
+
if (variant.streamId !== void 0) parts.push(`s${variant.streamId}`);
|
|
272
|
+
if (variant.width !== void 0) parts.push(`w${String(variant.width)}`);
|
|
273
273
|
return `"${parts.join("-")}"`;
|
|
274
274
|
}
|
|
275
275
|
/**
|
|
@@ -288,6 +288,29 @@ function createSnapshotMediaHandler(deps) {
|
|
|
288
288
|
res.writeHead(404).end();
|
|
289
289
|
return;
|
|
290
290
|
}
|
|
291
|
+
const inm = req.headers["if-none-match"];
|
|
292
|
+
if (typeof inm === "string" && !parsed.force && deps.peekFresh !== void 0) {
|
|
293
|
+
let peeked = null;
|
|
294
|
+
try {
|
|
295
|
+
peeked = await deps.peekFresh(parsed.deviceId, parsed.streamId);
|
|
296
|
+
} catch {
|
|
297
|
+
peeked = null;
|
|
298
|
+
}
|
|
299
|
+
if (peeked !== null) {
|
|
300
|
+
const peekedEtag = snapshotEtag({
|
|
301
|
+
deviceId: parsed.deviceId,
|
|
302
|
+
streamId: parsed.streamId,
|
|
303
|
+
width: parsed.width
|
|
304
|
+
}, peeked.capturedAt);
|
|
305
|
+
if (inm === peekedEtag) {
|
|
306
|
+
res.writeHead(304, {
|
|
307
|
+
etag: peekedEtag,
|
|
308
|
+
"cache-control": `private, max-age=${Math.max(0, Math.floor(peeked.maxAgeS))}`
|
|
309
|
+
}).end();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
291
314
|
let media;
|
|
292
315
|
try {
|
|
293
316
|
media = await deps.getMedia(parsed.deviceId, parsed.streamId, parsed.force, parsed.width);
|
|
@@ -305,9 +328,13 @@ function createSnapshotMediaHandler(deps) {
|
|
|
305
328
|
res.writeHead(404).end();
|
|
306
329
|
return;
|
|
307
330
|
}
|
|
308
|
-
const etag = snapshotEtag(
|
|
331
|
+
const etag = snapshotEtag({
|
|
332
|
+
deviceId: parsed.deviceId,
|
|
333
|
+
streamId: parsed.streamId,
|
|
334
|
+
width: "servedWidth" in media ? media.servedWidth : parsed.width
|
|
335
|
+
}, media.capturedAt);
|
|
309
336
|
const cacheControl = `private, max-age=${Math.max(0, Math.floor(media.maxAgeS))}`;
|
|
310
|
-
if (
|
|
337
|
+
if (inm === etag) {
|
|
311
338
|
res.writeHead(304, {
|
|
312
339
|
etag,
|
|
313
340
|
"cache-control": cacheControl
|
|
@@ -330,7 +357,7 @@ function createSnapshotMediaHandler(deps) {
|
|
|
330
357
|
* per-device preference decides what it captures, and conflating it with an
|
|
331
358
|
* explicit request is how the original bug read. */
|
|
332
359
|
var AUTO = "auto";
|
|
333
|
-
function keyOf(deviceId, streamId) {
|
|
360
|
+
function keyOf$1(deviceId, streamId) {
|
|
334
361
|
return `${deviceId}:${streamId ?? AUTO}`;
|
|
335
362
|
}
|
|
336
363
|
var SnapshotCache = class {
|
|
@@ -338,10 +365,10 @@ var SnapshotCache = class {
|
|
|
338
365
|
/** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
|
|
339
366
|
keysByDevice = /* @__PURE__ */ new Map();
|
|
340
367
|
get(deviceId, streamId) {
|
|
341
|
-
return this.byKey.get(keyOf(deviceId, streamId));
|
|
368
|
+
return this.byKey.get(keyOf$1(deviceId, streamId));
|
|
342
369
|
}
|
|
343
370
|
set(deviceId, streamId, entry) {
|
|
344
|
-
const key = keyOf(deviceId, streamId);
|
|
371
|
+
const key = keyOf$1(deviceId, streamId);
|
|
345
372
|
this.byKey.set(key, entry);
|
|
346
373
|
const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
|
|
347
374
|
keys.add(key);
|
|
@@ -501,6 +528,123 @@ var SnapshotVariantCache = class SnapshotVariantCache {
|
|
|
501
528
|
}
|
|
502
529
|
};
|
|
503
530
|
//#endregion
|
|
531
|
+
//#region src/builtins/snapshot/snapshot-keep-warm.ts
|
|
532
|
+
/**
|
|
533
|
+
* Subscription bookkeeping for the snapshot keep-warm loop.
|
|
534
|
+
*
|
|
535
|
+
* The snapshot cache has exactly one writer — a successful capture — and every
|
|
536
|
+
* capture is triggered by somebody asking. Nothing refreshes a frame on its own,
|
|
537
|
+
* so with no viewer open every thumbnail in the system is as old as the last
|
|
538
|
+
* time somebody looked. Measured on the live hub: 15 of 16 cameras cached, all
|
|
539
|
+
* captured in one 55 ms burst, all 10 minutes old and not moving.
|
|
540
|
+
*
|
|
541
|
+
* The fix is a warm loop — but a blind one would dial 16 cameras forever for
|
|
542
|
+
* nobody. This module holds the thing that makes it bounded: a warm tick only
|
|
543
|
+
* touches cameras SOMEBODY IS LOOKING AT, and "looking at" is inferred from the
|
|
544
|
+
* requests themselves. One data-plane request for a device makes it an observer
|
|
545
|
+
* for {@link WARM_WINDOW_MS}; when the last viewer closes, the loop goes quiet
|
|
546
|
+
* on its own after that window and costs exactly nothing.
|
|
547
|
+
*
|
|
548
|
+
* Pure and side-effect-free (no timer, no I/O, `now` injected) — the addon owns
|
|
549
|
+
* the timer, this owns the decision. Unit-tested in
|
|
550
|
+
* `__tests__/snapshot-keep-warm.spec.ts`.
|
|
551
|
+
*/
|
|
552
|
+
/**
|
|
553
|
+
* How often the warm loop runs. Chosen so a tile is perceptibly fresh while
|
|
554
|
+
* watched; it is also the single biggest cost lever in the design (16 cameras ×
|
|
555
|
+
* 12 ticks/min against a pool of 3 ffmpeg). A chained timer, not `setInterval` —
|
|
556
|
+
* the next tick is scheduled only after the previous one settles, so a slow
|
|
557
|
+
* fleet cannot stack ticks on top of each other.
|
|
558
|
+
*/
|
|
559
|
+
var WARM_INTERVAL_MS = 5e3;
|
|
560
|
+
/**
|
|
561
|
+
* How long one request keeps a device warm. Two minutes of inertia after the
|
|
562
|
+
* last observer goes away: long enough that flipping between screens (or a
|
|
563
|
+
* phone locking briefly) does not go cold, short enough that a closed app stops
|
|
564
|
+
* costing captures well before anyone notices.
|
|
565
|
+
*/
|
|
566
|
+
var WARM_WINDOW_MS = 12e4;
|
|
567
|
+
function keyOf(deviceId, streamId) {
|
|
568
|
+
return `${deviceId}:${streamId ?? "auto"}`;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Who is being watched right now, and at what sizes.
|
|
572
|
+
*
|
|
573
|
+
* `note` on every data-plane request; `active` on every warm tick. `active`
|
|
574
|
+
* PRUNES as it walks rather than merely filtering — the map is fed by request
|
|
575
|
+
* traffic, so a filter-only read would leave every device ever requested in
|
|
576
|
+
* memory for the life of the process.
|
|
577
|
+
*/
|
|
578
|
+
var SnapshotSubscriptions = class {
|
|
579
|
+
entries = /* @__PURE__ */ new Map();
|
|
580
|
+
/** Record that somebody asked for this (device, stream) — at `width`, when the
|
|
581
|
+
* request carried one. */
|
|
582
|
+
note(deviceId, streamId, width, nowMs) {
|
|
583
|
+
const key = keyOf(deviceId, streamId);
|
|
584
|
+
const existing = this.entries.get(key);
|
|
585
|
+
if (existing) {
|
|
586
|
+
existing.lastRequestedAt = nowMs;
|
|
587
|
+
if (width !== void 0) existing.widths.set(width, nowMs);
|
|
588
|
+
this.entries.delete(key);
|
|
589
|
+
this.entries.set(key, existing);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
this.entries.set(key, {
|
|
593
|
+
deviceId,
|
|
594
|
+
streamId,
|
|
595
|
+
lastRequestedAt: nowMs,
|
|
596
|
+
widths: width === void 0 ? /* @__PURE__ */ new Map() : new Map([[width, nowMs]])
|
|
597
|
+
});
|
|
598
|
+
while (this.entries.size > 64) {
|
|
599
|
+
const oldest = this.entries.keys().next();
|
|
600
|
+
if (oldest.done === true) break;
|
|
601
|
+
this.entries.delete(oldest.value);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Refresh the window on subscriptions that ALREADY exist. Never creates one.
|
|
606
|
+
*
|
|
607
|
+
* This is what stops the design deadlocking, and the deadlock was measured on
|
|
608
|
+
* the live hub before it existed. The client re-requests an image only when
|
|
609
|
+
* the frame identity changes, and the identity changes only because the warm
|
|
610
|
+
* loop captured — so 120 s after the last image request the subscription
|
|
611
|
+
* expired, warming stopped, the identity froze, the client never asked again,
|
|
612
|
+
* and the tile was stuck. Ages went 13 s → 49.7 s at exactly t+120 s.
|
|
613
|
+
*
|
|
614
|
+
* The client's `getSnapshotOverview` poll is the heartbeat: asking "what is
|
|
615
|
+
* the state of these cameras" IS the statement that somebody is looking. It
|
|
616
|
+
* deliberately cannot CREATE a subscription, because that poll is app-wide —
|
|
617
|
+
* if it did, opening the app would warm every camera on the install, which is
|
|
618
|
+
* the blind poll this whole design avoids. Only a rendered surface subscribes.
|
|
619
|
+
*/
|
|
620
|
+
renew(deviceIds, nowMs) {
|
|
621
|
+
for (const deviceId of deviceIds) for (const entry of this.entries.values()) if (entry.deviceId === deviceId) entry.lastRequestedAt = nowMs;
|
|
622
|
+
}
|
|
623
|
+
/** The (device, stream) pairs still inside the warm window, dropping the rest. */
|
|
624
|
+
active(nowMs, windowMs = WARM_WINDOW_MS) {
|
|
625
|
+
const targets = [];
|
|
626
|
+
for (const [key, entry] of this.entries) {
|
|
627
|
+
if (nowMs - entry.lastRequestedAt >= windowMs) {
|
|
628
|
+
this.entries.delete(key);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
for (const [width, at] of entry.widths) if (nowMs - at >= windowMs) entry.widths.delete(width);
|
|
632
|
+
targets.push({
|
|
633
|
+
deviceId: entry.deviceId,
|
|
634
|
+
streamId: entry.streamId,
|
|
635
|
+
widths: [...entry.widths.keys()].sort((a, b) => a - b)
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
return targets;
|
|
639
|
+
}
|
|
640
|
+
get size() {
|
|
641
|
+
return this.entries.size;
|
|
642
|
+
}
|
|
643
|
+
clear() {
|
|
644
|
+
this.entries.clear();
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
//#endregion
|
|
504
648
|
//#region src/builtins/snapshot/snapshot.addon.ts
|
|
505
649
|
/** Default cache window for non-battery cams (seconds). 10s feels live. */
|
|
506
650
|
var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
|
|
@@ -567,8 +711,28 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
567
711
|
* strictly-additive delivery path). Disposed on shutdown.
|
|
568
712
|
*/
|
|
569
713
|
mediaDataPlane = null;
|
|
570
|
-
|
|
714
|
+
/**
|
|
715
|
+
* Who is being looked at, inferred from data-plane traffic. The keep-warm
|
|
716
|
+
* loop's entire bound — see `snapshot-keep-warm.ts`.
|
|
717
|
+
*/
|
|
718
|
+
subscriptions = new SnapshotSubscriptions();
|
|
719
|
+
/** Chained warm-tick timer (never `setInterval` — see `scheduleWarmTick`). */
|
|
720
|
+
warmTimer = null;
|
|
721
|
+
/** Set while a warm tick is running so ticks can never stack. */
|
|
722
|
+
warmTickRunning = false;
|
|
723
|
+
/** Flipped on shutdown so an in-flight tick does not reschedule itself. */
|
|
724
|
+
stopped = false;
|
|
725
|
+
/**
|
|
726
|
+
* How a full frame becomes a card-sized one. Defaults to the real ffmpeg
|
|
727
|
+
* resize; injectable so the wrapper's own logic is testable without spawning a
|
|
728
|
+
* process for every fixture. `JpegResizer` was declared for exactly this and
|
|
729
|
+
* had no injector — which is why the resize-failure path had never been
|
|
730
|
+
* exercised anywhere but production.
|
|
731
|
+
*/
|
|
732
|
+
resize;
|
|
733
|
+
constructor(resize = resizeJpeg) {
|
|
571
734
|
super({ staleTtlMs: 6e4 });
|
|
735
|
+
this.resize = resize;
|
|
572
736
|
}
|
|
573
737
|
async onInitialize() {
|
|
574
738
|
this.ctx.logger.info("Snapshot wrapper initialized");
|
|
@@ -582,6 +746,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
582
746
|
getSnapshotOverview: (input) => this.getSnapshotOverview(input)
|
|
583
747
|
};
|
|
584
748
|
await this.serveMediaDataPlane();
|
|
749
|
+
this.scheduleWarmTick();
|
|
585
750
|
return [{
|
|
586
751
|
capability: require_dist.snapshotCapability,
|
|
587
752
|
provider
|
|
@@ -597,7 +762,10 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
597
762
|
* base64 path.
|
|
598
763
|
*/
|
|
599
764
|
async serveMediaDataPlane() {
|
|
600
|
-
const handler = createSnapshotMediaHandler({
|
|
765
|
+
const handler = createSnapshotMediaHandler({
|
|
766
|
+
getMedia: (deviceId, streamId, force, width) => this.resolveSnapshotMedia(deviceId, streamId, force, width),
|
|
767
|
+
peekFresh: (deviceId, streamId) => this.peekFreshFrame(deviceId, streamId)
|
|
768
|
+
});
|
|
601
769
|
try {
|
|
602
770
|
this.mediaDataPlane = await this.ctx.dataPlane?.serve({
|
|
603
771
|
prefix: "media",
|
|
@@ -619,6 +787,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
619
787
|
* effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
|
|
620
788
|
*/
|
|
621
789
|
async resolveSnapshotMedia(deviceId, streamId, force, width) {
|
|
790
|
+
this.subscriptions.note(deviceId, streamId, width, Date.now());
|
|
622
791
|
const image = await this.getSnapshot({
|
|
623
792
|
deviceId,
|
|
624
793
|
...streamId !== void 0 ? { streamId } : {},
|
|
@@ -628,14 +797,128 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
628
797
|
const capturedAt = this.cache.get(deviceId, streamId)?.ts ?? Date.now();
|
|
629
798
|
const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
|
|
630
799
|
const isBattery = (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false;
|
|
800
|
+
if (width === void 0) return {
|
|
801
|
+
bytes: Buffer.from(image.base64, "base64"),
|
|
802
|
+
contentType: image.contentType,
|
|
803
|
+
capturedAt,
|
|
804
|
+
maxAgeS: effectiveMaxAgeS(prefs, isBattery),
|
|
805
|
+
servedWidth: void 0
|
|
806
|
+
};
|
|
807
|
+
const thumb = await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs);
|
|
631
808
|
return {
|
|
632
|
-
bytes:
|
|
809
|
+
bytes: thumb.bytes,
|
|
633
810
|
contentType: image.contentType,
|
|
634
811
|
capturedAt,
|
|
635
|
-
maxAgeS: effectiveMaxAgeS(prefs, isBattery)
|
|
812
|
+
maxAgeS: effectiveMaxAgeS(prefs, isBattery),
|
|
813
|
+
servedWidth: thumb.width
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Cache-only freshness peek for the data-plane's conditional-GET fast path.
|
|
818
|
+
* Never captures, never resizes, never dials anything: a cached frame that is
|
|
819
|
+
* still inside its per-device max-age, or null.
|
|
820
|
+
*
|
|
821
|
+
* Returning null for a STALE entry is the important half. A 304 there would
|
|
822
|
+
* pin the tile to the old frame for as long as the client kept revalidating,
|
|
823
|
+
* which is the exact staleness this change exists to remove.
|
|
824
|
+
*/
|
|
825
|
+
async peekFreshFrame(deviceId, streamId) {
|
|
826
|
+
const hit = this.cache.get(deviceId, streamId);
|
|
827
|
+
if (!hit) return null;
|
|
828
|
+
const maxAgeS = effectiveMaxAgeS(await this.readDeviceSettings(deviceId).catch(() => ({})), (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false);
|
|
829
|
+
if (Date.now() - hit.ts >= maxAgeS * 1e3) return null;
|
|
830
|
+
return {
|
|
831
|
+
capturedAt: hit.ts,
|
|
832
|
+
maxAgeS
|
|
636
833
|
};
|
|
637
834
|
}
|
|
638
835
|
/**
|
|
836
|
+
* Schedule the next warm tick. A CHAINED timer, not `setInterval`: the next
|
|
837
|
+
* tick is armed only after the previous one settles, so a fleet that is slower
|
|
838
|
+
* than the interval degrades to "as fast as it can" instead of stacking ticks
|
|
839
|
+
* on top of each other and exhausting the ffmpeg pool.
|
|
840
|
+
*
|
|
841
|
+
* `unref` so the loop never holds the process open.
|
|
842
|
+
*/
|
|
843
|
+
scheduleWarmTick() {
|
|
844
|
+
if (this.stopped) return;
|
|
845
|
+
this.warmTimer = setTimeout(() => {
|
|
846
|
+
this.runWarmTick().finally(() => {
|
|
847
|
+
this.scheduleWarmTick();
|
|
848
|
+
});
|
|
849
|
+
}, WARM_INTERVAL_MS);
|
|
850
|
+
this.warmTimer.unref?.();
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Refresh the cache for every camera somebody is currently looking at.
|
|
854
|
+
*
|
|
855
|
+
* Deliberately NOT a blind poll of the whole fleet: with nobody watching this
|
|
856
|
+
* walks an empty list and costs nothing, which is the entire difference
|
|
857
|
+
* between this and a background poller.
|
|
858
|
+
*
|
|
859
|
+
* Battery cameras need no special case here and must not get one. A warm tick
|
|
860
|
+
* is a plain non-forced `getSnapshot`, so it meets the two gates that already
|
|
861
|
+
* exist — the 3600 s battery max-age (which makes the tick a cache hit) and
|
|
862
|
+
* the sleeping-battery gate (which refuses to capture without `force`). A
|
|
863
|
+
* second battery check here would be a rule that disagrees with the authority
|
|
864
|
+
* that already owns it.
|
|
865
|
+
*/
|
|
866
|
+
async runWarmTick() {
|
|
867
|
+
if (this.warmTickRunning || this.stopped) return;
|
|
868
|
+
this.warmTickRunning = true;
|
|
869
|
+
const logger = this.ctx.logger;
|
|
870
|
+
try {
|
|
871
|
+
const targets = this.subscriptions.active(Date.now());
|
|
872
|
+
if (targets.length === 0) return;
|
|
873
|
+
const failed = (await Promise.allSettled(targets.map((t) => this.warmOne(t, logger)))).filter((r) => r.status === "rejected").length;
|
|
874
|
+
logger.debug("snapshot: keep-warm tick", { meta: {
|
|
875
|
+
watched: targets.length,
|
|
876
|
+
failed
|
|
877
|
+
} });
|
|
878
|
+
} catch (err) {
|
|
879
|
+
logger.warn("snapshot: keep-warm tick failed", { meta: { error: require_dist.errMsg(err) } });
|
|
880
|
+
} finally {
|
|
881
|
+
this.warmTickRunning = false;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Warm one (device, stream): refresh the frame, then pre-derive the widths
|
|
886
|
+
* that were actually requested.
|
|
887
|
+
*
|
|
888
|
+
* Deriving the variants matters as much as the capture. Warming only the full
|
|
889
|
+
* frame would leave every grid tile still paying an ffmpeg resize on the
|
|
890
|
+
* request path — the cache would be fresh and the tile would still be slow.
|
|
891
|
+
*/
|
|
892
|
+
async warmOne(target, logger) {
|
|
893
|
+
const work = this.warmOneUnbounded(target, logger);
|
|
894
|
+
work.catch(() => void 0);
|
|
895
|
+
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 } });
|
|
896
|
+
}
|
|
897
|
+
async warmOneUnbounded(target, logger) {
|
|
898
|
+
const { deviceId, streamId, widths } = target;
|
|
899
|
+
try {
|
|
900
|
+
if (this.stopped) return;
|
|
901
|
+
const image = await this.getSnapshot({
|
|
902
|
+
deviceId,
|
|
903
|
+
...streamId !== void 0 ? { streamId } : {},
|
|
904
|
+
force: false
|
|
905
|
+
}, WARM_INTERVAL_MS);
|
|
906
|
+
if (!image) return;
|
|
907
|
+
const capturedAt = this.cache.get(deviceId, streamId)?.ts;
|
|
908
|
+
if (capturedAt === void 0) return;
|
|
909
|
+
const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
|
|
910
|
+
for (const width of widths) {
|
|
911
|
+
if (this.stopped) return;
|
|
912
|
+
await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs);
|
|
913
|
+
}
|
|
914
|
+
} catch (err) {
|
|
915
|
+
logger.debug("snapshot: keep-warm failed for device", {
|
|
916
|
+
tags: { deviceId },
|
|
917
|
+
meta: { error: require_dist.errMsg(err) }
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
639
922
|
* The frame at a card-sized width, derived once per (device, stream, width)
|
|
640
923
|
* per capture.
|
|
641
924
|
*
|
|
@@ -646,10 +929,13 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
646
929
|
*/
|
|
647
930
|
async thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs) {
|
|
648
931
|
const cached = this.variants.get(deviceId, streamId, width, capturedAt);
|
|
649
|
-
if (cached !== void 0) return
|
|
932
|
+
if (cached !== void 0) return {
|
|
933
|
+
bytes: cached,
|
|
934
|
+
width
|
|
935
|
+
};
|
|
650
936
|
const full = Buffer.from(image.base64, "base64");
|
|
651
937
|
try {
|
|
652
|
-
const resized = await
|
|
938
|
+
const resized = await this.resize(full, width);
|
|
653
939
|
this.variants.set(deviceId, streamId, width, capturedAt, resized);
|
|
654
940
|
if (prefs.snapshotDebug) this.ctx.logger.info("snapshot: thumbnail derived", {
|
|
655
941
|
tags: { deviceId },
|
|
@@ -659,7 +945,10 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
659
945
|
thumbBytes: resized.length
|
|
660
946
|
}
|
|
661
947
|
});
|
|
662
|
-
return
|
|
948
|
+
return {
|
|
949
|
+
bytes: resized,
|
|
950
|
+
width
|
|
951
|
+
};
|
|
663
952
|
} catch (err) {
|
|
664
953
|
this.ctx.logger.warn("snapshot: resize failed — serving the full frame", {
|
|
665
954
|
tags: { deviceId },
|
|
@@ -669,10 +958,19 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
669
958
|
error: require_dist.errMsg(err)
|
|
670
959
|
}
|
|
671
960
|
});
|
|
672
|
-
return
|
|
961
|
+
return {
|
|
962
|
+
bytes: full,
|
|
963
|
+
width: void 0
|
|
964
|
+
};
|
|
673
965
|
}
|
|
674
966
|
}
|
|
675
967
|
async onShutdown() {
|
|
968
|
+
this.stopped = true;
|
|
969
|
+
if (this.warmTimer !== null) {
|
|
970
|
+
clearTimeout(this.warmTimer);
|
|
971
|
+
this.warmTimer = null;
|
|
972
|
+
}
|
|
973
|
+
this.subscriptions.clear();
|
|
676
974
|
if (this.mediaDataPlane) {
|
|
677
975
|
await this.mediaDataPlane.dispose().catch(() => void 0);
|
|
678
976
|
this.mediaDataPlane = null;
|
|
@@ -711,7 +1009,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
711
1009
|
})]
|
|
712
1010
|
}] });
|
|
713
1011
|
}
|
|
714
|
-
async getSnapshot(input) {
|
|
1012
|
+
async getSnapshot(input, warmAheadMs = 0) {
|
|
715
1013
|
const { deviceId } = input;
|
|
716
1014
|
const force = input.force === true;
|
|
717
1015
|
const meta = await this.lookupDeviceMeta(deviceId);
|
|
@@ -730,7 +1028,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
730
1028
|
meta: { stream: effectiveStreamId ?? "auto" }
|
|
731
1029
|
});
|
|
732
1030
|
const hit = this.cache.get(deviceId, effectiveStreamId);
|
|
733
|
-
const effectiveMaxAgeMs = effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3;
|
|
1031
|
+
const effectiveMaxAgeMs = Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3 - warmAheadMs);
|
|
734
1032
|
const decision = decideSnapshotServe({
|
|
735
1033
|
now,
|
|
736
1034
|
cachedAt: hit?.ts ?? null,
|
|
@@ -1092,6 +1390,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1092
1390
|
*/
|
|
1093
1391
|
getSnapshotOverview(input) {
|
|
1094
1392
|
const now = Date.now();
|
|
1393
|
+
this.subscriptions.renew(input.deviceIds, now);
|
|
1095
1394
|
const rows = input.deviceIds.map((deviceId) => {
|
|
1096
1395
|
const hit = this.cache.latest(deviceId);
|
|
1097
1396
|
if (!hit) return {
|