@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.
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 +207 -75
  34. package/dist/builtins/snapshot/index.mjs +207 -75
  35. package/dist/builtins/snapshot/snapshot-courtesy.d.ts +24 -2
  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 -26
  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-BH2tZ9ZN.js → dist-BspfS8Q7.js} +105 -2
  50. package/dist/{dist-DXDvrBi0.mjs → dist-COd223t-.mjs} +94 -3
  51. package/dist/index.js +1 -1
  52. package/dist/index.mjs +1 -1
  53. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { Ct as DeviceType, It as nodePin, Ut as EventCategory, _t as BaseAddon, ct as snapshotCapability, ft as streamQualityLabel, gt as errMsg, i as BatteryStatusSchema, w as bareAddonId, xt as DeviceFeature } from "../../dist-DXDvrBi0.mjs";
1
+ import { Ct as DeviceFeature, Gt as EventCategory, M as deriveBatteryPresence, Rt as nodePin, T as batteryCapability, Tt as DeviceType, i as BatteryStatusSchema, mt as streamQualityLabel, ut as snapshotCapability, vt as errMsg, w as bareAddonId, yt as BaseAddon } from "../../dist-COd223t-.mjs";
2
2
  import { z } from "zod";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { signExpiringUrl, verifyExpiringUrl } from "@camstack/types/node";
