@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
@@ -1,4 +1,4 @@
1
- import { Lt as nodePin, St as DeviceFeature, Wt as EventCategory, _t as errMsg, i as BatteryStatusSchema, j as deriveBatteryPresence, lt as snapshotCapability, pt as streamQualityLabel, vt as BaseAddon, w as bareAddonId, wt as DeviceType } from "../../dist-F1XDQDE1.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`
@@ -294,6 +294,7 @@ function courtesyLabel(reason) {
294
294
  case "offline": return "Offline";
295
295
  case "sleeping": return "Sleeping";
296
296
  case "unreachable": return "Unreachable";
297
+ case "waking": return "Waking";
297
298
  }
298
299
  }
299
300
  /**
@@ -305,6 +306,7 @@ function courtesyBackground(reason) {
305
306
  case "disabled": return "#2b2b31";
306
307
  case "offline": return "#3a2b2b";
307
308
  case "sleeping": return "#232b3a";
309
+ case "waking": return "#243247";
308
310
  case "unreachable": return "#3a2b2b";
309
311
  }
310
312
  }
@@ -320,15 +322,27 @@ function escapeCourtesyText(value) {
320
322
  * frame read the same, with a floor so a thumbnail stays legible.
321
323
  */
322
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) {
323
335
  const stateSize = Math.max(12, Math.round(spec.width / 12));
324
336
  const nameSize = Math.max(9, Math.round(spec.width / 26));
325
337
  const state = escapeCourtesyText(courtesyLabel(spec.reason));
326
338
  const name = escapeCourtesyText(spec.deviceName);
327
339
  const midY = spec.height / 2;
340
+ const washOpacity = overlay ? "0.62" : "1";
341
+ const textStroke = Math.max(1, Math.round(spec.width / 320));
328
342
  return [
329
343
  `<svg xmlns="http://www.w3.org/2000/svg" width="${String(spec.width)}" height="${String(spec.height)}">`,
330
- `<rect width="100%" height="100%" fill="${courtesyBackground(spec.reason)}"/>`,
331
- `<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)}">`,
332
346
  `<text x="50%" y="${String(Math.round(midY))}" font-size="${String(stateSize)}" fill="#e8e8ee">${state}</text>`,
333
347
  `<text x="50%" y="${String(Math.round(midY + stateSize))}" font-size="${String(nameSize)}" fill="#9a9aa8">${name}</text>`,
334
348
  `</g></svg>`
@@ -350,6 +364,24 @@ function renderCourtesyJpeg(spec) {
350
364
  return bytes;
351
365
  });
352
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
+ }
353
385
  //#endregion
354
386
  //#region src/builtins/snapshot/snapshot-link-url.ts
355
387
  /**
@@ -543,7 +575,8 @@ function parseSnapshotMediaRequest(url) {
543
575
  * The response's entity tag.
544
576
  *
545
577
  * It has to identify the VARIANT, not just the frame: the same capture can now
546
- * 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
547
580
  * `"<device>-<capturedAt>"` would let a client that fetched `?w=320` receive a
548
581
  * 304 for `?w=960` and render the small image at full size. The base form is
549
582
  * unchanged for a plain request, so the identity
@@ -554,6 +587,7 @@ function snapshotEtag(variant, capturedAt) {
554
587
  const parts = [`${String(variant.deviceId)}-${String(capturedAt)}`];
555
588
  if (variant.streamId !== void 0) parts.push(`s${variant.streamId}`);
556
589
  if (variant.width !== void 0) parts.push(`w${String(variant.width)}`);
590
+ if (variant.variantKey !== void 0) parts.push(`v${variant.variantKey}`);
557
591
  return `"${parts.join("-")}"`;
558
592
  }
559
593
  /**
@@ -584,7 +618,8 @@ function createSnapshotMediaHandler(deps) {
584
618
  const peekedEtag = snapshotEtag({
585
619
  deviceId: parsed.deviceId,
586
620
  streamId: parsed.streamId,
587
- width: parsed.width
621
+ width: parsed.width,
622
+ variantKey: peeked.variantKey
588
623
  }, peeked.capturedAt);
589
624
  if (inm === peekedEtag) {
590
625
  res.writeHead(304, {
@@ -615,7 +650,8 @@ function createSnapshotMediaHandler(deps) {
615
650
  const etag = snapshotEtag({
616
651
  deviceId: parsed.deviceId,
617
652
  streamId: parsed.streamId,
618
- width: "servedWidth" in media ? media.servedWidth : parsed.width
653
+ width: "servedWidth" in media ? media.servedWidth : parsed.width,
654
+ variantKey: media.variantKey
619
655
  }, media.capturedAt);
620
656
  const cacheControl = `private, max-age=${Math.max(0, Math.floor(media.maxAgeS))}`;
621
657
  if (inm === etag) {
@@ -718,8 +754,8 @@ function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
718
754
  });
719
755
  }
720
756
  /**
721
- * Resized frames, keyed by (device, stream, width) AND validated against the
722
- * source frame's timestamp.
757
+ * Resized frames, keyed by (device, stream, width, state treatment) AND
758
+ * validated against the source frame's timestamp.
723
759
  *
724
760
  * The timestamp is the whole correctness argument: a variant outlives nothing.
725
761
  * When the underlying frame is recaptured its `capturedAt` moves, every variant
@@ -730,17 +766,17 @@ function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
730
766
  var SnapshotVariantCache = class SnapshotVariantCache {
731
767
  byKey = /* @__PURE__ */ new Map();
