@camstack/system 1.2.93 → 1.2.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +218 -102
  34. package/dist/builtins/snapshot/index.mjs +218 -102
  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 +37 -38
  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-BQoU9nGS.js} +16 -1
  50. package/dist/{dist-F1XDQDE1.mjs → dist-hKJyHgFg.mjs} +11 -2
  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-hKJyHgFg.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 [{
@@ -1099,11 +1149,11 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1099
1149
  const expMs = snapshotLinkExpiry(Date.now(), SNAPSHOT_LINK_TTL_MS);
1100
1150
  const served = this.linkDataPlane !== null && secret !== null;
1101
1151
  const prefix = this.linkRoutePrefix();
1102
- const sleepingByDevice = /* @__PURE__ */ new Map();
1152
+ const stateByDevice = /* @__PURE__ */ new Map();
1103
1153
  const refreshes = input.targets.map(async (target) => {
1104
- const sleeping = await this.isSleepingBatteryDevice(target.deviceId);
1105
- sleepingByDevice.set(target.deviceId, sleeping);
1106
- if (!sleeping) await this.awaitFreshEnough(target.deviceId);
1154
+ const state = await this.snapshotStateForLink(target.deviceId);
1155
+ stateByDevice.set(target.deviceId, state);
1156
+ if (state.reason === void 0) await this.awaitFreshEnough(target.deviceId);
1107
1157
  });
1108
1158
  await raceForResult(Promise.allSettled(refreshes), LINK_BATCH_WAIT_MS);
1109
1159
  const rows = input.targets.map((target) => {
@@ -1126,7 +1176,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1126
1176
  expiresAt: served ? expMs : null,
1127
1177
  width: width ?? null,
1128
1178
  neverCaptured: capturedAt === null,
1129
- sleeping: sleepingByDevice.get(deviceId) ?? false
1179
+ sleeping: stateByDevice.get(deviceId)?.reason === "sleeping",
1180
+ stateReason: stateByDevice.get(deviceId)?.reason ?? null
1130
1181
  };
1131
1182
  });
1132
1183
  this.ctx.logger.debug("snapshot: minted links", { meta: {
@@ -1160,15 +1211,18 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1160
1211
  }
1161
1212
  });
1162
1213
  }
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. */
1166
- async isSleepingBatteryDevice(deviceId) {
1214
+ /** Resolve the state advertised by a minted link before deciding whether a
1215
+ * background capture is allowed. */
1216
+ async snapshotStateForLink(deviceId) {
1167
1217
  try {
1168
- if ((await this.lookupDeviceMeta(deviceId))?.isBattery !== true) return false;
1169
- return await this.isDeviceSleeping(deviceId);
1170
- } catch {
1171
- return false;
1218
+ const meta = await this.lookupDeviceMeta(deviceId);
1219
+ return await this.resolveSnapshotState(deviceId, meta);
1220
+ } catch (err) {
1221
+ this.ctx.logger.debug("snapshot: link state resolution failed", {
1222
+ tags: { deviceId },
1223
+ meta: { error: errMsg(err) }
1224
+ });
1225
+ return { isBattery: false };
1172
1226
  }
1173
1227
  }
1174
1228
  /**
@@ -1180,28 +1234,31 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1180
1234
  * effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
1181
1235
  */
1182
1236
  async resolveSnapshotMedia(deviceId, streamId, force, width) {
1237
+ const resolutionMeta = {};
1183
1238
  const image = await this.getSnapshot({
1184
1239
  deviceId,
1185
1240
  ...streamId !== void 0 ? { streamId } : {},
1186
1241
  force
1187
- });
1242
+ }, 0, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, resolutionMeta);
1188
1243
  if (!image) return null;
1189
1244
  const capturedAt = this.cache.get(deviceId, streamId)?.ts ?? Date.now();
1190
1245
  const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
1191
- const isBattery = (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false;
1246
+ const isBattery = resolutionMeta.isBattery ?? false;
1192
1247
  if (width === void 0) return {
1193
1248
  bytes: Buffer.from(image.base64, "base64"),
1194
1249
  contentType: image.contentType,
1195
1250
  capturedAt,
1196
1251
  maxAgeS: effectiveMaxAgeS(prefs, isBattery),
1252
+ variantKey: resolutionMeta.stateReason,
1197
1253
  servedWidth: void 0
1198
1254
  };
1199
- const thumb = await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs);
1255
+ const thumb = await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs, resolutionMeta.stateReason);
1200
1256
  return {
1201
1257
  bytes: thumb.bytes,
1202
1258
  contentType: image.contentType,
1203
1259
  capturedAt,
1204
1260
  maxAgeS: effectiveMaxAgeS(prefs, isBattery),
1261
+ variantKey: resolutionMeta.stateReason,
1205
1262
  servedWidth: thumb.width
1206
1263
  };
