@camstack/system 1.2.93 → 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.
Files changed (53) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  8. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  9. package/dist/builtins/console-logging/index.js +1 -1
  10. package/dist/builtins/console-logging/index.mjs +1 -1
  11. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  12. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  13. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  14. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  15. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  16. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  17. package/dist/builtins/hub-forwarder/index.js +1 -1
  18. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  19. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  20. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  21. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  22. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  23. package/dist/builtins/local-network/local-network.addon.js +1 -1
  24. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  25. package/dist/builtins/loki-logging/index.js +1 -1
  26. package/dist/builtins/loki-logging/index.mjs +1 -1
  27. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  28. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  29. package/dist/builtins/platform-probe/index.js +1 -1
  30. package/dist/builtins/platform-probe/index.mjs +1 -1
  31. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  32. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  33. package/dist/builtins/snapshot/index.js +205 -94
  34. package/dist/builtins/snapshot/index.mjs +205 -94
  35. package/dist/builtins/snapshot/snapshot-courtesy.d.ts +13 -1
  36. package/dist/builtins/snapshot/snapshot-media-handler.d.ts +7 -1
  37. package/dist/builtins/snapshot/snapshot-resize.d.ts +4 -4
  38. package/dist/builtins/snapshot/snapshot.addon.d.ts +35 -37
  39. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  40. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  41. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  42. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  45. package/dist/builtins/system-config/system-config.addon.js +1 -1
  46. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  47. package/dist/builtins/winston-logging/index.js +1 -1
  48. package/dist/builtins/winston-logging/index.mjs +1 -1
  49. package/dist/{dist-BMus2E5R.js → dist-BspfS8Q7.js} +6 -0
  50. package/dist/{dist-F1XDQDE1.mjs → dist-COd223t-.mjs} +1 -1
  51. package/dist/index.js +1 -1
  52. package/dist/index.mjs +1 -1
  53. 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-BMus2E5R.js");
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
- * A courtesy frame for a camera that CANNOT produce one.
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 a frame that SAYS what is going on, carrying the
270
- * camera's own name. The client gets a valid image, the tile paints, and the
271
- * state is legible instead of inferred from an error.
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 and from several streams, and a plain
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 validated against the
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
- * (3 reasons × this node's own cameras), not user input.
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 [{
@@ -1166,13 +1216,12 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1166
1216
  }
1167
1217
  });
1168
1218
  }
1169
- /** True only for a BATTERY device that is currently asleep. Kept as one
1170
- * question so the link path cannot accidentally treat "battery" as "asleep"
1171
- * and stop refreshing a camera that is awake and streaming. */
1219
+ /** True only for a battery device that must not be snapshot-refreshed. */
1172
1220
  async isSleepingBatteryDevice(deviceId) {
1173
1221
  try {
1174
- if ((await this.lookupDeviceMeta(deviceId))?.isBattery !== true) return false;
1175
- return await this.isDeviceSleeping(deviceId);
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";
1176
1225
  } catch {
1177
1226
  return false;
1178
1227
  }
@@ -1186,28 +1235,31 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1186
1235
  * effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
1187
1236
  */
1188
1237
  async resolveSnapshotMedia(deviceId, streamId, force, width) {
1238
+ const resolutionMeta = {};
1189
1239
  const image = await this.getSnapshot({
1190
1240
  deviceId,
1191
1241
  ...streamId !== void 0 ? { streamId } : {},
1192
1242
  force
1193
- });
1243
+ }, 0, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, resolutionMeta);
1194
1244
  if (!image) return null;
1195
1245
  const capturedAt = this.cache.get(deviceId, streamId)?.ts ?? Date.now();
1196
1246
  const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
1197
- const isBattery = (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false;
1247
+ const isBattery = resolutionMeta.isBattery ?? false;
1198
1248
  if (width === void 0) return {
1199
1249
  bytes: Buffer.from(image.base64, "base64"),
1200
1250
  contentType: image.contentType,
1201
1251
  capturedAt,
1202
1252
  maxAgeS: effectiveMaxAgeS(prefs, isBattery),
1253
+ variantKey: resolutionMeta.stateReason,
1203
1254
  servedWidth: void 0
1204
1255
  };
1205
- 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);
1206
1257
  return {
1207
1258
  bytes: thumb.bytes,
1208
1259
  contentType: image.contentType,
1209
1260
  capturedAt,
1210
1261
  maxAgeS: effectiveMaxAgeS(prefs, isBattery),
1262
+ variantKey: resolutionMeta.stateReason,
1211
1263
  servedWidth: thumb.width
1212
1264
  };