732
768
  keysByDevice = /* @__PURE__ */ new Map();
733
- static key(deviceId, streamId, width) {
734
- return `${deviceId}:${streamId ?? "auto"}:${width}`;
769
+ static key(deviceId, streamId, width, variantKey) {
770
+ return `${deviceId}:${streamId ?? "auto"}:${width}:${variantKey ?? "raw"}`;
735
771
  }
736
772
  /** The variant for this exact frame, or undefined when it is missing or was
737
773
  * derived from an older capture. */
738
- get(deviceId, streamId, width, sourceTs) {
739
- 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));
740
776
  return entry !== void 0 && entry.sourceTs === sourceTs ? entry.bytes : void 0;
741
777
  }
742
- set(deviceId, streamId, width, sourceTs, bytes) {
743
- const key = SnapshotVariantCache.key(deviceId, streamId, width);
778
+ set(deviceId, streamId, width, sourceTs, bytes, variantKey) {
779
+ const key = SnapshotVariantCache.key(deviceId, streamId, width, variantKey);
744
780
  this.byKey.set(key, {
745
781
  bytes,
746
782
  sourceTs
@@ -781,6 +817,9 @@ var LINK_MINT_DEADLINE_MS = 2500;
781
817
  var LINK_BATCH_WAIT_MS = 750;
782
818
  /** Frames older than this are never represented as current by the Viewer. */
783
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;
784
823
  /** Default cache window for non-battery cams (seconds). 10s feels live. */
785
824
  var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
786
825
  /** Default cache window for battery cams (seconds). 1h ≈ "don't wake the cam unless asked". */
@@ -825,9 +864,14 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
825
864
  * A courtesy frame is a pure function of those three, so it is rendered once
826
865
  * and reused for as long as the process lives — a disabled camera must not
827
866
  * cost an ffmpeg run per poll. Unbounded on purpose: the key space is
828
- * (3 reasons × this node's own cameras), not user input.
867
+ * (state reasons × this node's own cameras), not user input.
829
868
  */
830
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();
831
875
  /**
832
876
  * De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
833
877
  * settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
@@ -899,6 +943,12 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
899
943
  const deviceId = event.data.deviceId;
900
944
  if (typeof deviceId === "number") this.evictRemovedDevice(deviceId, "device-unregistered");
901
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
+ });
902
952
  await this.serveMediaDataPlane();
903
953
  await this.ensureLinkDataPlane();
904
954
  return [{
@@ -1160,13 +1210,12 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1160
1210
  }
1161
1211
  });
1162
1212
  }
1163
- /** True only for a BATTERY device that is currently asleep. Kept as one
1164
- * question so the link path cannot accidentally treat "battery" as "asleep"
1165
- * and stop refreshing a camera that is awake and streaming. */
1213
+ /** True only for a battery device that must not be snapshot-refreshed. */
1166
1214
  async isSleepingBatteryDevice(deviceId) {
1167
1215
  try {
1168
- if ((await this.lookupDeviceMeta(deviceId))?.isBattery !== true) return false;
1169
- 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";
1170
1219
  } catch {
1171
1220
  return false;
1172
1221
  }
@@ -1180,28 +1229,31 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1180
1229
  * effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
1181
1230
  */
1182
1231
  async resolveSnapshotMedia(deviceId, streamId, force, width) {
1232
+ const resolutionMeta = {};
1183
1233
  const image = await this.getSnapshot({
1184
1234
  deviceId,
1185
1235
  ...streamId !== void 0 ? { streamId } : {},
1186
1236
  force
1187
- });
1237
+ }, 0, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, resolutionMeta);
1188
1238
  if (!image) return null;
1189
1239
  const capturedAt = this.cache.get(deviceId, streamId)?.ts ?? Date.now();
1190
1240
  const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
1191
- const isBattery = (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false;
1241
+ const isBattery = resolutionMeta.isBattery ?? false;
1192
1242
  if (width === void 0) return {
1193
1243
  bytes: Buffer.from(image.base64, "base64"),
1194
1244
  contentType: image.contentType,
1195
1245
  capturedAt,
1196
1246
  maxAgeS: effectiveMaxAgeS(prefs, isBattery),
1247
+ variantKey: resolutionMeta.stateReason,
1197
1248
  servedWidth: void 0
1198
1249
  };
1199
- 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);
1200
1251
  return {
1201
1252
  bytes: thumb.bytes,
1202
1253
  contentType: image.contentType,
1203
1254
  capturedAt,
1204
1255
  maxAgeS: effectiveMaxAgeS(prefs, isBattery),
1256
+ variantKey: resolutionMeta.stateReason,
1205
1257
  servedWidth: thumb.width
1206
1258
  };
1207
1259
  }
@@ -1217,24 +1269,28 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1217
1269
  async peekFreshFrame(deviceId, streamId) {
1218
1270
  const hit = this.cache.get(deviceId, streamId);
1219
1271
  if (!hit) return null;
1220
- 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);
1221
1276
  if (Date.now() - hit.ts >= maxAgeS * 1e3) return null;
1222
1277
  return {
1223
1278
  capturedAt: hit.ts,
1224
- maxAgeS
1279
+ maxAgeS,
1280
+ variantKey: state.reason
1225
1281
  };
1226
1282
  }
1227
1283
  /**
1228
- * The frame at a card-sized width, derived once per (device, stream, width)
1229
- * per capture.
1284
+ * The frame at a card-sized width, derived once per
1285
+ * (device, stream, width, state treatment) per capture.
1230
1286
  *
1231
1287
  * A failed resize falls back to the FULL frame — a visible card beats a
1232
1288
  * broken one — but never silently: the card would otherwise keep costing a
1233
1289
  * megabyte with nothing anywhere saying why, which is the shape of the bug
1234
1290
  * this whole change came from.
1235
1291
  */
1236
- async thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs) {
1237
- 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);
1238
1294
  if (cached !== void 0) return {
1239
1295
  bytes: cached,
1240
1296
  width
@@ -1242,7 +1298,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1242
1298
  const full = Buffer.from(image.base64, "base64");
1243
1299
  try {
1244
1300
  const resized = await this.resize(full, width);
1245
- this.variants.set(deviceId, streamId, width, capturedAt, resized);
1301
+ this.variants.set(deviceId, streamId, width, capturedAt, resized, variantKey);
1246
1302
  if (prefs.snapshotDebug) this.ctx.logger.info("snapshot: thumbnail derived", {
1247
1303
  tags: { deviceId },
1248
1304
  meta: {
@@ -1281,6 +1337,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1281
1337
  }
1282
1338
  this.cache.clear();
1283
1339
  this.variants.clear();
1340
+ this.stateFrames.clear();
1341
+ this.wakingDevices.clear();
1284
1342
  this.captureFlight.clear();
1285
1343
  this.ownerCache = null;
1286
1344
  }
@@ -1313,18 +1371,16 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1313
1371
  })]
1314
1372
  }] });