1207
1264
  }
@@ -1217,24 +1274,28 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1217
1274
  async peekFreshFrame(deviceId, streamId) {
1218
1275
  const hit = this.cache.get(deviceId, streamId);
1219
1276
  if (!hit) return null;
1220
- const maxAgeS = effectiveMaxAgeS(await this.readDeviceSettings(deviceId).catch(() => ({})), (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false);
1277
+ const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
1278
+ const meta = await this.lookupDeviceMeta(deviceId);
1279
+ const state = await this.resolveSnapshotState(deviceId, meta);
1280
+ const maxAgeS = effectiveMaxAgeS(prefs, state.isBattery);
1221
1281
  if (Date.now() - hit.ts >= maxAgeS * 1e3) return null;
1222
1282
  return {
1223
1283
  capturedAt: hit.ts,
1224
- maxAgeS
1284
+ maxAgeS,
1285
+ variantKey: state.reason
1225
1286
  };
1226
1287
  }
1227
1288
  /**
1228
- * The frame at a card-sized width, derived once per (device, stream, width)
1229
- * per capture.
1289
+ * The frame at a card-sized width, derived once per
1290
+ * (device, stream, width, state treatment) per capture.
1230
1291
  *
1231
1292
  * A failed resize falls back to the FULL frame — a visible card beats a
1232
1293
  * broken one — but never silently: the card would otherwise keep costing a
1233
1294
  * megabyte with nothing anywhere saying why, which is the shape of the bug
1234
1295
  * this whole change came from.
1235
1296
  */
1236
- async thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs) {
1237
- const cached = this.variants.get(deviceId, streamId, width, capturedAt);
1297
+ async thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs, variantKey) {
1298
+ const cached = this.variants.get(deviceId, streamId, width, capturedAt, variantKey);
1238
1299
  if (cached !== void 0) return {
1239
1300
  bytes: cached,
1240
1301
  width
@@ -1242,7 +1303,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1242
1303
  const full = Buffer.from(image.base64, "base64");
1243
1304
  try {
1244
1305
  const resized = await this.resize(full, width);
1245
- this.variants.set(deviceId, streamId, width, capturedAt, resized);
1306
+ this.variants.set(deviceId, streamId, width, capturedAt, resized, variantKey);
1246
1307
  if (prefs.snapshotDebug) this.ctx.logger.info("snapshot: thumbnail derived", {
1247
1308
  tags: { deviceId },
1248
1309
  meta: {
@@ -1281,6 +1342,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1281
1342
  }
1282
1343
  this.cache.clear();
1283
1344
  this.variants.clear();
1345
+ this.stateFrames.clear();
1346
+ this.wakingDevices.clear();
1284
1347
  this.captureFlight.clear();
1285
1348
  this.ownerCache = null;
1286
1349
  }
@@ -1313,18 +1376,16 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1313
1376
  })]
1314
1377
  }] });
1315
1378
  }
1316
- async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY, maximumCaptureWaitMs = Number.POSITIVE_INFINITY) {
1379
+ async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY, maximumCaptureWaitMs = Number.POSITIVE_INFINITY, resolutionMeta) {
1317
1380
  const { deviceId } = input;
1318
1381
  const force = input.force === true;
1319
1382
  const meta = await this.lookupDeviceMeta(deviceId);
1320
1383
  const deviceName = meta?.name;
1321
- const isBatteryDevice = meta?.isBattery ?? false;
1322
1384
  const log = this.ctx.logger.withTags({
1323
1385
  deviceId,
1324
1386
  ...deviceName ? { deviceName } : {}
1325
1387
  });
1326
1388
  const now = Date.now();
1327
- if (meta?.disabled === true) return await this.courtesyImage(deviceId, "disabled", deviceName);
1328
1389
  const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
1329
1390
  const rawPref = prefs.snapshotStreamId;
1330
1391
  const effectiveStreamId = input.streamId ?? (rawPref && rawPref !== "auto" ? rawPref : void 0);
@@ -1333,6 +1394,24 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1333
1394
  meta: { stream: effectiveStreamId ?? "auto" }
1334
1395
  });
1335
1396
  const hit = this.cache.get(deviceId, effectiveStreamId);