1213
1265
  }
@@ -1223,24 +1275,28 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1223
1275
  async peekFreshFrame(deviceId, streamId) {
1224
1276
  const hit = this.cache.get(deviceId, streamId);
1225
1277
  if (!hit) return null;
1226
- const maxAgeS = effectiveMaxAgeS(await this.readDeviceSettings(deviceId).catch(() => ({})), (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false);
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);
1227
1282
  if (Date.now() - hit.ts >= maxAgeS * 1e3) return null;
1228
1283
  return {
1229
1284
  capturedAt: hit.ts,
1230
- maxAgeS
1285
+ maxAgeS,
1286
+ variantKey: state.reason
1231
1287
  };
1232
1288
  }
1233
1289
  /**
1234
- * The frame at a card-sized width, derived once per (device, stream, width)
1235
- * per capture.
1290
+ * The frame at a card-sized width, derived once per
1291
+ * (device, stream, width, state treatment) per capture.
1236
1292
  *
1237
1293
  * A failed resize falls back to the FULL frame — a visible card beats a
1238
1294
  * broken one — but never silently: the card would otherwise keep costing a
1239
1295
  * megabyte with nothing anywhere saying why, which is the shape of the bug
1240
1296
  * this whole change came from.
1241
1297
  */
1242
- async thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs) {
1243
- 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);
1244
1300
  if (cached !== void 0) return {
1245
1301
  bytes: cached,
1246
1302
  width
@@ -1248,7 +1304,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1248
1304
  const full = Buffer.from(image.base64, "base64");
1249
1305
  try {
1250
1306
  const resized = await this.resize(full, width);
1251
- this.variants.set(deviceId, streamId, width, capturedAt, resized);
1307
+ this.variants.set(deviceId, streamId, width, capturedAt, resized, variantKey);
1252
1308
  if (prefs.snapshotDebug) this.ctx.logger.info("snapshot: thumbnail derived", {
1253
1309
  tags: { deviceId },
1254
1310
  meta: {
@@ -1287,6 +1343,8 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1287
1343
  }
1288
1344
  this.cache.clear();
1289
1345
  this.variants.clear();
1346
+ this.stateFrames.clear();
1347
+ this.wakingDevices.clear();
1290
1348
  this.captureFlight.clear();
1291
1349
  this.ownerCache = null;
1292
1350
  }
@@ -1319,18 +1377,16 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1319
1377
  })]
1320
1378
  }] });
1321
1379
  }
1322
- 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) {
1323
1381
  const { deviceId } = input;
1324
1382
  const force = input.force === true;
1325
1383
  const meta = await this.lookupDeviceMeta(deviceId);
1326
1384
  const deviceName = meta?.name;
1327
- const isBatteryDevice = meta?.isBattery ?? false;
1328
1385
  const log = this.ctx.logger.withTags({
1329
1386
  deviceId,
1330
1387
  ...deviceName ? { deviceName } : {}
1331
1388
  });
1332
1389
  const now = Date.now();
1333
- if (meta?.disabled === true) return await this.courtesyImage(deviceId, "disabled", deviceName);
1334
1390
  const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
1335
1391
  const rawPref = prefs.snapshotStreamId;
1336
1392
  const effectiveStreamId = input.streamId ?? (rawPref && rawPref !== "auto" ? rawPref : void 0);
@@ -1339,6 +1395,24 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1339
1395
  meta: { stream: effectiveStreamId ?? "auto" }
1340
1396
  });
1341
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
+ }
1342
1416
  const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3));
1343
1417
  const decision = decideSnapshotServe({
1344
1418
  now,
@@ -1357,19 +1431,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1357
1431
  });
1358
1432
  return hit ? hit.data : null;
1359
1433
  }
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
1434
  const flightKey = `${deviceId}:${effectiveStreamId ?? "auto"}`;