1315
1373
  }
1316
- 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) {
1317
1375
  const { deviceId } = input;
1318
1376
  const force = input.force === true;
1319
1377
  const meta = await this.lookupDeviceMeta(deviceId);
1320
1378
  const deviceName = meta?.name;
1321
- const isBatteryDevice = meta?.isBattery ?? false;
1322
1379
  const log = this.ctx.logger.withTags({
1323
1380
  deviceId,
1324
1381
  ...deviceName ? { deviceName } : {}
1325
1382
  });
1326
1383
  const now = Date.now();
1327
- if (meta?.disabled === true) return await this.courtesyImage(deviceId, "disabled", deviceName);
1328
1384
  const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
1329
1385
  const rawPref = prefs.snapshotStreamId;
1330
1386
  const effectiveStreamId = input.streamId ?? (rawPref && rawPref !== "auto" ? rawPref : void 0);
@@ -1333,6 +1389,24 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1333
1389
  meta: { stream: effectiveStreamId ?? "auto" }
1334
1390
  });
1335
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
+ }
1336
1410
  const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3));
1337
1411
  const decision = decideSnapshotServe({
1338
1412
  now,
@@ -1351,19 +1425,6 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1351
1425
  });
1352
1426
  return hit ? hit.data : null;
1353
1427
  }