1397
+ const state = await this.resolveSnapshotState(deviceId, meta);
1398
+ const isBatteryDevice = state.isBattery;
1399
+ if (resolutionMeta) {
1400
+ resolutionMeta.isBattery = isBatteryDevice;
1401
+ delete resolutionMeta.stateReason;
1402
+ }
1403
+ if (state.reason !== void 0 && (!force || state.reason === "disabled" || state.reason === "unreachable" || state.reason === "waking")) {
1404
+ if (resolutionMeta) resolutionMeta.stateReason = state.reason;
1405
+ log.debug("snapshot: camera state overrides raw cache", {
1406
+ tags: { deviceId },
1407
+ meta: {
1408
+ reason: state.reason,
1409
+ hasCache: hit !== void 0,
1410
+ ageMs: hit ? now - hit.ts : null
1411
+ }
1412
+ });
1413
+ return await this.stateImage(deviceId, state.reason, deviceName, hit);
1414
+ }
1336
1415
  const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3));
1337
1416
  const decision = decideSnapshotServe({
1338
1417
  now,
@@ -1351,19 +1430,6 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1351
1430
  });
1352
1431
  return hit ? hit.data : null;
1353
1432
  }
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
1433
  const flightKey = `${deviceId}:${effectiveStreamId ?? "auto"}`;
