@camstack/system 1.2.93 → 1.2.95
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 +218 -102
- package/dist/builtins/snapshot/index.mjs +218 -102
- package/dist/builtins/snapshot/snapshot-courtesy.d.ts +13 -1
- 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 +37 -38
- 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-BMus2E5R.js → dist-BQoU9nGS.js} +16 -1
- package/dist/{dist-F1XDQDE1.mjs → dist-hKJyHgFg.mjs} +11 -2
- 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-BQoU9nGS.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`
|
|
@@ -300,6 +300,7 @@ function courtesyLabel(reason) {
|
|
|
300
300
|
case "offline": return "Offline";
|
|
301
301
|
case "sleeping": return "Sleeping";
|
|
302
302
|
case "unreachable": return "Unreachable";
|
|
303
|
+
case "waking": return "Waking";
|
|
303
304
|
}
|
|
304
305
|
}
|
|
305
306
|
/**
|
|
@@ -311,6 +312,7 @@ function courtesyBackground(reason) {
|
|
|
311
312
|
case "disabled": return "#2b2b31";
|
|
312
313
|
case "offline": return "#3a2b2b";
|
|
313
314
|
case "sleeping": return "#232b3a";
|
|
315
|
+
case "waking": return "#243247";
|
|
314
316
|
case "unreachable": return "#3a2b2b";
|
|
315
317
|
}
|
|
316
318
|
}
|
|
@@ -326,15 +328,27 @@ function escapeCourtesyText(value) {
|
|
|
326
328
|
* frame read the same, with a floor so a thumbnail stays legible.
|
|
327
329
|
*/
|
|
328
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) {
|
|
329
341
|
const stateSize = Math.max(12, Math.round(spec.width / 12));
|
|
330
342
|
const nameSize = Math.max(9, Math.round(spec.width / 26));
|
|
331
343
|
const state = escapeCourtesyText(courtesyLabel(spec.reason));
|
|
332
344
|
const name = escapeCourtesyText(spec.deviceName);
|
|
333
345
|
const midY = spec.height / 2;
|
|
346
|
+
const washOpacity = overlay ? "0.62" : "1";
|
|
347
|
+
const textStroke = Math.max(1, Math.round(spec.width / 320));
|
|
334
348
|
return [
|
|
335
349
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${String(spec.width)}" height="${String(spec.height)}">`,
|
|
336
|
-
`<rect width="100%" height="100%" fill="${courtesyBackground(spec.reason)}"/>`,
|
|
337
|
-
`<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)}">`,
|
|
338
352
|
`<text x="50%" y="${String(Math.round(midY))}" font-size="${String(stateSize)}" fill="#e8e8ee">${state}</text>`,
|
|
339
353
|
`<text x="50%" y="${String(Math.round(midY + stateSize))}" font-size="${String(nameSize)}" fill="#9a9aa8">${name}</text>`,
|
|
340
354
|
`</g></svg>`
|
|
@@ -356,6 +370,24 @@ function renderCourtesyJpeg(spec) {
|
|
|
356
370
|
return bytes;
|
|
357
371
|
});
|
|
358
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
|
+
}
|
|
359
391
|
//#endregion
|
|
360
392
|
//#region src/builtins/snapshot/snapshot-link-url.ts
|
|
361
393
|
/**
|
|
@@ -549,7 +581,8 @@ function parseSnapshotMediaRequest(url) {
|
|
|
549
581
|
* The response's entity tag.
|
|
550
582
|
*
|
|
551
583
|
* It has to identify the VARIANT, not just the frame: the same capture can now
|
|
552
|
-
* be served at several widths
|
|
584
|
+
* be served at several widths, from several streams, and with a camera-state
|
|
585
|
+
* treatment, and a plain
|
|
553
586
|
* `"<device>-<capturedAt>"` would let a client that fetched `?w=320` receive a
|
|
554
587
|
* 304 for `?w=960` and render the small image at full size. The base form is
|
|
555
588
|
* unchanged for a plain request, so the identity
|
|
@@ -560,6 +593,7 @@ function snapshotEtag(variant, capturedAt) {
|
|
|
560
593
|
const parts = [`${String(variant.deviceId)}-${String(capturedAt)}`];
|
|
561
594
|
if (variant.streamId !== void 0) parts.push(`s${variant.streamId}`);
|
|
562
595
|
if (variant.width !== void 0) parts.push(`w${String(variant.width)}`);
|
|
596
|
+
if (variant.variantKey !== void 0) parts.push(`v${variant.variantKey}`);
|
|
563
597
|
return `"${parts.join("-")}"`;
|
|
564
598
|
}
|
|
565
599
|
/**
|
|
@@ -590,7 +624,8 @@ function createSnapshotMediaHandler(deps) {
|
|
|
590
624
|
const peekedEtag = snapshotEtag({
|
|
591
625
|
deviceId: parsed.deviceId,
|
|
592
626
|
streamId: parsed.streamId,
|
|
593
|
-
width: parsed.width
|
|
627
|
+
width: parsed.width,
|
|
628
|
+
variantKey: peeked.variantKey
|
|
594
629
|
}, peeked.capturedAt);
|
|
595
630
|
if (inm === peekedEtag) {
|
|
596
631
|
res.writeHead(304, {
|
|
@@ -621,7 +656,8 @@ function createSnapshotMediaHandler(deps) {
|
|
|
621
656
|
const etag = snapshotEtag({
|
|
622
657
|
deviceId: parsed.deviceId,
|
|
623
658
|
streamId: parsed.streamId,
|
|
624
|
-
width: "servedWidth" in media ? media.servedWidth : parsed.width
|
|
659
|
+
width: "servedWidth" in media ? media.servedWidth : parsed.width,
|
|
660
|
+
variantKey: media.variantKey
|
|
625
661
|
}, media.capturedAt);
|
|
626
662
|
const cacheControl = `private, max-age=${Math.max(0, Math.floor(media.maxAgeS))}`;
|
|
627
663
|
if (inm === etag) {
|
|
@@ -724,8 +760,8 @@ function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
|
|
|
724
760
|
});
|
|
725
761
|
}
|
|
726
762
|
/**
|
|
727
|
-
* Resized frames, keyed by (device, stream, width) AND
|
|
728
|
-
* source frame's timestamp.
|
|
763
|
+
* Resized frames, keyed by (device, stream, width, state treatment) AND
|
|
764
|
+
* validated against the source frame's timestamp.
|
|
729
765
|
*
|
|
730
766
|
* The timestamp is the whole correctness argument: a variant outlives nothing.
|
|
731
767
|
* When the underlying frame is recaptured its `capturedAt` moves, every variant
|
|
@@ -736,17 +772,17 @@ function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
|
|
|
736
772
|
var SnapshotVariantCache = class SnapshotVariantCache {
|
|
737
773
|
byKey = /* @__PURE__ */ new Map();
|
|
738
774
|
keysByDevice = /* @__PURE__ */ new Map();
|
|
739
|
-
static key(deviceId, streamId, width) {
|
|
740
|
-
return `${deviceId}:${streamId ?? "auto"}:${width}`;
|
|
775
|
+
static key(deviceId, streamId, width, variantKey) {
|
|
776
|
+
return `${deviceId}:${streamId ?? "auto"}:${width}:${variantKey ?? "raw"}`;
|
|
741
777
|
}
|
|
742
778
|
/** The variant for this exact frame, or undefined when it is missing or was
|
|
743
779
|
* derived from an older capture. */
|
|
744
|
-
get(deviceId, streamId, width, sourceTs) {
|
|
745
|
-
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));
|
|
746
782
|
return entry !== void 0 && entry.sourceTs === sourceTs ? entry.bytes : void 0;
|
|
747
783
|
}
|
|
748
|
-
set(deviceId, streamId, width, sourceTs, bytes) {
|
|
749
|
-
const key = SnapshotVariantCache.key(deviceId, streamId, width);
|
|
784
|
+
set(deviceId, streamId, width, sourceTs, bytes, variantKey) {
|
|
785
|
+
const key = SnapshotVariantCache.key(deviceId, streamId, width, variantKey);
|
|
750
786
|
this.byKey.set(key, {
|
|
751
787
|
bytes,
|
|
752
788
|
sourceTs
|
|
@@ -787,6 +823,9 @@ var LINK_MINT_DEADLINE_MS = 2500;
|
|
|
787
823
|
var LINK_BATCH_WAIT_MS = 750;
|
|
788
824
|
/** Frames older than this are never represented as current by the Viewer. */
|
|
789
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;
|
|
790
829
|
/** Default cache window for non-battery cams (seconds). 10s feels live. */
|
|
791
830
|
var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
|
|
792
831
|
/** Default cache window for battery cams (seconds). 1h ≈ "don't wake the cam unless asked". */
|
|
@@ -831,9 +870,14 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
831
870
|
* A courtesy frame is a pure function of those three, so it is rendered once
|
|
832
871
|
* and reused for as long as the process lives — a disabled camera must not
|
|
833
872
|
* cost an ffmpeg run per poll. Unbounded on purpose: the key space is
|
|
834
|
-
* (
|
|
873
|
+
* (state reasons × this node's own cameras), not user input.
|
|
835
874
|
*/
|
|
836
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();
|
|
837
881
|
/**
|
|
838
882
|
* De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
|
|
839
883
|
* settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
|
|
@@ -905,6 +949,12 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
905
949
|
const deviceId = event.data.deviceId;
|
|
906
950
|
if (typeof deviceId === "number") this.evictRemovedDevice(deviceId, "device-unregistered");
|
|
907
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
|
+
});
|
|
908
958
|
await this.serveMediaDataPlane();
|
|
909
959
|
await this.ensureLinkDataPlane();
|
|
910
960
|
return [{
|
|
@@ -1105,11 +1155,11 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1105
1155
|
const expMs = snapshotLinkExpiry(Date.now(), SNAPSHOT_LINK_TTL_MS);
|
|
1106
1156
|
const served = this.linkDataPlane !== null && secret !== null;
|
|
1107
1157
|
const prefix = this.linkRoutePrefix();
|
|
1108
|
-
const
|
|
1158
|
+
const stateByDevice = /* @__PURE__ */ new Map();
|
|
1109
1159
|
const refreshes = input.targets.map(async (target) => {
|
|
1110
|
-
const
|
|
1111
|
-
|
|
1112
|
-
if (
|
|
1160
|
+
const state = await this.snapshotStateForLink(target.deviceId);
|
|
1161
|
+
stateByDevice.set(target.deviceId, state);
|
|
1162
|
+
if (state.reason === void 0) await this.awaitFreshEnough(target.deviceId);
|
|
1113
1163
|
});
|
|
1114
1164
|
await raceForResult(Promise.allSettled(refreshes), LINK_BATCH_WAIT_MS);
|
|
1115
1165
|
const rows = input.targets.map((target) => {
|
|
@@ -1132,7 +1182,8 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1132
1182
|
expiresAt: served ? expMs : null,
|
|
1133
1183
|
width: width ?? null,
|
|
1134
1184
|
neverCaptured: capturedAt === null,
|
|
1135
|
-
sleeping:
|
|
1185
|
+
sleeping: stateByDevice.get(deviceId)?.reason === "sleeping",
|
|
1186
|
+
stateReason: stateByDevice.get(deviceId)?.reason ?? null
|
|
1136
1187
|
};
|
|
1137
1188
|
});
|
|
1138
1189
|
this.ctx.logger.debug("snapshot: minted links", { meta: {
|
|
@@ -1166,15 +1217,18 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1166
1217
|
}
|
|
1167
1218
|
});
|
|
1168
1219
|
}
|
|
1169
|
-
/**
|
|
1170
|
-
*
|
|
1171
|
-
|
|
1172
|
-
async isSleepingBatteryDevice(deviceId) {
|
|
1220
|
+
/** Resolve the state advertised by a minted link before deciding whether a
|
|
1221
|
+
* background capture is allowed. */
|
|
1222
|
+
async snapshotStateForLink(deviceId) {
|
|
1173
1223
|
try {
|
|
1174
|
-
|
|
1175
|
-
return await this.
|
|
1176
|
-
} catch {
|
|
1177
|
-
|
|
1224
|
+
const meta = await this.lookupDeviceMeta(deviceId);
|
|
1225
|
+
return await this.resolveSnapshotState(deviceId, meta);
|
|
1226
|
+
} catch (err) {
|
|
1227
|
+
this.ctx.logger.debug("snapshot: link state resolution failed", {
|
|
1228
|
+
tags: { deviceId },
|
|
1229
|
+
meta: { error: require_dist.errMsg(err) }
|
|
1230
|
+
});
|
|
1231
|
+
return { isBattery: false };
|
|
1178
1232
|
}
|
|
1179
1233
|
}
|
|
1180
1234
|
/**
|
|
@@ -1186,28 +1240,31 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1186
1240
|
* effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
|
|
1187
1241
|
*/
|
|
1188
1242
|
async resolveSnapshotMedia(deviceId, streamId, force, width) {
|
|
1243
|
+
const resolutionMeta = {};
|
|
1189
1244
|
const image = await this.getSnapshot({
|
|
1190
1245
|
deviceId,
|
|
1191
1246
|
...streamId !== void 0 ? { streamId } : {},
|
|
1192
1247
|
force
|
|
1193
|
-
});
|
|
1248
|
+
}, 0, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, resolutionMeta);
|
|
1194
1249
|
if (!image) return null;
|
|
1195
1250
|
const capturedAt = this.cache.get(deviceId, streamId)?.ts ?? Date.now();
|
|
1196
1251
|
const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
|
|
1197
|
-
const isBattery =
|
|
1252
|
+
const isBattery = resolutionMeta.isBattery ?? false;
|
|
1198
1253
|
if (width === void 0) return {
|
|
1199
1254
|
bytes: Buffer.from(image.base64, "base64"),
|
|
1200
1255
|
contentType: image.contentType,
|
|
1201
1256
|
capturedAt,
|
|
1202
1257
|
maxAgeS: effectiveMaxAgeS(prefs, isBattery),
|
|
1258
|
+
variantKey: resolutionMeta.stateReason,
|
|
1203
1259
|
servedWidth: void 0
|
|
1204
1260
|
};
|
|
1205
|
-
const thumb = await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs);
|
|
1261
|
+
const thumb = await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs, resolutionMeta.stateReason);
|
|
1206
1262
|
return {
|
|
1207
1263
|
bytes: thumb.bytes,
|
|
1208
1264
|
contentType: image.contentType,
|
|
1209
1265
|
capturedAt,
|
|
1210
1266
|
maxAgeS: effectiveMaxAgeS(prefs, isBattery),
|
|
1267
|
+
variantKey: resolutionMeta.stateReason,
|
|
1211
1268
|
servedWidth: thumb.width
|
|
1212
1269
|
};
|
|
1213
1270
|
}
|
|
@@ -1223,24 +1280,28 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1223
1280
|
async peekFreshFrame(deviceId, streamId) {
|
|
1224
1281
|
const hit = this.cache.get(deviceId, streamId);
|
|
1225
1282
|
if (!hit) return null;
|
|
1226
|
-
const
|
|
1283
|
+
const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
|
|
1284
|
+
const meta = await this.lookupDeviceMeta(deviceId);
|
|
1285
|
+
const state = await this.resolveSnapshotState(deviceId, meta);
|
|
1286
|
+
const maxAgeS = effectiveMaxAgeS(prefs, state.isBattery);
|
|
1227
1287
|
if (Date.now() - hit.ts >= maxAgeS * 1e3) return null;
|
|
1228
1288
|
return {
|
|
1229
1289
|
capturedAt: hit.ts,
|
|
1230
|
-
maxAgeS
|
|
1290
|
+
maxAgeS,
|
|
1291
|
+
variantKey: state.reason
|
|
1231
1292
|
};
|
|
1232
1293
|
}
|
|
1233
1294
|
/**
|
|
1234
|
-
* The frame at a card-sized width, derived once per
|
|
1235
|
-
* per capture.
|
|
1295
|
+
* The frame at a card-sized width, derived once per
|
|
1296
|
+
* (device, stream, width, state treatment) per capture.
|
|
1236
1297
|
*
|
|
1237
1298
|
* A failed resize falls back to the FULL frame — a visible card beats a
|
|
1238
1299
|
* broken one — but never silently: the card would otherwise keep costing a
|
|
1239
1300
|
* megabyte with nothing anywhere saying why, which is the shape of the bug
|
|
1240
1301
|
* this whole change came from.
|
|
1241
1302
|
*/
|
|
1242
|
-
async thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs) {
|
|
1243
|
-
const cached = this.variants.get(deviceId, streamId, width, capturedAt);
|
|
1303
|
+
async thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs, variantKey) {
|
|
1304
|
+
const cached = this.variants.get(deviceId, streamId, width, capturedAt, variantKey);
|
|
1244
1305
|
if (cached !== void 0) return {
|
|
1245
1306
|
bytes: cached,
|
|
1246
1307
|
width
|
|
@@ -1248,7 +1309,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1248
1309
|
const full = Buffer.from(image.base64, "base64");
|
|
1249
1310
|
try {
|
|
1250
1311
|
const resized = await this.resize(full, width);
|
|
1251
|
-
this.variants.set(deviceId, streamId, width, capturedAt, resized);
|
|
1312
|
+
this.variants.set(deviceId, streamId, width, capturedAt, resized, variantKey);
|
|
1252
1313
|
if (prefs.snapshotDebug) this.ctx.logger.info("snapshot: thumbnail derived", {
|
|
1253
1314
|
tags: { deviceId },
|
|
1254
1315
|
meta: {
|
|
@@ -1287,6 +1348,8 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1287
1348
|
}
|
|
1288
1349
|
this.cache.clear();
|
|
1289
1350
|
this.variants.clear();
|
|
1351
|
+
this.stateFrames.clear();
|
|
1352
|
+
this.wakingDevices.clear();
|
|
1290
1353
|
this.captureFlight.clear();
|
|
1291
1354
|
this.ownerCache = null;
|
|
1292
1355
|
}
|
|
@@ -1319,18 +1382,16 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1319
1382
|
})]
|
|
1320
1383
|
}] });
|
|
1321
1384
|
}
|
|
1322
|
-
async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY, maximumCaptureWaitMs = Number.POSITIVE_INFINITY) {
|
|
1385
|
+
async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY, maximumCaptureWaitMs = Number.POSITIVE_INFINITY, resolutionMeta) {
|
|
1323
1386
|
const { deviceId } = input;
|
|
1324
1387
|
const force = input.force === true;
|
|
1325
1388
|
const meta = await this.lookupDeviceMeta(deviceId);
|
|
1326
1389
|
const deviceName = meta?.name;
|
|
1327
|
-
const isBatteryDevice = meta?.isBattery ?? false;
|
|
1328
1390
|
const log = this.ctx.logger.withTags({
|
|
1329
1391
|
deviceId,
|
|
1330
1392
|
...deviceName ? { deviceName } : {}
|
|
1331
1393
|
});
|
|
1332
1394
|
const now = Date.now();
|
|
1333
|
-
if (meta?.disabled === true) return await this.courtesyImage(deviceId, "disabled", deviceName);
|
|
1334
1395
|
const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
|
|
1335
1396
|
const rawPref = prefs.snapshotStreamId;
|
|
1336
1397
|
const effectiveStreamId = input.streamId ?? (rawPref && rawPref !== "auto" ? rawPref : void 0);
|
|
@@ -1339,6 +1400,24 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1339
1400
|
meta: { stream: effectiveStreamId ?? "auto" }
|
|
1340
1401
|
});
|
|
1341
1402
|
const hit = this.cache.get(deviceId, effectiveStreamId);
|
|
1403
|
+
const state = await this.resolveSnapshotState(deviceId, meta);
|
|
1404
|
+
const isBatteryDevice = state.isBattery;
|
|
1405
|
+
if (resolutionMeta) {
|
|
1406
|
+
resolutionMeta.isBattery = isBatteryDevice;
|
|
1407
|
+
delete resolutionMeta.stateReason;
|
|
1408
|
+
}
|
|
1409
|
+
if (state.reason !== void 0 && (!force || state.reason === "disabled" || state.reason === "unreachable" || state.reason === "waking")) {
|
|
1410
|
+
if (resolutionMeta) resolutionMeta.stateReason = state.reason;
|
|
1411
|
+
log.debug("snapshot: camera state overrides raw cache", {
|
|
1412
|
+
tags: { deviceId },
|
|
1413
|
+
meta: {
|
|
1414
|
+
reason: state.reason,
|
|
1415
|
+
hasCache: hit !== void 0,
|
|
1416
|
+
ageMs: hit ? now - hit.ts : null
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
return await this.stateImage(deviceId, state.reason, deviceName, hit);
|
|
1420
|
+
}
|
|
1342
1421
|
const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3));
|
|
1343
1422
|
const decision = decideSnapshotServe({
|
|
1344
1423
|
now,
|
|
@@ -1357,19 +1436,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1357
1436
|
});
|
|
1358
1437
|
return hit ? hit.data : null;
|
|
1359
1438
|
}
|
|
1360
|
-
const presence = isBatteryDevice ? await this.readBatteryPresence(deviceId) : "awake";
|
|
1361
|
-
if (!force && presence !== "awake") {
|
|
1362
|
-
log.debug("snapshot: battery cam not awake and no force — serving cache, not capturing", {
|
|
1363
|
-
tags: { deviceId },
|
|
1364
|
-
meta: {
|
|
1365
|
-
presence,
|
|
1366
|
-
hasCache: hit !== void 0,
|
|
1367
|
-
ageMs: hit ? now - hit.ts : null
|
|
1368
|
-
}
|
|
1369
|
-
});
|
|
1370
|
-
if (hit) return hit.data;
|
|
1371
|
-
return await this.courtesyImage(deviceId, presence, deviceName);
|
|
1372
|
-
}
|
|
1373
1439
|
const flightKey = `${deviceId}:${effectiveStreamId ?? "auto"}`;
|
|
1374
1440
|
const flight = this.captureFlight.run(flightKey, () => this.captureFresh({
|
|
1375
1441
|
deviceId,
|
|
@@ -1488,6 +1554,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1488
1554
|
if (native) {
|
|
1489
1555
|
const result = await this.nativePool.run(() => native.getSnapshot(input));
|
|
1490
1556
|
if (result) {
|
|
1557
|
+
this.stateFrames.delete(deviceId);
|
|
1491
1558
|
this.cache.set(deviceId, effectiveStreamId, {
|
|
1492
1559
|
data: result,
|
|
1493
1560
|
ts: now,
|
|
@@ -1522,6 +1589,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1522
1589
|
if (!(isBatteryDevice && nativeAbsent && !await this.hasStreamingBrokerForDevice(deviceId))) try {
|
|
1523
1590
|
const fallback = await this.grabFrameFromBroker(deviceId, effectiveStreamId);
|
|
1524
1591
|
if (fallback) {
|
|
1592
|
+
this.stateFrames.delete(deviceId);
|
|
1525
1593
|
this.cache.set(deviceId, effectiveStreamId, {
|
|
1526
1594
|
data: fallback,
|
|
1527
1595
|
ts: now,
|
|
@@ -1675,6 +1743,8 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1675
1743
|
dropDeviceCaches(deviceId) {
|
|
1676
1744
|
this.cache.deleteDevice(deviceId);
|
|
1677
1745
|
this.variants.deleteDevice(deviceId);
|
|
1746
|
+
this.stateFrames.delete(deviceId);
|
|
1747
|
+
this.wakingDevices.delete(deviceId);
|
|
1678
1748
|
this.captureFlight.invalidatePrefix(`${deviceId}:`);
|
|
1679
1749
|
}
|
|
1680
1750
|
/**
|
|
@@ -1712,55 +1782,64 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1712
1782
|
}
|
|
1713
1783
|
}
|
|
1714
1784
|
/**
|
|
1715
|
-
*
|
|
1716
|
-
*
|
|
1717
|
-
* `fetchDevice(id).state.battery` is a `SliceHandle` over the hub's
|
|
1718
|
-
* runtime-state mirror: a local read, no RPC, no failure branch to get
|
|
1719
|
-
* wrong. It is the same mirror that feeds the battery badge elsewhere
|
|
1720
|
-
* in the UI, so if the operator can see "sleeping" on screen, this
|
|
1721
|
-
* sees it too. (Both spec harnesses already model exactly this wiring
|
|
1722
|
-
* — like the orphaned doc comment above, it outlived the gate it was
|
|
1723
|
-
* built for.)
|
|
1785
|
+
* Resolve the state that must be visible on a snapshot.
|
|
1724
1786
|
*
|
|
1725
|
-
*
|
|
1726
|
-
*
|
|
1727
|
-
* battery
|
|
1728
|
-
* WHETHER IT IS ASLEEP, never whether it has a battery.
|
|
1787
|
+
* Uses the same two canonical sources as wake-on-play:
|
|
1788
|
+
* 1. `deviceManager.getDevice` for the registry's BatteryOperated feature;
|
|
1789
|
+
* 2. `deviceState.getCapSlice('battery')` for the durable runtime mirror.
|
|
1729
1790
|
*
|
|
1730
|
-
*
|
|
1731
|
-
*
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
* The device's three-valued battery presence (D173) — `awake`, `sleeping`,
|
|
1738
|
-
* or `unreachable`. Same mirror read as the sleep gate, one derivation
|
|
1739
|
-
* (`deriveBatteryPresence`) so this layer cannot disagree with the badge or
|
|
1740
|
-
* the stream error about what a camera is doing.
|
|
1791
|
+
* A valid battery slice is itself positive evidence that the device is
|
|
1792
|
+
* battery-operated. That closes the field failure where "Baby monitor" had a
|
|
1793
|
+
* sleeping battery slice but a stale persisted feature projection, so the
|
|
1794
|
+
* snapshot wrapper classified it as mains and a Viewer thumbnail logged in
|
|
1795
|
+
* to the sleeping camera. The reverse is deliberately not guessed: no
|
|
1796
|
+
* feature + no valid slice remains a mains/unknown camera and follows the
|
|
1797
|
+
* ordinary capture path.
|
|
1741
1798
|
*
|
|
1742
|
-
*
|
|
1743
|
-
*
|
|
1744
|
-
*
|
|
1799
|
+
* Once battery-operated is established, a missing slice fails safe toward
|
|
1800
|
+
* `sleeping` through the shared `deriveBatteryPresence` function. This avoids
|
|
1801
|
+
* a background wake without inventing an `unreachable` fault.
|
|
1745
1802
|
*/
|
|
1746
|
-
async
|
|
1803
|
+
async resolveSnapshotState(deviceId, meta) {
|
|
1804
|
+
if (meta?.disabled === true) return {
|
|
1805
|
+
isBattery: meta.isBattery,
|
|
1806
|
+
reason: "disabled"
|
|
1807
|
+
};
|
|
1808
|
+
const waking = this.isDeviceWaking(deviceId);
|
|
1809
|
+
let batteryStatus = null;
|
|
1747
1810
|
try {
|
|
1748
|
-
const
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
const parsed = require_dist.BatteryStatusSchema.safeParse(raw);
|
|
1752
|
-
if (!parsed.success) return "awake";
|
|
1753
|
-
return require_dist.deriveBatteryPresence({
|
|
1754
|
-
status: parsed.data,
|
|
1755
|
-
nowMs: Date.now()
|
|
1811
|
+
const raw = await this.ctx.api.deviceState.getCapSlice.query({
|
|
1812
|
+
deviceId,
|
|
1813
|
+
capName: require_dist.batteryCapability.name
|
|
1756
1814
|
});
|
|
1815
|
+
const parsed = require_dist.BatteryStatusSchema.safeParse(raw);
|
|
1816
|
+
if (parsed.success) batteryStatus = parsed.data;
|
|
1757
1817
|
} catch (err) {
|
|
1758
|
-
this.ctx.logger.
|
|
1818
|
+
this.ctx.logger.debug("snapshot: battery runtime-state read failed", {
|
|
1759
1819
|
tags: { deviceId },
|
|
1760
1820
|
meta: { error: require_dist.errMsg(err) }
|
|
1761
1821
|
});
|
|
1762
|
-
return "awake";
|
|
1763
1822
|
}
|
|
1823
|
+
if (!(meta?.isBattery === true || batteryStatus !== null || waking)) return { isBattery: false };
|
|
1824
|
+
if (waking) return {
|
|
1825
|
+
isBattery: true,
|
|
1826
|
+
reason: "waking"
|
|
1827
|
+
};
|
|
1828
|
+
const presence = require_dist.deriveBatteryPresence({
|
|
1829
|
+
status: batteryStatus,
|
|
1830
|
+
nowMs: Date.now()
|
|
1831
|
+
});
|
|
1832
|
+
return presence === "awake" ? { isBattery: true } : {
|
|
1833
|
+
isBattery: true,
|
|
1834
|
+
reason: presence
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
isDeviceWaking(deviceId) {
|
|
1838
|
+
const startedAt = this.wakingDevices.get(deviceId);
|
|
1839
|
+
if (startedAt === void 0) return false;
|
|
1840
|
+
if (Date.now() - startedAt <= SNAPSHOT_WAKING_TTL_MS) return true;
|
|
1841
|
+
this.wakingDevices.delete(deviceId);
|
|
1842
|
+
return false;
|
|
1764
1843
|
}
|
|
1765
1844
|
/**
|
|
1766
1845
|
* True when at least one of the device's brokers is actively
|
|
@@ -1936,12 +2015,48 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1936
2015
|
};
|
|
1937
2016
|
}
|
|
1938
2017
|
/**
|
|
2018
|
+
* Render a state-labelled answer. A cached real frame is the background, not
|
|
2019
|
+
* the answer by itself; if it cannot be decoded, fall back to the generated
|
|
2020
|
+
* courtesy background rather than leaking an unlabelled stale frame.
|
|
2021
|
+
*/
|
|
2022
|
+
async stateImage(deviceId, reason, deviceName, hit) {
|
|
2023
|
+
const resolvedName = deviceName ?? `#${String(deviceId)}`;
|
|
2024
|
+
if (!hit) return await this.courtesyImage(deviceId, reason, resolvedName);
|
|
2025
|
+
const key = `${String(hit.ts)}:${hit.streamId ?? "auto"}:${reason}:${resolvedName}`;
|
|
2026
|
+
const deviceFrames = this.stateFrames.get(deviceId) ?? /* @__PURE__ */ new Map();
|
|
2027
|
+
this.stateFrames.set(deviceId, deviceFrames);
|
|
2028
|
+
const cached = deviceFrames.get(key);
|
|
2029
|
+
if (cached !== void 0) return {
|
|
2030
|
+
base64: cached.toString("base64"),
|
|
2031
|
+
contentType: "image/jpeg"
|
|
2032
|
+
};
|
|
2033
|
+
try {
|
|
2034
|
+
const bytes = await renderCourtesyOverlayJpeg(Buffer.from(hit.data.base64, "base64"), {
|
|
2035
|
+
deviceName: resolvedName,
|
|
2036
|
+
reason
|
|
2037
|
+
});
|
|
2038
|
+
deviceFrames.set(key, bytes);
|
|
2039
|
+
return {
|
|
2040
|
+
base64: bytes.toString("base64"),
|
|
2041
|
+
contentType: "image/jpeg"
|
|
2042
|
+
};
|
|
2043
|
+
} catch (err) {
|
|
2044
|
+
this.ctx.logger.warn("snapshot: state overlay failed — using generated courtesy background", {
|
|
2045
|
+
tags: { deviceId },
|
|
2046
|
+
meta: {
|
|
2047
|
+
reason,
|
|
2048
|
+
error: require_dist.errMsg(err)
|
|
2049
|
+
}
|
|
2050
|
+
});
|
|
2051
|
+
return await this.courtesyImage(deviceId, reason, resolvedName);
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
1939
2055
|
* Single-trip device lookup against device-manager. Returns the
|
|
1940
|
-
* fields the wrapper
|
|
1941
|
-
*
|
|
1942
|
-
*
|
|
1943
|
-
*
|
|
1944
|
-
* we're trying to be resilient to).
|
|
2056
|
+
* stable registry fields the wrapper consults. Battery classification is
|
|
2057
|
+
* completed by `resolveSnapshotState` from this feature flag PLUS the
|
|
2058
|
+
* canonical runtime-state mirror, so a stale persisted feature projection
|
|
2059
|
+
* cannot authorize a snapshot dial against a known sleeping battery camera.
|
|
1945
2060
|
*
|
|
1946
2061
|
* Logged at debug + null return on failure: every call site already
|
|
1947
2062
|
* has a sensible fallback path (cache hit, conservative default, …),
|
|
@@ -1973,7 +2088,8 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1973
2088
|
}
|
|
1974
2089
|
/** Settings-UI helper — battery flag drives the default max-age in the field description. */
|
|
1975
2090
|
async isDeviceBattery(deviceId) {
|
|
1976
|
-
|
|
2091
|
+
const meta = await this.lookupDeviceMeta(deviceId);
|
|
2092
|
+
return (await this.resolveSnapshotState(deviceId, meta)).isBattery;
|
|
1977
2093
|
}
|
|
1978
2094
|
/**
|
|
1979
2095
|
* Feeds the settings-UI stream picker. Pinned to the ingest owner via
|