@@ -249,7 +249,7 @@ function raceForResult(promise, timeoutMs) {
249
249
  //#endregion
250
250
  //#region src/builtins/snapshot/snapshot-courtesy.ts
251
251
  /**
252
- * A courtesy frame for a camera that CANNOT produce one.
252
+ * State treatment for a camera that cannot produce a current frame.
253
253
  *
254
254
  * ── Why this exists ───────────────────────────────────────────────────────
255
255
  * A disabled, offline or sleeping camera has no frame, and until 2026-08-11 the
@@ -260,9 +260,9 @@ function raceForResult(promise, timeoutMs) {
260
260
  * who deliberately switched a camera off saw the same thing as a fault ([D62]:
261
261
  * "an off switch is REPORTED off; disabled must never look like broken").
262
262
  *
263
- * So the service answers with a frame that SAYS what is going on, carrying the
264
- * camera's own name. The client gets a valid image, the tile paints, and the
265
- * state is legible instead of inferred from an error.
263
+ * So the service answers with an image that SAYS what is going on, carrying
264
+ * the camera's own name. When a real cached frame exists it remains visible
265
+ * under the treatment; otherwise the generated courtesy background is used.
266
266
  *
267
267
  * ── Why sharp and not ffmpeg ──────────────────────────────────────────────
268
268
  * The first draft of this file shelled out to `ffmpeg` with a `drawtext`
@@ -293,6 +293,8 @@ function courtesyLabel(reason) {
293
293
  case "disabled": return "Disabled";
294
294
  case "offline": return "Offline";
295
295
  case "sleeping": return "Sleeping";
296
+ case "unreachable": return "Unreachable";
297
+ case "waking": return "Waking";
296
298
  }
297
299
  }
298
300
  /**
@@ -304,6 +306,8 @@ function courtesyBackground(reason) {
304
306
  case "disabled": return "#2b2b31";
305
307
  case "offline": return "#3a2b2b";
306
308
  case "sleeping": return "#232b3a";
309
+ case "waking": return "#243247";
310
+ case "unreachable": return "#3a2b2b";
307
311
  }
308
312
  }
309
313
  /** XML escaping for text placed inside an SVG `<text>` node. */
@@ -318,15 +322,27 @@ function escapeCourtesyText(value) {
318
322
  * frame read the same, with a floor so a thumbnail stays legible.
319
323
  */
320
324
  function buildCourtesySvg(spec) {
325
+ return buildStateSvg(spec, false);
326
+ }
327
+ /**
328
+ * The same state treatment as a courtesy frame, but translucent so the last
329
+ * real camera frame remains visible underneath it.
330
+ */
331
+ function buildCourtesyOverlaySvg(spec) {
332
+ return buildStateSvg(spec, true);
333
+ }
334
+ function buildStateSvg(spec, overlay) {
321
335
  const stateSize = Math.max(12, Math.round(spec.width / 12));
322
336
  const nameSize = Math.max(9, Math.round(spec.width / 26));
323
337
  const state = escapeCourtesyText(courtesyLabel(spec.reason));
324
338
  const name = escapeCourtesyText(spec.deviceName);
325
339
  const midY = spec.height / 2;
340
+ const washOpacity = overlay ? "0.62" : "1";
341
+ const textStroke = Math.max(1, Math.round(spec.width / 320));
326
342
  return [
327
343
  `<svg xmlns="http://www.w3.org/2000/svg" width="${String(spec.width)}" height="${String(spec.height)}">`,
328
- `<rect width="100%" height="100%" fill="${courtesyBackground(spec.reason)}"/>`,
329
- `<g font-family="${COURTESY_FONT_STACK}" text-anchor="middle">`,
344
+ `<rect width="100%" height="100%" fill="${courtesyBackground(spec.reason)}" fill-opacity="${washOpacity}"/>`,
345
+ `<g font-family="${COURTESY_FONT_STACK}" text-anchor="middle" paint-order="stroke" stroke="#111118" stroke-width="${String(textStroke)}">`,
330
346
  `<text x="50%" y="${String(Math.round(midY))}" font-size="${String(stateSize)}" fill="#e8e8ee">${state}</text>`,
331
347
  `<text x="50%" y="${String(Math.round(midY + stateSize))}" font-size="${String(nameSize)}" fill="#9a9aa8">${name}</text>`,
332
348
  `</g></svg>`
@@ -348,6 +364,24 @@ function renderCourtesyJpeg(spec) {
348
364
  return bytes;
349
365
  });
350
366
  }
367
+ /**
368
+ * Paint the state treatment over a real cached frame.
369
+ *
370
+ * The source dimensions are authoritative: overlays preserve the original
371
+ * frame rather than resizing it to the generated courtesy-frame default.
372
+ */
373
+ async function renderCourtesyOverlayJpeg(background, spec) {
374
+ const metadata = await sharp(background).metadata();
375
+ if (!metadata.width || !metadata.height) throw new Error("cached snapshot has no renderable dimensions");
376
+ const overlay = Buffer.from(buildCourtesyOverlaySvg({
377
+ ...spec,
378
+ width: metadata.width,
379
+ height: metadata.height
380
+ }));
381
+ const bytes = await sharp(background).composite([{ input: overlay }]).jpeg({ quality: 82 }).toBuffer();
382
+ if (bytes.length === 0) throw new Error("courtesy overlay produced no bytes");
383
+ return bytes;
384
+ }
351
385
  //#endregion
352
386
  //#region src/builtins/snapshot/snapshot-link-url.ts
353
387
  /**
@@ -541,7 +575,8 @@ function parseSnapshotMediaRequest(url) {
541
575
  * The response's entity tag.
542
576
  *
543
577
  * It has to identify the VARIANT, not just the frame: the same capture can now
544
- * be served at several widths and from several streams, and a plain
578
+ * be served at several widths, from several streams, and with a camera-state
579
+ * treatment, and a plain
545
580
  * `"<device>-<capturedAt>"` would let a client that fetched `?w=320` receive a
546
581
  * 304 for `?w=960` and render the small image at full size. The base form is
547
582
  * unchanged for a plain request, so the identity
@@ -552,6 +587,7 @@ function snapshotEtag(variant, capturedAt) {
552
587
  const parts = [`${String(variant.deviceId)}-${String(capturedAt)}`];
553
588
  if (variant.streamId !== void 0) parts.push(`s${variant.streamId}`);
554
589
  if (variant.width !== void 0) parts.push(`w${String(variant.width)}`);
590
+ if (variant.variantKey !== void 0) parts.push(`v${variant.variantKey}`);
555
591
  return `"${parts.join("-")}"`;
556
592
  }
557
593
  /**
@@ -582,7 +618,8 @@ function createSnapshotMediaHandler(deps) {
582
618
  const peekedEtag = snapshotEtag({
583
619
  deviceId: parsed.deviceId,
584
620
  streamId: parsed.streamId,
585
- width: parsed.width
621
+ width: parsed.width,
622
+ variantKey: peeked.variantKey
586
623
  }, peeked.capturedAt);
587
624
  if (inm === peekedEtag) {
588
625
  res.writeHead(304, {
@@ -613,7 +650,8 @@ function createSnapshotMediaHandler(deps) {
613
650
  const etag = snapshotEtag({
614
651
  deviceId: parsed.deviceId,
615
652
  streamId: parsed.streamId,
616
- width: "servedWidth" in media ? media.servedWidth : parsed.width
653
+ width: "servedWidth" in media ? media.servedWidth : parsed.width,
654
+ variantKey: media.variantKey
617
655
  }, media.capturedAt);
618
656
  const cacheControl = `private, max-age=${Math.max(0, Math.floor(media.maxAgeS))}`;
619
657
  if (inm === etag) {
@@ -716,8 +754,8 @@ function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
716
754
  });
717
755
  }
718
756
  /**
719
- * Resized frames, keyed by (device, stream, width) AND validated against the
720
- * source frame's timestamp.
757
+ * Resized frames, keyed by (device, stream, width, state treatment) AND
758
+ * validated against the source frame's timestamp.
721
759
  *
722
760
  * The timestamp is the whole correctness argument: a variant outlives nothing.
723
761
  * When the underlying frame is recaptured its `capturedAt` moves, every variant
@@ -728,17 +766,17 @@ function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
728
766
  var SnapshotVariantCache = class SnapshotVariantCache {
729
767
  byKey = /* @__PURE__ */ new Map();
730
768
  keysByDevice = /* @__PURE__ */ new Map();
731
- static key(deviceId, streamId, width) {
732
- return `${deviceId}:${streamId ?? "auto"}:${width}`;
769
+ static key(deviceId, streamId, width, variantKey) {
770
+ return `${deviceId}:${streamId ?? "auto"}:${width}:${variantKey ?? "raw"}`;
733
771
  }
734
772
  /** The variant for this exact frame, or undefined when it is missing or was
735
773
  * derived from an older capture. */
736
- get(deviceId, streamId, width, sourceTs) {
737
- const entry = this.byKey.get(SnapshotVariantCache.key(deviceId, streamId, width));
774
+ get(deviceId, streamId, width, sourceTs, variantKey) {
775
+ const entry = this.byKey.get(SnapshotVariantCache.key(deviceId, streamId, width, variantKey));
738
776
  return entry !== void 0 && entry.sourceTs === sourceTs ? entry.bytes : void 0;
739
777
  }
740
- set(deviceId, streamId, width, sourceTs, bytes) {
741
- const key = SnapshotVariantCache.key(deviceId, streamId, width);
778
+ set(deviceId, streamId, width, sourceTs, bytes, variantKey) {
779
+ const key = SnapshotVariantCache.key(deviceId, streamId, width, variantKey);
742
780
  this.byKey.set(key, {
743
781
  bytes,
744
782
  sourceTs
@@ -779,6 +817,9 @@ var LINK_MINT_DEADLINE_MS = 2500;
779
817
  var LINK_BATCH_WAIT_MS = 750;
780
818
  /** Frames older than this are never represented as current by the Viewer. */
781
819
  var LINK_CURRENT_MAX_AGE_MS = 15e3;
820
+ /** Wake-on-play's 8 s firmware wait + 20 s broker settle, with scheduling
821
+ * slack. A lost completion event must not leave a camera "Waking" forever. */
822
+ var SNAPSHOT_WAKING_TTL_MS = 35e3;
782
823
  /** Default cache window for non-battery cams (seconds). 10s feels live. */
783
824
  var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
784
825
  /** Default cache window for battery cams (seconds). 1h ≈ "don't wake the cam unless asked". */
@@ -823,9 +864,14 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
823
864
  * A courtesy frame is a pure function of those three, so it is rendered once
824
865
  * and reused for as long as the process lives — a disabled camera must not
825
866
  * cost an ffmpeg run per poll. Unbounded on purpose: the key space is
826
- * (3 reasons × this node's own cameras), not user input.
867
+ * (state reasons × this node's own cameras), not user input.
827
868
  */
828
869
  courtesyFrames = /* @__PURE__ */ new Map();
870
+ /** State overlays over real cached frames, grouped by device so invalidation
871
+ * can discard every generation in O(1). */
872
+ stateFrames = /* @__PURE__ */ new Map();
873
+ /** Devices inside the explicit battery wake-in-progress window. */
874
+ wakingDevices = /* @__PURE__ */ new Map();
829
875
  /**
830
876
  * De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
831
877
  * settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
@@ -897,6 +943,12 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
897
943
  const deviceId = event.data.deviceId;
898
944
  if (typeof deviceId === "number") this.evictRemovedDevice(deviceId, "device-unregistered");
899
945
  });
946
+ this.subscribe({ category: EventCategory.BatteryOnWakeStarted }, (event) => {
947
+ this.wakingDevices.set(event.data.deviceId, Date.now());
948
+ });
949
+ this.subscribe({ category: EventCategory.BatteryOnStatusChanged }, (event) => {
950
+ this.wakingDevices.delete(event.data.deviceId);
951
+ });
900
952
  await this.serveMediaDataPlane();
901
953
  await this.ensureLinkDataPlane();
902
954
  return [{
@@ -1158,13 +1210,12 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1158
1210
  }
1159
1211
  });
1160
1212
  }
1161
- /** True only for a BATTERY device that is currently asleep. Kept as one
1162
- * question so the link path cannot accidentally treat "battery" as "asleep"
1163
- * and stop refreshing a camera that is awake and streaming. */
1213
+ /** True only for a battery device that must not be snapshot-refreshed. */
1164
1214
  async isSleepingBatteryDevice(deviceId) {
1165
1215
  try {
1166
- if ((await this.lookupDeviceMeta(deviceId))?.isBattery !== true) return false;
1167
- return await this.isDeviceSleeping(deviceId);
1216
+ const meta = await this.lookupDeviceMeta(deviceId);
1217
+ const state = await this.resolveSnapshotState(deviceId, meta);
1218
+ return state.reason === "sleeping" || state.reason === "unreachable" || state.reason === "waking";
1168
1219
  } catch {
1169
1220
  return false;
1170
1221
  }
@@ -1178,28 +1229,31 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1178
1229
  * effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
1179
1230
  */
1180
1231
  async resolveSnapshotMedia(deviceId, streamId, force, width) {
1232
+ const resolutionMeta = {};
1181
1233
  const image = await this.getSnapshot({
1182
1234
  deviceId,
1183
1235
  ...streamId !== void 0 ? { streamId } : {},
1184
1236
  force
1185
- });
1237
+ }, 0, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, resolutionMeta);
1186
1238
  if (!image) return null;
1187
1239
  const capturedAt = this.cache.get(deviceId, streamId)?.ts ?? Date.now();
1188
1240
  const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
1189
- const isBattery = (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false;
1241
+ const isBattery = resolutionMeta.isBattery ?? false;
1190
1242
  if (width === void 0) return {
1191
1243
  bytes: Buffer.from(image.base64, "base64"),
1192
1244
  contentType: image.contentType,
1193
1245
  capturedAt,
1194
1246
  maxAgeS: effectiveMaxAgeS(prefs, isBattery),
1247
+ variantKey: resolutionMeta.stateReason,
1195
1248
  servedWidth: void 0
1196
1249
  };
1197
- const thumb = await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs);
1250
+ const thumb = await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs, resolutionMeta.stateReason);
1198
1251
  return {
1199
1252
  bytes: thumb.bytes,
1200
1253
  contentType: image.contentType,
1201
1254
  capturedAt,
1202
1255
  maxAgeS: effectiveMaxAgeS(prefs, isBattery),
1256
+ variantKey: resolutionMeta.stateReason,
1203
1257
  servedWidth: thumb.width
1204
1258
  };
1205
1259
  }
@@ -1215,24 +1269,28 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1215
1269
  async peekFreshFrame(deviceId, streamId) {
1216
1270
  const hit = this.cache.get(deviceId, streamId);
1217
1271
  if (!hit) return null;
1218
- const maxAgeS = effectiveMaxAgeS(await this.readDeviceSettings(deviceId).catch(() => ({})), (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false);
1272
+ const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
1273
+ const meta = await this.lookupDeviceMeta(deviceId);
1274
+ const state = await this.resolveSnapshotState(deviceId, meta);
1275
+ const maxAgeS = effectiveMaxAgeS(prefs, state.isBattery);
1219
1276
  if (Date.now() - hit.ts >= maxAgeS * 1e3) return null;
1220
1277
  return {
1221
1278
  capturedAt: hit.ts,
1222
- maxAgeS
1279
+ maxAgeS,
1280
+ variantKey: state.reason
1223
1281
  };
1224
1282
  }
1225
1283
  /**
1226
- * The frame at a card-sized width, derived once per (device, stream, width)
1227
- * per capture.
1284
+ * The frame at a card-sized width, derived once per
1285
+ * (device, stream, width, state treatment) per capture.
1228
1286
  *
1229
1287
  * A failed resize falls back to the FULL frame — a visible card beats a
1230
1288
  * broken one — but never silently: the card would otherwise keep costing a
1231
1289
  * megabyte with nothing anywhere saying why, which is the shape of the bug
1232
1290
  * this whole change came from.
1233
1291
  */
1234
- async thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs) {
1235
- const cached = this.variants.get(deviceId, streamId, width, capturedAt);
1292
+ async thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs, variantKey) {
1293
+ const cached = this.variants.get(deviceId, streamId, width, capturedAt, variantKey);
1236
1294
  if (cached !== void 0) return {
1237
1295
  bytes: cached,
1238
1296
  width
@@ -1240,7 +1298,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1240
1298
  const full = Buffer.from(image.base64, "base64");
1241
1299
  try {
1242
1300
  const resized = await this.resize(full, width);
1243
- this.variants.set(deviceId, streamId, width, capturedAt, resized);
1301
+ this.variants.set(deviceId, streamId, width, capturedAt, resized, variantKey);
1244
1302
  if (prefs.snapshotDebug) this.ctx.logger.info("snapshot: thumbnail derived", {
1245
1303
  tags: { deviceId },
1246
1304
  meta: {
@@ -1279,6 +1337,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1279
1337
  }
1280
1338
  this.cache.clear();
1281
1339
  this.variants.clear();
1340
+ this.stateFrames.clear();
1341
+ this.wakingDevices.clear();
1282
1342
  this.captureFlight.clear();
1283
1343
  this.ownerCache = null;
1284
1344
  }
@@ -1311,18 +1371,16 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1311
1371
  })]
1312
1372
  }] });
1313
1373
  }
1314
- async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY, maximumCaptureWaitMs = Number.POSITIVE_INFINITY) {
1374
+ async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY, maximumCaptureWaitMs = Number.POSITIVE_INFINITY, resolutionMeta) {
1315
1375
  const { deviceId } = input;
1316
1376
  const force = input.force === true;
1317
1377
  const meta = await this.lookupDeviceMeta(deviceId);
1318
1378
  const deviceName = meta?.name;
1319
- const isBatteryDevice = meta?.isBattery ?? false;
1320
1379
  const log = this.ctx.logger.withTags({
1321
1380
  deviceId,
1322
1381
  ...deviceName ? { deviceName } : {}
1323
1382
  });
1324
1383
  const now = Date.now();
1325
- if (meta?.disabled === true) return await this.courtesyImage(deviceId, "disabled", deviceName);
1326
1384
  const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
1327
1385
  const rawPref = prefs.snapshotStreamId;
1328
1386
  const effectiveStreamId = input.streamId ?? (rawPref && rawPref !== "auto" ? rawPref : void 0);
@@ -1331,6 +1389,24 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1331
1389
  meta: { stream: effectiveStreamId ?? "auto" }
1332
1390
  });
1333
1391
  const hit = this.cache.get(deviceId, effectiveStreamId);
1392
+ const state = await this.resolveSnapshotState(deviceId, meta);
1393
+ const isBatteryDevice = state.isBattery;
1394
+ if (resolutionMeta) {
1395
+ resolutionMeta.isBattery = isBatteryDevice;
1396
+ delete resolutionMeta.stateReason;
1397
+ }
1398
+ if (state.reason !== void 0 && (!force || state.reason === "disabled" || state.reason === "unreachable" || state.reason === "waking")) {
1399
+ if (resolutionMeta) resolutionMeta.stateReason = state.reason;
1400
+ log.debug("snapshot: camera state overrides raw cache", {
1401
+ tags: { deviceId },
1402
+ meta: {
1403
+ reason: state.reason,
1404
+ hasCache: hit !== void 0,
1405
+ ageMs: hit ? now - hit.ts : null
1406
+ }
1407
+ });
1408
+ return await this.stateImage(deviceId, state.reason, deviceName, hit);
1409
+ }
1334
1410
  const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3));