1368
1434
  const flight = this.captureFlight.run(flightKey, () => this.captureFresh({
1369
1435
  deviceId,
@@ -1482,6 +1548,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1482
1548
  if (native) {
1483
1549
  const result = await this.nativePool.run(() => native.getSnapshot(input));
1484
1550
  if (result) {
1551
+ this.stateFrames.delete(deviceId);
1485
1552
  this.cache.set(deviceId, effectiveStreamId, {
1486
1553
  data: result,
1487
1554
  ts: now,
@@ -1516,6 +1583,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1516
1583
  if (!(isBatteryDevice && nativeAbsent && !await this.hasStreamingBrokerForDevice(deviceId))) try {
1517
1584
  const fallback = await this.grabFrameFromBroker(deviceId, effectiveStreamId);
1518
1585
  if (fallback) {
1586
+ this.stateFrames.delete(deviceId);
1519
1587
  this.cache.set(deviceId, effectiveStreamId, {
1520
1588
  data: fallback,
1521
1589
  ts: now,
@@ -1669,6 +1737,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1669
1737
  dropDeviceCaches(deviceId) {
1670
1738
  this.cache.deleteDevice(deviceId);
1671
1739
  this.variants.deleteDevice(deviceId);
1740
+ this.stateFrames.delete(deviceId);
1741
+ this.wakingDevices.delete(deviceId);
1672
1742
  this.captureFlight.invalidatePrefix(`${deviceId}:`);
1673
1743
  }
1674
1744
  /**
@@ -1706,55 +1776,64 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1706
1776
  }
1707
1777
  }
1708
1778
  /**
1709
- * Sleep state from the device-state MIRROR, not from a cap round-trip.
1710
- *
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.)
1779
+ * Resolve the state that must be visible on a snapshot.
1718
1780
  *
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.
1781
+ * Uses the same two canonical sources as wake-on-play:
1782
+ * 1. `deviceManager.getDevice` for the registry's BatteryOperated feature;
1783
+ * 2. `deviceState.getCapSlice('battery')` for the durable runtime mirror.
1723
1784
  *
1724
- * Only consulted on the path that would otherwise CAPTURE a fresh
1725
- * cache hit returns before we get here.
1726
- */
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.
1785
+ * A valid battery slice is itself positive evidence that the device is
1786
+ * battery-operated. That closes the field failure where "Baby monitor" had a
1787
+ * sleeping battery slice but a stale persisted feature projection, so the
1788
+ * snapshot wrapper classified it as mains and a Viewer thumbnail logged in
1789
+ * to the sleeping camera. The reverse is deliberately not guessed: no
1790
+ * feature + no valid slice remains a mains/unknown camera and follows the
1791
+ * ordinary capture path.
1735
1792
  *
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.
1793
+ * Once battery-operated is established, a missing slice fails safe toward
1794
+ * `sleeping` through the shared `deriveBatteryPresence` function. This avoids
1795
+ * a background wake without inventing an `unreachable` fault.
1739
1796
  */
1740
- async readBatteryPresence(deviceId) {
1797
+ async resolveSnapshotState(deviceId, meta) {
1798
+ if (meta?.disabled === true) return {
1799
+ isBattery: meta.isBattery,
1800
+ reason: "disabled"
1801
+ };
1802
+ const waking = this.isDeviceWaking(deviceId);
1803
+ let batteryStatus = null;
1741
1804
  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()
1805
+ const raw = await this.ctx.api.deviceState.getCapSlice.query({
1806
+ deviceId,
1807
+ capName: batteryCapability.name
1750
1808
  });
1809
+ const parsed = BatteryStatusSchema.safeParse(raw);
1810
+ if (parsed.success) batteryStatus = parsed.data;
1751
1811
  } catch (err) {
1752
- this.ctx.logger.warn("snapshot: battery mirror read failed — assuming awake", {
1812
+ this.ctx.logger.debug("snapshot: battery runtime-state read failed", {
1753
1813
  tags: { deviceId },
1754
1814
  meta: { error: errMsg(err) }
1755
1815
  });
1756
- return "awake";
1757
1816
  }
1817
+ if (!(meta?.isBattery === true || batteryStatus !== null || waking)) return { isBattery: false };
1818
+ if (waking) return {
1819
+ isBattery: true,
1820
+ reason: "waking"
1821
+ };
1822
+ const presence = deriveBatteryPresence({
1823
+ status: batteryStatus,
1824
+ nowMs: Date.now()
1825
+ });
1826
+ return presence === "awake" ? { isBattery: true } : {
1827
+ isBattery: true,
1828
+ reason: presence
1829
+ };
1830
+ }
1831
+ isDeviceWaking(deviceId) {
1832
+ const startedAt = this.wakingDevices.get(deviceId);
1833
+ if (startedAt === void 0) return false;
1834
+ if (Date.now() - startedAt <= SNAPSHOT_WAKING_TTL_MS) return true;
1835
+ this.wakingDevices.delete(deviceId);
1836
+ return false;
1758
1837
  }
1759
1838
  /**
1760
1839
  * True when at least one of the device's brokers is actively
@@ -1930,12 +2009,48 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1930
2009
  };
1931
2010
  }
1932
2011
  /**
2012
+ * Render a state-labelled answer. A cached real frame is the background, not
2013
+ * the answer by itself; if it cannot be decoded, fall back to the generated
2014
+ * courtesy background rather than leaking an unlabelled stale frame.
2015
+ */
2016
+ async stateImage(deviceId, reason, deviceName, hit) {
2017
+ const resolvedName = deviceName ?? `#${String(deviceId)}`;
2018
+ if (!hit) return await this.courtesyImage(deviceId, reason, resolvedName);
2019
+ const key = `${String(hit.ts)}:${hit.streamId ?? "auto"}:${reason}:${resolvedName}`;
2020
+ const deviceFrames = this.stateFrames.get(deviceId) ?? /* @__PURE__ */ new Map();
2021
+ this.stateFrames.set(deviceId, deviceFrames);
2022
+ const cached = deviceFrames.get(key);
2023
+ if (cached !== void 0) return {
2024
+ base64: cached.toString("base64"),
2025
+ contentType: "image/jpeg"
2026
+ };
2027
+ try {
2028
+ const bytes = await renderCourtesyOverlayJpeg(Buffer.from(hit.data.base64, "base64"), {
2029
+ deviceName: resolvedName,
2030
+ reason
2031
+ });
2032
+ deviceFrames.set(key, bytes);
2033
+ return {
2034
+ base64: bytes.toString("base64"),
2035
+ contentType: "image/jpeg"
2036
+ };
2037
+ } catch (err) {
2038
+ this.ctx.logger.warn("snapshot: state overlay failed — using generated courtesy background", {
2039
+ tags: { deviceId },
2040
+ meta: {
2041
+ reason,
2042
+ error: errMsg(err)
2043
+ }
2044
+ });
2045
+ return await this.courtesyImage(deviceId, reason, resolvedName);
2046
+ }
2047
+ }
2048
+ /**
1933
2049
  * 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).
2050
+ * stable registry fields the wrapper consults. Battery classification is
2051
+ * completed by `resolveSnapshotState` from this feature flag PLUS the
2052
+ * canonical runtime-state mirror, so a stale persisted feature projection
2053
+ * cannot authorize a snapshot dial against a known sleeping battery camera.
1939
2054
  *
1940
2055
  * Logged at debug + null return on failure: every call site already
1941
2056
  * has a sensible fallback path (cache hit, conservative default, …),
@@ -1967,7 +2082,8 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1967
2082
  }
1968
2083
  /** Settings-UI helper — battery flag drives the default max-age in the field description. */
1969
2084
  async isDeviceBattery(deviceId) {
1970
- return (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false;
2085
+ const meta = await this.lookupDeviceMeta(deviceId);
2086
+ return (await this.resolveSnapshotState(deviceId, meta)).isBattery;
1971
2087
  }
1972
2088
  /**
1973
2089
  * 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.