@camstack/system 1.2.92 → 1.2.94
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/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/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +1 -1
- package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
- 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/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +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 +207 -75
- package/dist/builtins/snapshot/index.mjs +207 -75
- package/dist/builtins/snapshot/snapshot-courtesy.d.ts +24 -2
- package/dist/builtins/snapshot/snapshot-media-handler.d.ts +7 -1
- package/dist/builtins/snapshot/snapshot-resize.d.ts +4 -4
- package/dist/builtins/snapshot/snapshot.addon.d.ts +35 -26
- 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/sqlite-settings.addon.js +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
- 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-BH2tZ9ZN.js → dist-BspfS8Q7.js} +105 -2
- package/dist/{dist-DXDvrBi0.mjs → dist-COd223t-.mjs} +94 -3
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +1 -1
|
@@ -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-BspfS8Q7.js");
|
|
7
7
|
let zod = require("zod");
|
|
8
8
|
let node_crypto = require("node:crypto");
|
|
9
9
|
let _camstack_types_node = require("@camstack/types/node");
|
|
@@ -255,7 +255,7 @@ function raceForResult(promise, timeoutMs) {
|
|
|
255
255
|
//#endregion
|
|
256
256
|
//#region src/builtins/snapshot/snapshot-courtesy.ts
|
|
257
257
|
/**
|
|
258
|
-
*
|
|
258
|
+
* State treatment for a camera that cannot produce a current frame.
|
|
259
259
|
*
|
|
260
260
|
* ── Why this exists ───────────────────────────────────────────────────────
|
|
261
261
|
* A disabled, offline or sleeping camera has no frame, and until 2026-08-11 the
|
|
@@ -266,9 +266,9 @@ function raceForResult(promise, timeoutMs) {
|
|
|
266
266
|
* who deliberately switched a camera off saw the same thing as a fault ([D62]:
|
|
267
267
|
* "an off switch is REPORTED off; disabled must never look like broken").
|
|
268
268
|
*
|
|
269
|
-
* So the service answers with
|
|
270
|
-
* camera's own name.
|
|
271
|
-
*
|
|
269
|
+
* So the service answers with an image that SAYS what is going on, carrying
|
|
270
|
+
* the camera's own name. When a real cached frame exists it remains visible
|
|
271
|
+
* under the treatment; otherwise the generated courtesy background is used.
|
|
272
272
|
*
|
|
273
273
|
* ── Why sharp and not ffmpeg ──────────────────────────────────────────────
|
|
274
274
|
* The first draft of this file shelled out to `ffmpeg` with a `drawtext`
|
|
@@ -299,6 +299,8 @@ function courtesyLabel(reason) {
|
|
|
299
299
|
case "disabled": return "Disabled";
|
|
300
300
|
case "offline": return "Offline";
|
|
301
301
|
case "sleeping": return "Sleeping";
|
|
302
|
+
case "unreachable": return "Unreachable";
|
|
303
|
+
case "waking": return "Waking";
|
|
302
304
|
}
|
|
303
305
|
}
|
|
304
306
|
/**
|
|
@@ -310,6 +312,8 @@ function courtesyBackground(reason) {
|
|
|
310
312
|
case "disabled": return "#2b2b31";
|
|
311
313
|
case "offline": return "#3a2b2b";
|
|
312
314
|
case "sleeping": return "#232b3a";
|
|
315
|
+
case "waking": return "#243247";
|
|
316
|
+
case "unreachable": return "#3a2b2b";
|
|
313
317
|
}
|
|
314
318
|
}
|
|
315
319
|
/** XML escaping for text placed inside an SVG `<text>` node. */
|
|
@@ -324,15 +328,27 @@ function escapeCourtesyText(value) {
|
|
|
324
328
|
* frame read the same, with a floor so a thumbnail stays legible.
|
|
325
329
|
*/
|
|
326
330
|
function buildCourtesySvg(spec) {
|
|
331
|
+
return buildStateSvg(spec, false);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* The same state treatment as a courtesy frame, but translucent so the last
|
|
335
|
+
* real camera frame remains visible underneath it.
|
|
336
|
+
*/
|
|
337
|
+
function buildCourtesyOverlaySvg(spec) {
|
|
338
|
+
return buildStateSvg(spec, true);
|
|
339
|
+
}
|
|
340
|
+
function buildStateSvg(spec, overlay) {
|
|
327
341
|
const stateSize = Math.max(12, Math.round(spec.width / 12));
|
|
328
342
|
const nameSize = Math.max(9, Math.round(spec.width / 26));
|
|
329
343
|
const state = escapeCourtesyText(courtesyLabel(spec.reason));
|
|
330
344
|
const name = escapeCourtesyText(spec.deviceName);
|
|
331
345
|
const midY = spec.height / 2;
|
|
346
|
+
const washOpacity = overlay ? "0.62" : "1";
|
|
347
|
+
const textStroke = Math.max(1, Math.round(spec.width / 320));
|
|
332
348
|
return [
|
|
333
349
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${String(spec.width)}" height="${String(spec.height)}">`,
|
|
334
|
-
`<rect width="100%" height="100%" fill="${courtesyBackground(spec.reason)}"/>`,
|
|
335
|
-
`<g font-family="${COURTESY_FONT_STACK}" text-anchor="middle">`,
|
|
350
|
+
`<rect width="100%" height="100%" fill="${courtesyBackground(spec.reason)}" fill-opacity="${washOpacity}"/>`,
|
|
351
|
+
`<g font-family="${COURTESY_FONT_STACK}" text-anchor="middle" paint-order="stroke" stroke="#111118" stroke-width="${String(textStroke)}">`,
|
|
336
352
|
`<text x="50%" y="${String(Math.round(midY))}" font-size="${String(stateSize)}" fill="#e8e8ee">${state}</text>`,
|
|
337
353
|
`<text x="50%" y="${String(Math.round(midY + stateSize))}" font-size="${String(nameSize)}" fill="#9a9aa8">${name}</text>`,
|
|
338
354
|
`</g></svg>`
|
|
@@ -354,6 +370,24 @@ function renderCourtesyJpeg(spec) {
|
|
|
354
370
|
return bytes;
|
|
355
371
|
});
|
|
356
372
|
}
|
|
373
|
+
/**
|
|
374
|
+
* Paint the state treatment over a real cached frame.
|
|
375
|
+
*
|
|
376
|
+
* The source dimensions are authoritative: overlays preserve the original
|
|
377
|
+
* frame rather than resizing it to the generated courtesy-frame default.
|
|
378
|
+
*/
|
|
379
|
+
async function renderCourtesyOverlayJpeg(background, spec) {
|
|
380
|
+
const metadata = await (0, sharp.default)(background).metadata();
|
|
381
|
+
if (!metadata.width || !metadata.height) throw new Error("cached snapshot has no renderable dimensions");
|
|
382
|
+
const overlay = Buffer.from(buildCourtesyOverlaySvg({
|
|
383
|
+
...spec,
|
|
384
|
+
width: metadata.width,
|
|
385
|
+
height: metadata.height
|
|
386
|
+
}));
|
|
387
|
+
const bytes = await (0, sharp.default)(background).composite([{ input: overlay }]).jpeg({ quality: 82 }).toBuffer();
|
|
388
|
+
if (bytes.length === 0) throw new Error("courtesy overlay produced no bytes");
|
|
389
|
+
return bytes;
|
|
390
|
+
}
|
|
357
391
|
//#endregion
|
|
358
392
|
//#region src/builtins/snapshot/snapshot-link-url.ts
|
|
359
393
|
/**
|
|
@@ -547,7 +581,8 @@ function parseSnapshotMediaRequest(url) {
|
|
|
547
581
|
* The response's entity tag.
|
|
548
582
|
*
|
|
549
583
|
* It has to identify the VARIANT, not just the frame: the same capture can now
|
|
550
|
-
* be served at several widths
|
|
584
|
+
* be served at several widths, from several streams, and with a camera-state
|
|
585
|
+
* treatment, and a plain
|
|
551
586
|
* `"<device>-<capturedAt>"` would let a client that fetched `?w=320` receive a
|
|
552
587
|
* 304 for `?w=960` and render the small image at full size. The base form is
|
|
553
588
|
* unchanged for a plain request, so the identity
|
|
@@ -558,6 +593,7 @@ function snapshotEtag(variant, capturedAt) {
|
|
|
558
593
|
const parts = [`${String(variant.deviceId)}-${String(capturedAt)}`];
|
|
559
594
|
if (variant.streamId !== void 0) parts.push(`s${variant.streamId}`);
|
|
560
595
|
if (variant.width !== void 0) parts.push(`w${String(variant.width)}`);
|
|
596
|
+
if (variant.variantKey !== void 0) parts.push(`v${variant.variantKey}`);
|
|
561
597
|
return `"${parts.join("-")}"`;
|
|
562
598
|
}
|
|
563
599
|
/**
|
|
@@ -588,7 +624,8 @@ function createSnapshotMediaHandler(deps) {
|
|
|
588
624
|
const peekedEtag = snapshotEtag({
|
|
589
625
|
deviceId: parsed.deviceId,
|
|
590
626
|
streamId: parsed.streamId,
|
|
591
|
-
width: parsed.width
|
|
627
|
+
width: parsed.width,
|
|
628
|
+
variantKey: peeked.variantKey
|
|
592
629
|
}, peeked.capturedAt);
|
|
593
630
|
if (inm === peekedEtag) {
|
|
594
631
|
res.writeHead(304, {
|
|
@@ -619,7 +656,8 @@ function createSnapshotMediaHandler(deps) {
|
|
|
619
656
|
const etag = snapshotEtag({
|
|
620
657
|
deviceId: parsed.deviceId,
|
|
621
658
|
streamId: parsed.streamId,
|
|
622
|
-
width: "servedWidth" in media ? media.servedWidth : parsed.width
|
|
659
|
+
width: "servedWidth" in media ? media.servedWidth : parsed.width,
|
|
660
|
+
variantKey: media.variantKey
|
|
623
661
|
}, media.capturedAt);
|
|
624
662
|
const cacheControl = `private, max-age=${Math.max(0, Math.floor(media.maxAgeS))}`;
|
|
625
663
|
if (inm === etag) {
|
|
@@ -722,8 +760,8 @@ function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
|
|
|
722
760
|
});
|
|
723
761
|
}
|
|
724
762
|
/**
|
|
725
|
-
* Resized frames, keyed by (device, stream, width) AND
|
|
726
|
-
* source frame's timestamp.
|
|
763
|
+
* Resized frames, keyed by (device, stream, width, state treatment) AND
|
|
764
|
+
* validated against the source frame's timestamp.
|
|
727
765
|
*
|
|
728
766
|
* The timestamp is the whole correctness argument: a variant outlives nothing.
|
|
729
767
|
* When the underlying frame is recaptured its `capturedAt` moves, every variant
|
|
@@ -734,17 +772,17 @@ function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
|
|
|
734
772
|
var SnapshotVariantCache = class SnapshotVariantCache {
|
|
735
773
|
byKey = /* @__PURE__ */ new Map();
|
|
736
774
|
keysByDevice = /* @__PURE__ */ new Map();
|
|
737
|
-
static key(deviceId, streamId, width) {
|
|
738
|
-
return `${deviceId}:${streamId ?? "auto"}:${width}`;
|
|
775
|
+
static key(deviceId, streamId, width, variantKey) {
|
|
776
|
+
return `${deviceId}:${streamId ?? "auto"}:${width}:${variantKey ?? "raw"}`;
|
|
739
777
|
}
|
|
740
778
|
/** The variant for this exact frame, or undefined when it is missing or was
|
|
741
779
|
* derived from an older capture. */
|
|
742
|
-
get(deviceId, streamId, width, sourceTs) {
|
|
743
|
-
const entry = this.byKey.get(SnapshotVariantCache.key(deviceId, streamId, width));
|
|
780
|
+
get(deviceId, streamId, width, sourceTs, variantKey) {
|
|
781
|
+
const entry = this.byKey.get(SnapshotVariantCache.key(deviceId, streamId, width, variantKey));
|
|
744
782
|
return entry !== void 0 && entry.sourceTs === sourceTs ? entry.bytes : void 0;
|
|
745
783
|
}
|
|
746
|
-
set(deviceId, streamId, width, sourceTs, bytes) {
|
|
747
|
-
const key = SnapshotVariantCache.key(deviceId, streamId, width);
|
|
784
|
+
set(deviceId, streamId, width, sourceTs, bytes, variantKey) {
|
|
785
|
+
const key = SnapshotVariantCache.key(deviceId, streamId, width, variantKey);
|
|
748
786
|
this.byKey.set(key, {
|
|
749
787
|
bytes,
|
|
750
788
|
sourceTs
|
|
@@ -785,6 +823,9 @@ var LINK_MINT_DEADLINE_MS = 2500;
|
|
|
785
823
|
var LINK_BATCH_WAIT_MS = 750;
|
|
786
824
|
/** Frames older than this are never represented as current by the Viewer. */
|
|
787
825
|
var LINK_CURRENT_MAX_AGE_MS = 15e3;
|
|
826
|
+
/** Wake-on-play's 8 s firmware wait + 20 s broker settle, with scheduling
|
|
827
|
+
* slack. A lost completion event must not leave a camera "Waking" forever. */
|
|
828
|
+
var SNAPSHOT_WAKING_TTL_MS = 35e3;
|
|
788
829
|
/** Default cache window for non-battery cams (seconds). 10s feels live. */
|
|
789
830
|
var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
|
|
790
831
|
/** Default cache window for battery cams (seconds). 1h ≈ "don't wake the cam unless asked". */
|
|
@@ -829,9 +870,14 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
829
870
|
* A courtesy frame is a pure function of those three, so it is rendered once
|
|
830
871
|
* and reused for as long as the process lives — a disabled camera must not
|
|
831
872
|
* cost an ffmpeg run per poll. Unbounded on purpose: the key space is
|
|
832
|
-
* (
|
|
873
|
+
* (state reasons × this node's own cameras), not user input.
|
|
833
874
|
*/
|
|
834
875
|
courtesyFrames = /* @__PURE__ */ new Map();
|
|
876
|
+
/** State overlays over real cached frames, grouped by device so invalidation
|
|
877
|
+
* can discard every generation in O(1). */
|
|
878
|
+
stateFrames = /* @__PURE__ */ new Map();
|
|
879
|
+
/** Devices inside the explicit battery wake-in-progress window. */
|
|
880
|
+
wakingDevices = /* @__PURE__ */ new Map();
|
|
835
881
|
/**
|
|
836
882
|
* De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
|
|
837
883
|
* settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
|
|
@@ -903,6 +949,12 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
903
949
|
const deviceId = event.data.deviceId;
|
|
904
950
|
if (typeof deviceId === "number") this.evictRemovedDevice(deviceId, "device-unregistered");
|
|
905
951
|
});
|
|
952
|
+
this.subscribe({ category: require_dist.EventCategory.BatteryOnWakeStarted }, (event) => {
|
|
953
|
+
this.wakingDevices.set(event.data.deviceId, Date.now());
|
|
954
|
+
});
|
|
955
|
+
this.subscribe({ category: require_dist.EventCategory.BatteryOnStatusChanged }, (event) => {
|
|
956
|
+
this.wakingDevices.delete(event.data.deviceId);
|
|
957
|
+
});
|
|
906
958
|
await this.serveMediaDataPlane();
|
|
907
959
|
await this.ensureLinkDataPlane();
|
|
908
960
|
return [{
|
|
@@ -1164,13 +1216,12 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1164
1216
|
}
|
|
1165
1217
|
});
|
|
1166
1218
|
}
|
|
1167
|
-
/** True only for a
|
|
1168
|
-
* question so the link path cannot accidentally treat "battery" as "asleep"
|
|
1169
|
-
* and stop refreshing a camera that is awake and streaming. */
|
|
1219
|
+
/** True only for a battery device that must not be snapshot-refreshed. */
|
|
1170
1220
|
async isSleepingBatteryDevice(deviceId) {
|
|
1171
1221
|
try {
|
|
1172
|
-
|
|
1173
|
-
|
|
1222
|
+
const meta = await this.lookupDeviceMeta(deviceId);
|
|
1223
|
+
const state = await this.resolveSnapshotState(deviceId, meta);
|
|
1224
|
+
return state.reason === "sleeping" || state.reason === "unreachable" || state.reason === "waking";
|
|
1174
1225
|
} catch {
|
|
1175
1226
|
return false;
|
|
1176
1227
|
}
|
|
@@ -1184,28 +1235,31 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1184
1235
|
* effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
|
|
1185
1236
|
*/
|
|
1186
1237
|
async resolveSnapshotMedia(deviceId, streamId, force, width) {
|
|
1238
|
+
const resolutionMeta = {};
|
|
1187
1239
|
const image = await this.getSnapshot({
|
|
1188
1240
|
deviceId,
|
|
1189
1241
|
...streamId !== void 0 ? { streamId } : {},
|
|
1190
1242
|
force
|
|
1191
|
-
});
|
|
1243
|
+
}, 0, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, resolutionMeta);
|
|
1192
1244
|
if (!image) return null;
|
|
1193
1245
|
const capturedAt = this.cache.get(deviceId, streamId)?.ts ?? Date.now();
|
|
1194
1246
|
const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
|
|
1195
|
-
const isBattery =
|
|
1247
|
+
const isBattery = resolutionMeta.isBattery ?? false;
|
|
1196
1248
|
if (width === void 0) return {
|
|
1197
1249
|
bytes: Buffer.from(image.base64, "base64"),
|
|
1198
1250
|
contentType: image.contentType,
|
|
1199
1251
|
capturedAt,
|
|
1200
1252
|
maxAgeS: effectiveMaxAgeS(prefs, isBattery),
|
|
1253
|
+
variantKey: resolutionMeta.stateReason,
|
|
1201
1254
|
servedWidth: void 0
|
|
1202
1255
|
};
|
|
1203
|
-
const thumb = await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs);
|
|
1256
|
+
const thumb = await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs, resolutionMeta.stateReason);
|
|
1204
1257
|
return {
|
|
1205
1258
|
bytes: thumb.bytes,
|
|
1206
1259
|
contentType: image.contentType,
|
|
1207
1260
|
capturedAt,
|
|
1208
1261
|
maxAgeS: effectiveMaxAgeS(prefs, isBattery),
|
|
1262
|
+
variantKey: resolutionMeta.stateReason,
|
|
1209
1263
|
servedWidth: thumb.width
|
|
1210
1264
|
};
|
|
1211
1265
|
}
|
|
@@ -1221,24 +1275,28 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1221
1275
|
async peekFreshFrame(deviceId, streamId) {
|
|
1222
1276
|
const hit = this.cache.get(deviceId, streamId);
|
|
1223
1277
|
if (!hit) return null;
|
|
1224
|
-
const
|
|
1278
|
+
const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
|
|
1279
|
+
const meta = await this.lookupDeviceMeta(deviceId);
|
|
1280
|
+
const state = await this.resolveSnapshotState(deviceId, meta);
|
|
1281
|
+
const maxAgeS = effectiveMaxAgeS(prefs, state.isBattery);
|
|
1225
1282
|
if (Date.now() - hit.ts >= maxAgeS * 1e3) return null;
|
|
1226
1283
|
return {
|
|
1227
1284
|
capturedAt: hit.ts,
|
|
1228
|
-
maxAgeS
|
|
1285
|
+
maxAgeS,
|
|
1286
|
+
variantKey: state.reason
|
|
1229
1287
|
};
|
|
1230
1288
|
}
|
|
1231
1289
|
/**
|
|
1232
|
-
* The frame at a card-sized width, derived once per
|
|
1233
|
-
* per capture.
|
|
1290
|
+
* The frame at a card-sized width, derived once per
|
|
1291
|
+
* (device, stream, width, state treatment) per capture.
|
|
1234
1292
|
*
|
|
1235
1293
|
* A failed resize falls back to the FULL frame — a visible card beats a
|
|
1236
1294
|
* broken one — but never silently: the card would otherwise keep costing a
|
|
1237
1295
|
* megabyte with nothing anywhere saying why, which is the shape of the bug
|
|
1238
1296
|
* this whole change came from.
|
|
1239
1297
|
*/
|
|
1240
|
-
async thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs) {
|
|
1241
|
-
const cached = this.variants.get(deviceId, streamId, width, capturedAt);
|
|
1298
|
+
async thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs, variantKey) {
|
|
1299
|
+
const cached = this.variants.get(deviceId, streamId, width, capturedAt, variantKey);
|
|
1242
1300
|
if (cached !== void 0) return {
|
|
1243
1301
|
bytes: cached,
|
|
1244
1302
|
width
|
|
@@ -1246,7 +1304,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1246
1304
|
const full = Buffer.from(image.base64, "base64");
|
|
1247
1305
|
try {
|
|
1248
1306
|
const resized = await this.resize(full, width);
|
|
1249
|
-
this.variants.set(deviceId, streamId, width, capturedAt, resized);
|
|
1307
|
+
this.variants.set(deviceId, streamId, width, capturedAt, resized, variantKey);
|
|
1250
1308
|
if (prefs.snapshotDebug) this.ctx.logger.info("snapshot: thumbnail derived", {
|
|
1251
1309
|
tags: { deviceId },
|
|
1252
1310
|
meta: {
|
|
@@ -1285,6 +1343,8 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1285
1343
|
}
|
|
1286
1344
|
this.cache.clear();
|
|
1287
1345
|
this.variants.clear();
|
|
1346
|
+
this.stateFrames.clear();
|
|
1347
|
+
this.wakingDevices.clear();
|
|
1288
1348
|
this.captureFlight.clear();
|
|
1289
1349
|
this.ownerCache = null;
|
|
1290
1350
|
}
|
|
@@ -1317,18 +1377,16 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1317
1377
|
})]
|
|
1318
1378
|
}] });
|
|
1319
1379
|
}
|
|
1320
|
-
async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY, maximumCaptureWaitMs = Number.POSITIVE_INFINITY) {
|
|
1380
|
+
async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY, maximumCaptureWaitMs = Number.POSITIVE_INFINITY, resolutionMeta) {
|
|
1321
1381
|
const { deviceId } = input;
|
|
1322
1382
|
const force = input.force === true;
|
|
1323
1383
|
const meta = await this.lookupDeviceMeta(deviceId);
|
|
1324
1384
|
const deviceName = meta?.name;
|
|
1325
|
-
const isBatteryDevice = meta?.isBattery ?? false;
|
|
1326
1385
|
const log = this.ctx.logger.withTags({
|
|
1327
1386
|
deviceId,
|
|
1328
1387
|
...deviceName ? { deviceName } : {}
|
|
1329
1388
|
});
|
|
1330
1389
|
const now = Date.now();
|
|
1331
|
-
if (meta?.disabled === true) return await this.courtesyImage(deviceId, "disabled", deviceName);
|
|
1332
1390
|
const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
|
|
1333
1391
|
const rawPref = prefs.snapshotStreamId;
|
|
1334
1392
|
const effectiveStreamId = input.streamId ?? (rawPref && rawPref !== "auto" ? rawPref : void 0);
|
|
@@ -1337,6 +1395,24 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1337
1395
|
meta: { stream: effectiveStreamId ?? "auto" }
|
|
1338
1396
|
});
|
|
1339
1397
|
const hit = this.cache.get(deviceId, effectiveStreamId);
|
|
1398
|
+
const state = await this.resolveSnapshotState(deviceId, meta);
|
|
1399
|
+
const isBatteryDevice = state.isBattery;
|
|
1400
|
+
if (resolutionMeta) {
|
|
1401
|
+
resolutionMeta.isBattery = isBatteryDevice;
|
|
1402
|
+
delete resolutionMeta.stateReason;
|
|
1403
|
+
}
|
|
1404
|
+
if (state.reason !== void 0 && (!force || state.reason === "disabled" || state.reason === "unreachable" || state.reason === "waking")) {
|
|
1405
|
+
if (resolutionMeta) resolutionMeta.stateReason = state.reason;
|
|
1406
|
+
log.debug("snapshot: camera state overrides raw cache", {
|
|
1407
|
+
tags: { deviceId },
|
|
1408
|
+
meta: {
|
|
1409
|
+
reason: state.reason,
|
|
1410
|
+
hasCache: hit !== void 0,
|
|
1411
|
+
ageMs: hit ? now - hit.ts : null
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
return await this.stateImage(deviceId, state.reason, deviceName, hit);
|
|
1415
|
+
}
|
|
1340
1416
|
const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3));
|
|
1341
1417
|
const decision = decideSnapshotServe({
|
|
1342
1418
|
now,
|
|
@@ -1355,17 +1431,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1355
1431
|
});
|
|
1356
1432
|
return hit ? hit.data : null;
|
|
1357
1433
|
}
|
|
1358
|
-
if (!force && isBatteryDevice && await this.isDeviceSleeping(deviceId)) {
|
|
1359
|
-
log.debug("snapshot: sleeping battery cam and no force — serving cache, not capturing", {
|
|
1360
|
-
tags: { deviceId },
|
|
1361
|
-
meta: {
|
|
1362
|
-
hasCache: hit !== void 0,
|
|
1363
|
-
ageMs: hit ? now - hit.ts : null
|
|
1364
|
-
}
|
|
1365
|
-
});
|
|
1366
|
-
if (hit) return hit.data;
|
|
1367
|
-
return await this.courtesyImage(deviceId, "sleeping", deviceName);
|
|
1368
|
-
}
|
|
1369
1434
|
const flightKey = `${deviceId}:${effectiveStreamId ?? "auto"}`;
|
|
1370
1435
|
const flight = this.captureFlight.run(flightKey, () => this.captureFresh({
|
|
1371
1436
|
deviceId,
|
|
@@ -1484,6 +1549,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1484
1549
|
if (native) {
|
|
1485
1550
|
const result = await this.nativePool.run(() => native.getSnapshot(input));
|
|
1486
1551
|
if (result) {
|
|
1552
|
+
this.stateFrames.delete(deviceId);
|
|
1487
1553
|
this.cache.set(deviceId, effectiveStreamId, {
|
|
1488
1554
|
data: result,
|
|
1489
1555
|
ts: now,
|
|
@@ -1518,6 +1584,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1518
1584
|
if (!(isBatteryDevice && nativeAbsent && !await this.hasStreamingBrokerForDevice(deviceId))) try {
|
|
1519
1585
|
const fallback = await this.grabFrameFromBroker(deviceId, effectiveStreamId);
|
|
1520
1586
|
if (fallback) {
|
|
1587
|
+
this.stateFrames.delete(deviceId);
|
|
1521
1588
|
this.cache.set(deviceId, effectiveStreamId, {
|
|
1522
1589
|
data: fallback,
|
|
1523
1590
|
ts: now,
|
|
@@ -1671,6 +1738,8 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1671
1738
|
dropDeviceCaches(deviceId) {
|
|
1672
1739
|
this.cache.deleteDevice(deviceId);
|
|
1673
1740
|
this.variants.deleteDevice(deviceId);
|
|
1741
|
+
this.stateFrames.delete(deviceId);
|
|
1742
|
+
this.wakingDevices.delete(deviceId);
|
|
1674
1743
|
this.captureFlight.invalidatePrefix(`${deviceId}:`);
|
|
1675
1744
|
}
|
|
1676
1745
|
/**
|
|
@@ -1708,38 +1777,64 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1708
1777
|
}
|
|
1709
1778
|
}
|
|
1710
1779
|
/**
|
|
1711
|
-
*
|
|
1780
|
+
* Resolve the state that must be visible on a snapshot.
|
|
1712
1781
|
*
|
|
1713
|
-
*
|
|
1714
|
-
*
|
|
1715
|
-
*
|
|
1716
|
-
* in the UI, so if the operator can see "sleeping" on screen, this
|
|
1717
|
-
* sees it too. (Both spec harnesses already model exactly this wiring
|
|
1718
|
-
* — like the orphaned doc comment above, it outlived the gate it was
|
|
1719
|
-
* built for.)
|
|
1782
|
+
* Uses the same two canonical sources as wake-on-play:
|
|
1783
|
+
* 1. `deviceManager.getDevice` for the registry's BatteryOperated feature;
|
|
1784
|
+
* 2. `deviceState.getCapSlice('battery')` for the durable runtime mirror.
|
|
1720
1785
|
*
|
|
1721
|
-
*
|
|
1722
|
-
*
|
|
1723
|
-
* battery
|
|
1724
|
-
*
|
|
1786
|
+
* A valid battery slice is itself positive evidence that the device is
|
|
1787
|
+
* battery-operated. That closes the field failure where "Baby monitor" had a
|
|
1788
|
+
* sleeping battery slice but a stale persisted feature projection, so the
|
|
1789
|
+
* snapshot wrapper classified it as mains and a Viewer thumbnail logged in
|
|
1790
|
+
* to the sleeping camera. The reverse is deliberately not guessed: no
|
|
1791
|
+
* feature + no valid slice remains a mains/unknown camera and follows the
|
|
1792
|
+
* ordinary capture path.
|
|
1725
1793
|
*
|
|
1726
|
-
*
|
|
1727
|
-
*
|
|
1794
|
+
* Once battery-operated is established, a missing slice fails safe toward
|
|
1795
|
+
* `sleeping` through the shared `deriveBatteryPresence` function. This avoids
|
|
1796
|
+
* a background wake without inventing an `unreachable` fault.
|
|
1728
1797
|
*/
|
|
1729
|
-
async
|
|
1798
|
+
async resolveSnapshotState(deviceId, meta) {
|
|
1799
|
+
if (meta?.disabled === true) return {
|
|
1800
|
+
isBattery: meta.isBattery,
|
|
1801
|
+
reason: "disabled"
|
|
1802
|
+
};
|
|
1803
|
+
const waking = this.isDeviceWaking(deviceId);
|
|
1804
|
+
let batteryStatus = null;
|
|
1730
1805
|
try {
|
|
1731
|
-
const
|
|
1732
|
-
|
|
1733
|
-
|
|
1806
|
+
const raw = await this.ctx.api.deviceState.getCapSlice.query({
|
|
1807
|
+
deviceId,
|
|
1808
|
+
capName: require_dist.batteryCapability.name
|
|
1809
|
+
});
|
|
1734
1810
|
const parsed = require_dist.BatteryStatusSchema.safeParse(raw);
|
|
1735
|
-
|
|
1811
|
+
if (parsed.success) batteryStatus = parsed.data;
|
|
1736
1812
|
} catch (err) {
|
|
1737
|
-
this.ctx.logger.
|
|
1813
|
+
this.ctx.logger.debug("snapshot: battery runtime-state read failed", {
|
|
1738
1814
|
tags: { deviceId },
|
|
1739
1815
|
meta: { error: require_dist.errMsg(err) }
|
|
1740
1816
|
});
|
|
1741
|
-
return false;
|
|
1742
1817
|
}
|
|
1818
|
+
if (!(meta?.isBattery === true || batteryStatus !== null || waking)) return { isBattery: false };
|
|
1819
|
+
if (waking) return {
|
|
1820
|
+
isBattery: true,
|
|
1821
|
+
reason: "waking"
|
|
1822
|
+
};
|
|
1823
|
+
const presence = require_dist.deriveBatteryPresence({
|
|
1824
|
+
status: batteryStatus,
|
|
1825
|
+
nowMs: Date.now()
|
|
1826
|
+
});
|
|
1827
|
+
return presence === "awake" ? { isBattery: true } : {
|
|
1828
|
+
isBattery: true,
|
|
1829
|
+
reason: presence
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
isDeviceWaking(deviceId) {
|
|
1833
|
+
const startedAt = this.wakingDevices.get(deviceId);
|
|
1834
|
+
if (startedAt === void 0) return false;
|
|
1835
|
+
if (Date.now() - startedAt <= SNAPSHOT_WAKING_TTL_MS) return true;
|
|
1836
|
+
this.wakingDevices.delete(deviceId);
|
|
1837
|
+
return false;
|
|
1743
1838
|
}
|
|
1744
1839
|
/**
|
|
1745
1840
|
* True when at least one of the device's brokers is actively
|
|
@@ -1915,12 +2010,48 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1915
2010
|
};
|
|
1916
2011
|
}
|
|
1917
2012
|
/**
|
|
2013
|
+
* Render a state-labelled answer. A cached real frame is the background, not
|
|
2014
|
+
* the answer by itself; if it cannot be decoded, fall back to the generated
|
|
2015
|
+
* courtesy background rather than leaking an unlabelled stale frame.
|
|
2016
|
+
*/
|
|
2017
|
+
async stateImage(deviceId, reason, deviceName, hit) {
|
|
2018
|
+
const resolvedName = deviceName ?? `#${String(deviceId)}`;
|
|
2019
|
+
if (!hit) return await this.courtesyImage(deviceId, reason, resolvedName);
|
|
2020
|
+
const key = `${String(hit.ts)}:${hit.streamId ?? "auto"}:${reason}:${resolvedName}`;
|
|
2021
|
+
const deviceFrames = this.stateFrames.get(deviceId) ?? /* @__PURE__ */ new Map();
|
|
2022
|
+
this.stateFrames.set(deviceId, deviceFrames);
|
|
2023
|
+
const cached = deviceFrames.get(key);
|
|
2024
|
+
if (cached !== void 0) return {
|
|
2025
|
+
base64: cached.toString("base64"),
|
|
2026
|
+
contentType: "image/jpeg"
|
|
2027
|
+
};
|
|
2028
|
+
try {
|
|
2029
|
+
const bytes = await renderCourtesyOverlayJpeg(Buffer.from(hit.data.base64, "base64"), {
|
|
2030
|
+
deviceName: resolvedName,
|
|
2031
|
+
reason
|
|
2032
|
+
});
|
|
2033
|
+
deviceFrames.set(key, bytes);
|
|
2034
|
+
return {
|
|
2035
|
+
base64: bytes.toString("base64"),
|
|
2036
|
+
contentType: "image/jpeg"
|
|
2037
|
+
};
|
|
2038
|
+
} catch (err) {
|
|
2039
|
+
this.ctx.logger.warn("snapshot: state overlay failed — using generated courtesy background", {
|
|
2040
|
+
tags: { deviceId },
|
|
2041
|
+
meta: {
|
|
2042
|
+
reason,
|
|
2043
|
+
error: require_dist.errMsg(err)
|
|
2044
|
+
}
|
|
2045
|
+
});
|
|
2046
|
+
return await this.courtesyImage(deviceId, reason, resolvedName);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
1918
2050
|
* Single-trip device lookup against device-manager. Returns the
|
|
1919
|
-
* fields the wrapper
|
|
1920
|
-
*
|
|
1921
|
-
*
|
|
1922
|
-
*
|
|
1923
|
-
* we're trying to be resilient to).
|
|
2051
|
+
* stable registry fields the wrapper consults. Battery classification is
|
|
2052
|
+
* completed by `resolveSnapshotState` from this feature flag PLUS the
|
|
2053
|
+
* canonical runtime-state mirror, so a stale persisted feature projection
|
|
2054
|
+
* cannot authorize a snapshot dial against a known sleeping battery camera.
|
|
1924
2055
|
*
|
|
1925
2056
|
* Logged at debug + null return on failure: every call site already
|
|
1926
2057
|
* has a sensible fallback path (cache hit, conservative default, …),
|
|
@@ -1952,7 +2083,8 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1952
2083
|
}
|
|
1953
2084
|
/** Settings-UI helper — battery flag drives the default max-age in the field description. */
|
|
1954
2085
|
async isDeviceBattery(deviceId) {
|
|
1955
|
-
|
|
2086
|
+
const meta = await this.lookupDeviceMeta(deviceId);
|
|
2087
|
+
return (await this.resolveSnapshotState(deviceId, meta)).isBattery;
|
|
1956
2088
|
}
|
|
1957
2089
|
/**
|
|
1958
2090
|
* Feeds the settings-UI stream picker. Pinned to the ingest owner via
|