1374
1435
  const flight = this.captureFlight.run(flightKey, () => this.captureFresh({
1375
1436
  deviceId,
@@ -1488,6 +1549,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1488
1549
  if (native) {
1489
1550
  const result = await this.nativePool.run(() => native.getSnapshot(input));
1490
1551
  if (result) {
1552
+ this.stateFrames.delete(deviceId);
1491
1553
  this.cache.set(deviceId, effectiveStreamId, {
1492
1554
  data: result,
1493
1555
  ts: now,
@@ -1522,6 +1584,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1522
1584
  if (!(isBatteryDevice && nativeAbsent && !await this.hasStreamingBrokerForDevice(deviceId))) try {
1523
1585
  const fallback = await this.grabFrameFromBroker(deviceId, effectiveStreamId);
1524
1586
  if (fallback) {
1587
+ this.stateFrames.delete(deviceId);
1525
1588
  this.cache.set(deviceId, effectiveStreamId, {
1526
1589
  data: fallback,
1527
1590
  ts: now,
@@ -1675,6 +1738,8 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1675
1738
  dropDeviceCaches(deviceId) {
1676
1739
  this.cache.deleteDevice(deviceId);
1677
1740
  this.variants.deleteDevice(deviceId);
1741
+ this.stateFrames.delete(deviceId);
1742
+ this.wakingDevices.delete(deviceId);
1678
1743
  this.captureFlight.invalidatePrefix(`${deviceId}:`);
1679
1744
  }
1680
1745
  /**
@@ -1712,55 +1777,64 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1712
1777
  }
1713
1778
  }
1714
1779
  /**
1715
- * Sleep state from the device-state MIRROR, not from a cap round-trip.
1780
+ * Resolve the state that must be visible on a snapshot.
1716
1781
  *
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.)
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.
1724
1785
  *
1725
- * `undefined` slice the device has no battery state we know of →
1726
- * treated as awake. The caller has already established the device is
1727
- * battery-operated from its feature flags, so this only decides
1728
- * WHETHER IT IS ASLEEP, never whether it has a battery.
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.
1729
1793
  *
1730
- * Only consulted on the path that would otherwise CAPTURE — a fresh
1731
- * cache hit returns before we get here.
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.
1732
1797
  */
1733
- async isDeviceSleeping(deviceId) {
1734
- return await this.readBatteryPresence(deviceId) !== "awake";
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.
1741
- *
1742
- * A failed read answers `awake`: unknown must never become a fault badge,
1743
- * and "assume awake" is the pre-existing, deliberately conservative failure
1744
- * direction of the gate this replaces.
1745
- */
1746
- async readBatteryPresence(deviceId) {
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;
1747
1805
  try {
1748
- const handle = (await this.ctx.fetchDevice(deviceId)).state.battery;
1749
- await handle.refresh();
1750
- const raw = handle.value;
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()
1806
+ const raw = await this.ctx.api.deviceState.getCapSlice.query({
1807
+ deviceId,
1808
+ capName: require_dist.batteryCapability.name
1756
1809
  });
1810
+ const parsed = require_dist.BatteryStatusSchema.safeParse(raw);
1811
+ if (parsed.success) batteryStatus = parsed.data;
1757
1812
  } catch (err) {
1758
- this.ctx.logger.warn("snapshot: battery mirror read failed — assuming awake", {
1813
+ this.ctx.logger.debug("snapshot: battery runtime-state read failed", {
1759
1814
  tags: { deviceId },
1760
1815
  meta: { error: require_dist.errMsg(err) }
1761
1816
  });
1762
- return "awake";
1763
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;
1764
1838
  }
1765
1839
  /**
1766
1840
  * True when at least one of the device's brokers is actively
@@ -1936,12 +2010,48 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1936
2010
  };
1937
2011
  }
1938
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
+ /**
1939
2050
  * Single-trip device lookup against device-manager. Returns the
1940
- * fields the wrapper actually consults name (logging) + battery
1941
- * flag (cache window + broker-fallback gate). Sourced from the
1942
- * device-manager registry rather than the battery cap so the answer
1943
- * survives a momentarily-unreachable provider (the very condition
1944
- * 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.
1945
2055
  *
1946
2056
  * Logged at debug + null return on failure: every call site already
1947
2057
  * has a sensible fallback path (cache hit, conservative default, …),
@@ -1973,7 +2083,8 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1973
2083
  }
1974
2084
  /** Settings-UI helper — battery flag drives the default max-age in the field description. */
1975
2085
  async isDeviceBattery(deviceId) {
1976
- return (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false;
2086
+ const meta = await this.lookupDeviceMeta(deviceId);
2087
+ return (await this.resolveSnapshotState(deviceId, meta)).isBattery;
1977
2088
  }
1978
2089
  /**
1979
2090
  * Feeds the settings-UI stream picker. Pinned to the ingest owner via