1335
1411
  const decision = decideSnapshotServe({
1336
1412
  now,
@@ -1349,17 +1425,6 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1349
1425
  });
1350
1426
  return hit ? hit.data : null;
1351
1427
  }
1352
- if (!force && isBatteryDevice && await this.isDeviceSleeping(deviceId)) {
1353
- log.debug("snapshot: sleeping battery cam and no force — serving cache, not capturing", {
1354
- tags: { deviceId },
1355
- meta: {
1356
- hasCache: hit !== void 0,
1357
- ageMs: hit ? now - hit.ts : null
1358
- }
1359
- });
1360
- if (hit) return hit.data;
1361
- return await this.courtesyImage(deviceId, "sleeping", deviceName);
1362
- }
1363
1428
  const flightKey = `${deviceId}:${effectiveStreamId ?? "auto"}`;
1364
1429
  const flight = this.captureFlight.run(flightKey, () => this.captureFresh({
1365
1430
  deviceId,
@@ -1478,6 +1543,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1478
1543
  if (native) {
1479
1544
  const result = await this.nativePool.run(() => native.getSnapshot(input));
1480
1545
  if (result) {
1546
+ this.stateFrames.delete(deviceId);
1481
1547
  this.cache.set(deviceId, effectiveStreamId, {
1482
1548
  data: result,
1483
1549
  ts: now,
@@ -1512,6 +1578,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1512
1578
  if (!(isBatteryDevice && nativeAbsent && !await this.hasStreamingBrokerForDevice(deviceId))) try {
1513
1579
  const fallback = await this.grabFrameFromBroker(deviceId, effectiveStreamId);
1514
1580
  if (fallback) {
1581
+ this.stateFrames.delete(deviceId);
1515
1582
  this.cache.set(deviceId, effectiveStreamId, {
1516
1583
  data: fallback,
1517
1584
  ts: now,
@@ -1665,6 +1732,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1665
1732
  dropDeviceCaches(deviceId) {
1666
1733
  this.cache.deleteDevice(deviceId);
1667
1734
  this.variants.deleteDevice(deviceId);
1735
+ this.stateFrames.delete(deviceId);
1736
+ this.wakingDevices.delete(deviceId);
1668
1737
  this.captureFlight.invalidatePrefix(`${deviceId}:`);
1669
1738
  }
1670
1739
  /**
@@ -1702,38 +1771,64 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1702
1771
  }
1703
1772
  }
1704
1773
  /**
1705
- * Sleep state from the device-state MIRROR, not from a cap round-trip.
1774
+ * Resolve the state that must be visible on a snapshot.
1706
1775
  *
1707
- * `fetchDevice(id).state.battery` is a `SliceHandle` over the hub's
1708
- * runtime-state mirror: a local read, no RPC, no failure branch to get
1709
- * wrong. It is the same mirror that feeds the battery badge elsewhere
1710
- * in the UI, so if the operator can see "sleeping" on screen, this
1711
- * sees it too. (Both spec harnesses already model exactly this wiring
1712
- * — like the orphaned doc comment above, it outlived the gate it was
1713
- * built for.)
1776
+ * Uses the same two canonical sources as wake-on-play:
1777
+ * 1. `deviceManager.getDevice` for the registry's BatteryOperated feature;
1778
+ * 2. `deviceState.getCapSlice('battery')` for the durable runtime mirror.
1714
1779
  *
1715
- * `undefined` slice the device has no battery state we know of →
1716
- * treated as awake. The caller has already established the device is
1717
- * battery-operated from its feature flags, so this only decides
1718
- * WHETHER IT IS ASLEEP, never whether it has a battery.
1780
+ * A valid battery slice is itself positive evidence that the device is
1781
+ * battery-operated. That closes the field failure where "Baby monitor" had a
1782
+ * sleeping battery slice but a stale persisted feature projection, so the
1783
+ * snapshot wrapper classified it as mains and a Viewer thumbnail logged in
1784
+ * to the sleeping camera. The reverse is deliberately not guessed: no
1785
+ * feature + no valid slice remains a mains/unknown camera and follows the
1786
+ * ordinary capture path.
1719
1787
  *
1720
- * Only consulted on the path that would otherwise CAPTURE — a fresh
1721
- * cache hit returns before we get here.
1788
+ * Once battery-operated is established, a missing slice fails safe toward
1789
+ * `sleeping` through the shared `deriveBatteryPresence` function. This avoids
1790
+ * a background wake without inventing an `unreachable` fault.
1722
1791
  */
1723
- async isDeviceSleeping(deviceId) {
1792
+ async resolveSnapshotState(deviceId, meta) {
1793
+ if (meta?.disabled === true) return {
1794
+ isBattery: meta.isBattery,
1795
+ reason: "disabled"
1796
+ };
1797
+ const waking = this.isDeviceWaking(deviceId);
1798
+ let batteryStatus = null;
1724
1799
  try {
1725
- const handle = (await this.ctx.fetchDevice(deviceId)).state.battery;
1726
- await handle.refresh();
1727
- const raw = handle.value;
1800
+ const raw = await this.ctx.api.deviceState.getCapSlice.query({
1801
+ deviceId,
1802
+ capName: batteryCapability.name
1803
+ });
1728
1804
  const parsed = BatteryStatusSchema.safeParse(raw);
1729
- return parsed.success && parsed.data.sleeping;
1805
+ if (parsed.success) batteryStatus = parsed.data;
1730
1806
  } catch (err) {
1731
- this.ctx.logger.warn("snapshot: battery mirror read failed — assuming awake", {
1807
+ this.ctx.logger.debug("snapshot: battery runtime-state read failed", {
1732
1808
  tags: { deviceId },
1733
1809
  meta: { error: errMsg(err) }
1734
1810
  });
1735
- return false;
1736
1811
  }
1812
+ if (!(meta?.isBattery === true || batteryStatus !== null || waking)) return { isBattery: false };
1813
+ if (waking) return {
1814
+ isBattery: true,
1815
+ reason: "waking"
1816
+ };
1817
+ const presence = deriveBatteryPresence({
1818
+ status: batteryStatus,
1819
+ nowMs: Date.now()
1820
+ });
1821
+ return presence === "awake" ? { isBattery: true } : {
1822
+ isBattery: true,
1823
+ reason: presence
1824
+ };
1825
+ }
1826
+ isDeviceWaking(deviceId) {
1827
+ const startedAt = this.wakingDevices.get(deviceId);
1828
+ if (startedAt === void 0) return false;
1829
+ if (Date.now() - startedAt <= SNAPSHOT_WAKING_TTL_MS) return true;
1830
+ this.wakingDevices.delete(deviceId);
1831
+ return false;
1737
1832
  }
1738
1833
  /**
1739
1834
  * True when at least one of the device's brokers is actively
@@ -1909,12 +2004,48 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1909
2004
  };
1910
2005
  }
1911
2006
  /**
2007
+ * Render a state-labelled answer. A cached real frame is the background, not
2008
+ * the answer by itself; if it cannot be decoded, fall back to the generated
2009
+ * courtesy background rather than leaking an unlabelled stale frame.
2010
+ */
2011
+ async stateImage(deviceId, reason, deviceName, hit) {
2012
+ const resolvedName = deviceName ?? `#${String(deviceId)}`;
2013
+ if (!hit) return await this.courtesyImage(deviceId, reason, resolvedName);
2014
+ const key = `${String(hit.ts)}:${hit.streamId ?? "auto"}:${reason}:${resolvedName}`;
2015
+ const deviceFrames = this.stateFrames.get(deviceId) ?? /* @__PURE__ */ new Map();
2016
+ this.stateFrames.set(deviceId, deviceFrames);
2017
+ const cached = deviceFrames.get(key);
2018
+ if (cached !== void 0) return {
2019
+ base64: cached.toString("base64"),
2020
+ contentType: "image/jpeg"
2021
+ };
2022
+ try {
2023
+ const bytes = await renderCourtesyOverlayJpeg(Buffer.from(hit.data.base64, "base64"), {
2024
+ deviceName: resolvedName,
2025
+ reason
2026
+ });
2027
+ deviceFrames.set(key, bytes);
2028
+ return {
2029
+ base64: bytes.toString("base64"),
2030
+ contentType: "image/jpeg"
2031
+ };
2032
+ } catch (err) {
2033
+ this.ctx.logger.warn("snapshot: state overlay failed — using generated courtesy background", {
2034
+ tags: { deviceId },
2035
+ meta: {
2036
+ reason,
2037
+ error: errMsg(err)
2038
+ }
2039
+ });
2040
+ return await this.courtesyImage(deviceId, reason, resolvedName);
2041
+ }
2042
+ }
2043
+ /**
1912
2044
  * Single-trip device lookup against device-manager. Returns the
1913
- * fields the wrapper actually consults name (logging) + battery
1914
- * flag (cache window + broker-fallback gate). Sourced from the
1915
- * device-manager registry rather than the battery cap so the answer
1916
- * survives a momentarily-unreachable provider (the very condition
1917
- * we're trying to be resilient to).
2045
+ * stable registry fields the wrapper consults. Battery classification is
2046
+ * completed by `resolveSnapshotState` from this feature flag PLUS the
2047
+ * canonical runtime-state mirror, so a stale persisted feature projection
2048
+ * cannot authorize a snapshot dial against a known sleeping battery camera.
1918
2049
  *
1919
2050
  * Logged at debug + null return on failure: every call site already
1920
2051
  * has a sensible fallback path (cache hit, conservative default, …),
@@ -1946,7 +2077,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1946
2077
  }
1947
2078
  /** Settings-UI helper — battery flag drives the default max-age in the field description. */
1948
2079
  async isDeviceBattery(deviceId) {
1949
- return (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false;
2080
+ const meta = await this.lookupDeviceMeta(deviceId);
2081
+ return (await this.resolveSnapshotState(deviceId, meta)).isBattery;
1950
2082
  }
1951
2083
  /**
1952
2084
  * Feeds the settings-UI stream picker. Pinned to the ingest owner via
@@ -1,5 +1,15 @@
1
- /** Why a camera cannot produce a frame right now. */
2
- export type CourtesyReason = 'disabled' | 'offline' | 'sleeping';
1
+ /**
2
+ * Why a camera cannot produce a frame right now.
3
+ *
4
+ * `sleeping` and `unreachable` are deliberately two reasons and not one. A
5
+ * battery camera in low-power mode is WORKING — that tile must not read as a
6
+ * fault, which is D62's rule in the third state — while a battery camera
7
+ * nothing has been heard from for hours is a genuine fault the operator has to
8
+ * see (a flat battery, or a camera off the LAN). Painting the second as the
9
+ * first is how a dead camera stayed green for 30 hours. The two are separated
10
+ * by `deriveBatteryPresence`, never by a caller's own guess.
11
+ */
12
+ export type CourtesyReason = 'disabled' | 'offline' | 'sleeping' | 'unreachable' | 'waking';
3
13
  /** Default tile size when the caller asks for no particular width. */
4
14
  export declare const COURTESY_DEFAULT_WIDTH = 640;
5
15
  /** The word the frame carries. Deliberately the operator's vocabulary. */
@@ -25,6 +35,11 @@ export interface CourtesyFrameSpec {
25
35
  * frame read the same, with a floor so a thumbnail stays legible.
26
36
  */
27
37
  export declare function buildCourtesySvg(spec: CourtesyFrameSpec): string;
38
+ /**
39
+ * The same state treatment as a courtesy frame, but translucent so the last
40
+ * real camera frame remains visible underneath it.
41
+ */
42
+ export declare function buildCourtesyOverlaySvg(spec: CourtesyFrameSpec): string;
28
43
  /** Cache key — a courtesy frame is a pure function of these four. */
29
44
  export declare function courtesyCacheKey(spec: CourtesyFrameSpec): string;
30
45
  /**
@@ -34,3 +49,10 @@ export declare function courtesyCacheKey(spec: CourtesyFrameSpec): string;
34
49
  * does, so a broken courtesy path is never mistaken for a broken camera.
35
50
  */
36
51
  export declare function renderCourtesyJpeg(spec: CourtesyFrameSpec): Promise<Buffer>;
52
+ /**
53
+ * Paint the state treatment over a real cached frame.
54
+ *
55
+ * The source dimensions are authoritative: overlays preserve the original
56
+ * frame rather than resizing it to the generated courtesy-frame default.
57
+ */
58
+ export declare function renderCourtesyOverlayJpeg(background: Buffer, spec: Pick<CourtesyFrameSpec, 'deviceName' | 'reason'>): Promise<Buffer>;
@@ -9,6 +9,8 @@ export interface SnapshotMedia {
9
9
  readonly capturedAt: number;
10
10
  /** Effective per-device max cache age (seconds) → `Cache-Control: max-age`. */
11
11
  readonly maxAgeS: number;
12
+ /** State treatment painted over the capture (sleeping, waking, …). */
13
+ readonly variantKey?: string | undefined;
12
14
  /**
13
15
  * The width the returned BYTES are actually at, or undefined when they are the
14
16
  * frame as captured.
@@ -27,6 +29,8 @@ export interface SnapshotPeek {
27
29
  readonly capturedAt: number;
28
30
  /** Effective per-device max cache age (seconds). */
29
31
  readonly maxAgeS: number;
32
+ /** State treatment that would be painted over this capture. */
33
+ readonly variantKey?: string | undefined;
30
34
  }
31
35
  export interface SnapshotMediaHandlerDeps {
32
36
  /**
@@ -91,7 +95,8 @@ export declare function parseSnapshotMediaRequest(url: string): SnapshotMediaReq
91
95
  * The response's entity tag.
92
96
  *
93
97
  * It has to identify the VARIANT, not just the frame: the same capture can now
94
- * be served at several widths and from several streams, and a plain
98
+ * be served at several widths, from several streams, and with a camera-state
99
+ * treatment, and a plain
95
100
  * `"<device>-<capturedAt>"` would let a client that fetched `?w=320` receive a
96
101
  * 304 for `?w=960` and render the small image at full size. The base form is
97
102
  * unchanged for a plain request, so the identity
@@ -106,6 +111,7 @@ export interface SnapshotVariantIdentity {
106
111
  readonly deviceId: number;
107
112
  readonly streamId: string | undefined;
108
113
  readonly width: number | undefined;
114
+ readonly variantKey?: string | undefined;
109
115
  }
110
116
  /**
111
117
  * Create a data-plane handler that serves per-device snapshots as JPEG images.
@@ -60,8 +60,8 @@ export declare const RESIZE_TIMEOUT_MS = 10000;
60
60
  */
61
61
  export declare function resizeJpeg(bytes: Buffer, width: number, timeoutMs?: number): Promise<Buffer>;
62
62
  /**
63
- * Resized frames, keyed by (device, stream, width) AND validated against the
64
- * source frame's timestamp.
63
+ * Resized frames, keyed by (device, stream, width, state treatment) AND
64
+ * validated against the source frame's timestamp.
65
65
  *
66
66
  * The timestamp is the whole correctness argument: a variant outlives nothing.
67
67
  * When the underlying frame is recaptured its `capturedAt` moves, every variant
@@ -75,8 +75,8 @@ export declare class SnapshotVariantCache {
75
75
  private static key;
76
76
  /** The variant for this exact frame, or undefined when it is missing or was
77
77
  * derived from an older capture. */
78
- get(deviceId: number, streamId: string | undefined, width: number, sourceTs: number): Buffer | undefined;
79
- set(deviceId: number, streamId: string | undefined, width: number, sourceTs: number, bytes: Buffer): void;
78
+ get(deviceId: number, streamId: string | undefined, width: number, sourceTs: number, variantKey?: string): Buffer | undefined;
79
+ set(deviceId: number, streamId: string | undefined, width: number, sourceTs: number, bytes: Buffer, variantKey?: string): void;
80
80
  deleteDevice(deviceId: number): void;
81
81
  clear(): void;
82
82
  }