1354
- const presence = isBatteryDevice ? await this.readBatteryPresence(deviceId) : "awake";
1355
- if (!force && presence !== "awake") {
1356
- log.debug("snapshot: battery cam not awake and no force — serving cache, not capturing", {
1357
- tags: { deviceId },
1358
- meta: {
1359
- presence,
1360
- hasCache: hit !== void 0,
1361
- ageMs: hit ? now - hit.ts : null
1362
- }
1363
- });
1364
- if (hit) return hit.data;
1365
- return await this.courtesyImage(deviceId, presence, deviceName);
1366
- }
1367
1428
  const flightKey = `${deviceId}:${effectiveStreamId ?? "auto"}`;
1368
1429
  const flight = this.captureFlight.run(flightKey, () => this.captureFresh({
1369
1430
  deviceId,
@@ -1482,6 +1543,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1482
1543
  if (native) {
1483
1544
  const result = await this.nativePool.run(() => native.getSnapshot(input));
1484
1545
  if (result) {
1546
+ this.stateFrames.delete(deviceId);
1485
1547
  this.cache.set(deviceId, effectiveStreamId, {
1486
1548
  data: result,
1487
1549
  ts: now,
@@ -1516,6 +1578,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1516
1578
  if (!(isBatteryDevice && nativeAbsent && !await this.hasStreamingBrokerForDevice(deviceId))) try {
1517
1579
  const fallback = await this.grabFrameFromBroker(deviceId, effectiveStreamId);
1518
1580
  if (fallback) {
1581
+ this.stateFrames.delete(deviceId);
1519
1582
  this.cache.set(deviceId, effectiveStreamId, {
1520
1583
  data: fallback,
1521
1584
  ts: now,
@@ -1669,6 +1732,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1669
1732
  dropDeviceCaches(deviceId) {
1670
1733
  this.cache.deleteDevice(deviceId);
1671
1734
  this.variants.deleteDevice(deviceId);
1735
+ this.stateFrames.delete(deviceId);
1736
+ this.wakingDevices.delete(deviceId);
1672
1737
  this.captureFlight.invalidatePrefix(`${deviceId}:`);
1673
1738
  }
1674
1739
  /**
@@ -1706,55 +1771,64 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1706
1771
  }
1707
1772
  }
1708
1773
  /**
1709
- * Sleep state from the device-state MIRROR, not from a cap round-trip.
1774
+ * Resolve the state that must be visible on a snapshot.
1710
1775
  *
1711
- * `fetchDevice(id).state.battery` is a `SliceHandle` over the hub's
1712
- * runtime-state mirror: a local read, no RPC, no failure branch to get
1713
- * wrong. It is the same mirror that feeds the battery badge elsewhere
1714
- * in the UI, so if the operator can see "sleeping" on screen, this
1715
- * sees it too. (Both spec harnesses already model exactly this wiring
1716
- * — like the orphaned doc comment above, it outlived the gate it was
1717
- * 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.
1718
1779
  *
1719
- * `undefined` slice the device has no battery state we know of →
1720
- * treated as awake. The caller has already established the device is
1721
- * battery-operated from its feature flags, so this only decides
1722
- * 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.
1723
1787
  *
1724
- * Only consulted on the path that would otherwise CAPTURE — a fresh
1725
- * 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.
1726
1791
  */
1727
- async isDeviceSleeping(deviceId) {
1728
- return await this.readBatteryPresence(deviceId) !== "awake";
1729
- }
1730
- /**
1731
- * The device's three-valued battery presence (D173) — `awake`, `sleeping`,
1732
- * or `unreachable`. Same mirror read as the sleep gate, one derivation
1733
- * (`deriveBatteryPresence`) so this layer cannot disagree with the badge or
1734
- * the stream error about what a camera is doing.
1735
- *
1736
- * A failed read answers `awake`: unknown must never become a fault badge,
1737
- * and "assume awake" is the pre-existing, deliberately conservative failure
1738
- * direction of the gate this replaces.
1739
- */
1740
- async readBatteryPresence(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;
1741
1799
  try {
1742
- const handle = (await this.ctx.fetchDevice(deviceId)).state.battery;
1743
- await handle.refresh();
1744
- const raw = handle.value;
1745
- const parsed = BatteryStatusSchema.safeParse(raw);
1746
- if (!parsed.success) return "awake";
1747
- return deriveBatteryPresence({
1748
- status: parsed.data,
1749
- nowMs: Date.now()
1800
+ const raw = await this.ctx.api.deviceState.getCapSlice.query({
1801
+ deviceId,
1802
+ capName: batteryCapability.name
1750
1803
  });
1804
+ const parsed = BatteryStatusSchema.safeParse(raw);
1805
+ if (parsed.success) batteryStatus = parsed.data;
1751
1806
  } catch (err) {
1752
- this.ctx.logger.warn("snapshot: battery mirror read failed — assuming awake", {
1807
+ this.ctx.logger.debug("snapshot: battery runtime-state read failed", {
1753
1808
  tags: { deviceId },
1754
1809
  meta: { error: errMsg(err) }
1755
1810
  });
1756
- return "awake";
1757
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;
1758
1832
  }
1759
1833
  /**
1760
1834
  * True when at least one of the device's brokers is actively
@@ -1930,12 +2004,48 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1930
2004
  };
1931
2005
  }
1932
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
+ /**
1933
2044
  * Single-trip device lookup against device-manager. Returns the
1934
- * fields the wrapper actually consults name (logging) + battery
1935
- * flag (cache window + broker-fallback gate). Sourced from the
1936
- * device-manager registry rather than the battery cap so the answer
1937
- * survives a momentarily-unreachable provider (the very condition
1938
- * 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.
1939
2049
  *
1940
2050
  * Logged at debug + null return on failure: every call site already
1941
2051
  * has a sensible fallback path (cache hit, conservative default, …),
@@ -1967,7 +2077,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1967
2077
  }
1968
2078
  /** Settings-UI helper — battery flag drives the default max-age in the field description. */
1969
2079
  async isDeviceBattery(deviceId) {
1970
- return (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false;
2080
+ const meta = await this.lookupDeviceMeta(deviceId);
2081
+ return (await this.resolveSnapshotState(deviceId, meta)).isBattery;
1971
2082
  }
1972
2083
  /**
1973
2084
  * Feeds the settings-UI stream picker. Pinned to the ingest owner via
@@ -9,7 +9,7 @@
9
9
  * first is how a dead camera stayed green for 30 hours. The two are separated
10
10
  * by `deriveBatteryPresence`, never by a caller's own guess.
11
11
  */
12
- export type CourtesyReason = 'disabled' | 'offline' | 'sleeping' | 'unreachable';
12
+ export type CourtesyReason = 'disabled' | 'offline' | 'sleeping' | 'unreachable' | 'waking';
13
13
  /** Default tile size when the caller asks for no particular width. */
14
14
  export declare const COURTESY_DEFAULT_WIDTH = 640;
15
15
  /** The word the frame carries. Deliberately the operator's vocabulary. */
@@ -35,6 +35,11 @@ export interface CourtesyFrameSpec {
35
35
  * frame read the same, with a floor so a thumbnail stays legible.
36
36
  */
37
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;
38
43
  /** Cache key — a courtesy frame is a pure function of these four. */
39
44
  export declare function courtesyCacheKey(spec: CourtesyFrameSpec): string;
40
45
  /**
@@ -44,3 +49,10 @@ export declare function courtesyCacheKey(spec: CourtesyFrameSpec): string;
44
49
  * does, so a broken courtesy path is never mistaken for a broken camera.
45
50
  */
46
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
  }