@camstack/system 1.2.62 → 1.2.64

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 (90) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/addon-utils.js +1 -1
  4. package/dist/addon-utils.mjs +1 -1
  5. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  9. package/dist/builtins/alerts/alerts.addon.js +1 -1
  10. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +3 -3
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
  13. package/dist/builtins/console-logging/index.js +1 -1
  14. package/dist/builtins/console-logging/index.mjs +1 -1
  15. package/dist/builtins/core-blocks/block-package.d.ts +9 -27
  16. package/dist/builtins/core-blocks/block-supervisor.d.ts +15 -0
  17. package/dist/builtins/core-blocks/core-blocks.addon.js +91 -24
  18. package/dist/builtins/core-blocks/core-blocks.addon.mjs +91 -25
  19. package/dist/builtins/device-manager/device-bindings-store.d.ts +28 -5
  20. package/dist/builtins/device-manager/device-linked-devices.d.ts +2 -2
  21. package/dist/builtins/device-manager/device-manager.addon.d.ts +3 -45
  22. package/dist/builtins/device-manager/device-manager.addon.js +123 -884
  23. package/dist/builtins/device-manager/device-manager.addon.mjs +123 -884
  24. package/dist/builtins/device-manager/device-meta-actions.d.ts +0 -8
  25. package/dist/builtins/device-manager/device-meta-store.d.ts +1 -28
  26. package/dist/builtins/device-manager/device-meta-types.d.ts +2 -6
  27. package/dist/builtins/device-manager/device-projection.d.ts +1 -2
  28. package/dist/builtins/device-manager/device-provider-context.d.ts +2 -13
  29. package/dist/builtins/device-manager/device-queries.d.ts +1 -1
  30. package/dist/builtins/device-manager/device-state-mirror.d.ts +8 -49
  31. package/dist/builtins/device-manager/device-sync-resolvers.d.ts +0 -11
  32. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  33. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  34. package/dist/builtins/hub-forwarder/index.js +1 -1
  35. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  36. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  37. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  38. package/dist/builtins/local-auth/homeassistant-integration.d.ts +7 -0
  39. package/dist/builtins/local-auth/index.d.ts +1 -0
  40. package/dist/builtins/local-auth/index.js +1 -0
  41. package/dist/builtins/local-auth/index.mjs +2 -2
  42. package/dist/builtins/local-auth/local-auth.addon.js +561 -497
  43. package/dist/builtins/local-auth/local-auth.addon.mjs +580 -517
  44. package/dist/builtins/local-auth/oauth-grants.d.ts +11 -0
  45. package/dist/builtins/local-auth/oauth-session-manager.d.ts +1 -1
  46. package/dist/builtins/local-network/local-network.addon.js +1 -1
  47. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  48. package/dist/builtins/loki-logging/index.js +1 -1
  49. package/dist/builtins/loki-logging/index.mjs +1 -1
  50. package/dist/builtins/native-metrics/native-metrics.addon.js +2 -2
  51. package/dist/builtins/native-metrics/native-metrics.addon.mjs +2 -2
  52. package/dist/builtins/platform-probe/index.js +2 -2
  53. package/dist/builtins/platform-probe/index.mjs +2 -2
  54. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  55. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  56. package/dist/builtins/snapshot/index.js +319 -20
  57. package/dist/builtins/snapshot/index.mjs +319 -20
  58. package/dist/builtins/snapshot/snapshot-keep-warm.d.ts +86 -0
  59. package/dist/builtins/snapshot/snapshot-media-handler.d.ts +48 -1
  60. package/dist/builtins/snapshot/snapshot.addon.d.ts +65 -1
  61. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  62. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  63. package/dist/builtins/sqlite-storage/retired-settings-keys.d.ts +15 -0
  64. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +73 -7
  65. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +73 -7
  66. package/dist/builtins/sqlite-storage/vector-index-vec.d.ts +19 -0
  67. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  68. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  69. package/dist/builtins/system-config/system-config.addon.js +1 -1
  70. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  71. package/dist/builtins/winston-logging/index.js +1 -1
  72. package/dist/builtins/winston-logging/index.mjs +1 -1
  73. package/dist/{dist-DnKpFaP1.mjs → dist-CMb7aKS0.mjs} +1227 -1330
  74. package/dist/{dist-DgPUarcs.js → dist-DP3nHlNM.js} +1251 -1366
  75. package/dist/http/asset-compression.d.ts +93 -0
  76. package/dist/index.d.ts +1 -1
  77. package/dist/index.js +64 -9
  78. package/dist/index.mjs +65 -11
  79. package/dist/kernel/heap-watch.d.ts +55 -0
  80. package/dist/kernel/moleculer/device-cap-proxy.d.ts +2 -2
  81. package/dist/{manifest-python-deps-d4acYXVV.js → manifest-python-deps-BE6l8Y4W.js} +21 -15
  82. package/dist/{manifest-python-deps-CX5tGRqf.mjs → manifest-python-deps-Dz1ryHIN.mjs} +21 -15
  83. package/dist/{model-download-service-D8B-4ktF.mjs → model-download-service-CTBHzORJ.mjs} +220 -2
  84. package/dist/{model-download-service-Cpc6SWzV.js → model-download-service-Caj1kaSi.js} +220 -2
  85. package/package.json +5 -1
  86. package/dist/builtins/device-manager/device-link-cycle.d.ts +0 -18
  87. package/dist/builtins/device-manager/device-link-overlay.d.ts +0 -33
  88. package/dist/builtins/device-manager/device-link-resolver.d.ts +0 -44
  89. package/dist/main-CVLq8IzA.js +0 -2356
  90. package/dist/main-CzH19GkU.mjs +0 -2353
@@ -1,4 +1,4 @@
1
- import { Mt as nodePin, _t as DeviceFeature, ft as errMsg, i as BatteryStatusSchema, ot as streamQualityLabel, pt as BaseAddon, rt as snapshotCapability, x as bareAddonId, yt as DeviceType } from "../../dist-DnKpFaP1.mjs";
1
+ import { At as nodePin, _t as DeviceType, dt as BaseAddon, ht as DeviceFeature, i as BatteryStatusSchema, ot as streamQualityLabel, rt as snapshotCapability, ut as errMsg, x as bareAddonId } from "../../dist-CMb7aKS0.mjs";
2
2
  import { execFile, spawn } from "node:child_process";
3
3
  //#region src/builtins/snapshot/snapshot-coalescing.ts
4
4
  /**
@@ -261,10 +261,10 @@ function parseSnapshotMediaRequest(url) {
261
261
  * `snapshot.getSnapshotOverview` advertises still matches the frame nobody
262
262
  * asked to resize.
263
263
  */
264
- function snapshotEtag(request, capturedAt) {
265
- const parts = [`${String(request.deviceId)}-${String(capturedAt)}`];
266
- if (request.streamId !== void 0) parts.push(`s${request.streamId}`);
267
- if (request.width !== void 0) parts.push(`w${String(request.width)}`);
264
+ function snapshotEtag(variant, capturedAt) {
265
+ const parts = [`${String(variant.deviceId)}-${String(capturedAt)}`];
266
+ if (variant.streamId !== void 0) parts.push(`s${variant.streamId}`);
267
+ if (variant.width !== void 0) parts.push(`w${String(variant.width)}`);
268
268
  return `"${parts.join("-")}"`;
269
269
  }
270
270
  /**
@@ -283,6 +283,29 @@ function createSnapshotMediaHandler(deps) {
283
283
  res.writeHead(404).end();
284
284
  return;
285
285
  }
286
+ const inm = req.headers["if-none-match"];
287
+ if (typeof inm === "string" && !parsed.force && deps.peekFresh !== void 0) {
288
+ let peeked = null;
289
+ try {
290
+ peeked = await deps.peekFresh(parsed.deviceId, parsed.streamId);
291
+ } catch {
292
+ peeked = null;
293
+ }
294
+ if (peeked !== null) {
295
+ const peekedEtag = snapshotEtag({
296
+ deviceId: parsed.deviceId,
297
+ streamId: parsed.streamId,
298
+ width: parsed.width
299
+ }, peeked.capturedAt);
300
+ if (inm === peekedEtag) {
301
+ res.writeHead(304, {
302
+ etag: peekedEtag,
303
+ "cache-control": `private, max-age=${Math.max(0, Math.floor(peeked.maxAgeS))}`
304
+ }).end();
305
+ return;
306
+ }
307
+ }
308
+ }
286
309
  let media;
287
310
  try {
288
311
  media = await deps.getMedia(parsed.deviceId, parsed.streamId, parsed.force, parsed.width);
@@ -300,9 +323,13 @@ function createSnapshotMediaHandler(deps) {
300
323
  res.writeHead(404).end();
301
324
  return;
302
325
  }
303
- const etag = snapshotEtag(parsed, media.capturedAt);
326
+ const etag = snapshotEtag({
327
+ deviceId: parsed.deviceId,
328
+ streamId: parsed.streamId,
329
+ width: "servedWidth" in media ? media.servedWidth : parsed.width
330
+ }, media.capturedAt);
304
331
  const cacheControl = `private, max-age=${Math.max(0, Math.floor(media.maxAgeS))}`;
305
- if (req.headers["if-none-match"] === etag) {
332
+ if (inm === etag) {
306
333
  res.writeHead(304, {
307
334
  etag,
308
335
  "cache-control": cacheControl
@@ -325,7 +352,7 @@ function createSnapshotMediaHandler(deps) {
325
352
  * per-device preference decides what it captures, and conflating it with an
326
353
  * explicit request is how the original bug read. */
327
354
  var AUTO = "auto";
328
- function keyOf(deviceId, streamId) {
355
+ function keyOf$1(deviceId, streamId) {
329
356
  return `${deviceId}:${streamId ?? AUTO}`;
330
357
  }
331
358
  var SnapshotCache = class {
@@ -333,10 +360,10 @@ var SnapshotCache = class {
333
360
  /** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
334
361
  keysByDevice = /* @__PURE__ */ new Map();
335
362
  get(deviceId, streamId) {
336
- return this.byKey.get(keyOf(deviceId, streamId));
363
+ return this.byKey.get(keyOf$1(deviceId, streamId));
337
364
  }
338
365
  set(deviceId, streamId, entry) {
339
- const key = keyOf(deviceId, streamId);
366
+ const key = keyOf$1(deviceId, streamId);
340
367
  this.byKey.set(key, entry);
341
368
  const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
342
369
  keys.add(key);
@@ -496,6 +523,123 @@ var SnapshotVariantCache = class SnapshotVariantCache {
496
523
  }
497
524
  };
498
525
  //#endregion
526
+ //#region src/builtins/snapshot/snapshot-keep-warm.ts
527
+ /**
528
+ * Subscription bookkeeping for the snapshot keep-warm loop.
529
+ *
530
+ * The snapshot cache has exactly one writer — a successful capture — and every
531
+ * capture is triggered by somebody asking. Nothing refreshes a frame on its own,
532
+ * so with no viewer open every thumbnail in the system is as old as the last
533
+ * time somebody looked. Measured on the live hub: 15 of 16 cameras cached, all
534
+ * captured in one 55 ms burst, all 10 minutes old and not moving.
535
+ *
536
+ * The fix is a warm loop — but a blind one would dial 16 cameras forever for
537
+ * nobody. This module holds the thing that makes it bounded: a warm tick only
538
+ * touches cameras SOMEBODY IS LOOKING AT, and "looking at" is inferred from the
539
+ * requests themselves. One data-plane request for a device makes it an observer
540
+ * for {@link WARM_WINDOW_MS}; when the last viewer closes, the loop goes quiet
541
+ * on its own after that window and costs exactly nothing.
542
+ *
543
+ * Pure and side-effect-free (no timer, no I/O, `now` injected) — the addon owns
544
+ * the timer, this owns the decision. Unit-tested in
545
+ * `__tests__/snapshot-keep-warm.spec.ts`.
546
+ */
547
+ /**
548
+ * How often the warm loop runs. Chosen so a tile is perceptibly fresh while
549
+ * watched; it is also the single biggest cost lever in the design (16 cameras ×
550
+ * 12 ticks/min against a pool of 3 ffmpeg). A chained timer, not `setInterval` —
551
+ * the next tick is scheduled only after the previous one settles, so a slow
552
+ * fleet cannot stack ticks on top of each other.
553
+ */
554
+ var WARM_INTERVAL_MS = 5e3;
555
+ /**
556
+ * How long one request keeps a device warm. Two minutes of inertia after the
557
+ * last observer goes away: long enough that flipping between screens (or a
558
+ * phone locking briefly) does not go cold, short enough that a closed app stops
559
+ * costing captures well before anyone notices.
560
+ */
561
+ var WARM_WINDOW_MS = 12e4;
562
+ function keyOf(deviceId, streamId) {
563
+ return `${deviceId}:${streamId ?? "auto"}`;
564
+ }
565
+ /**
566
+ * Who is being watched right now, and at what sizes.
567
+ *
568
+ * `note` on every data-plane request; `active` on every warm tick. `active`
569
+ * PRUNES as it walks rather than merely filtering — the map is fed by request
570
+ * traffic, so a filter-only read would leave every device ever requested in
571
+ * memory for the life of the process.
572
+ */
573
+ var SnapshotSubscriptions = class {
574
+ entries = /* @__PURE__ */ new Map();
575
+ /** Record that somebody asked for this (device, stream) — at `width`, when the
576
+ * request carried one. */
577
+ note(deviceId, streamId, width, nowMs) {
578
+ const key = keyOf(deviceId, streamId);
579
+ const existing = this.entries.get(key);
580
+ if (existing) {
581
+ existing.lastRequestedAt = nowMs;
582
+ if (width !== void 0) existing.widths.set(width, nowMs);
583
+ this.entries.delete(key);
584
+ this.entries.set(key, existing);
585
+ return;
586
+ }
587
+ this.entries.set(key, {
588
+ deviceId,
589
+ streamId,
590
+ lastRequestedAt: nowMs,
591
+ widths: width === void 0 ? /* @__PURE__ */ new Map() : new Map([[width, nowMs]])
592
+ });
593
+ while (this.entries.size > 64) {
594
+ const oldest = this.entries.keys().next();
595
+ if (oldest.done === true) break;
596
+ this.entries.delete(oldest.value);
597
+ }
598
+ }
599
+ /**
600
+ * Refresh the window on subscriptions that ALREADY exist. Never creates one.
601
+ *
602
+ * This is what stops the design deadlocking, and the deadlock was measured on
603
+ * the live hub before it existed. The client re-requests an image only when
604
+ * the frame identity changes, and the identity changes only because the warm
605
+ * loop captured — so 120 s after the last image request the subscription
606
+ * expired, warming stopped, the identity froze, the client never asked again,
607
+ * and the tile was stuck. Ages went 13 s → 49.7 s at exactly t+120 s.
608
+ *
609
+ * The client's `getSnapshotOverview` poll is the heartbeat: asking "what is
610
+ * the state of these cameras" IS the statement that somebody is looking. It
611
+ * deliberately cannot CREATE a subscription, because that poll is app-wide —
612
+ * if it did, opening the app would warm every camera on the install, which is
613
+ * the blind poll this whole design avoids. Only a rendered surface subscribes.
614
+ */
615
+ renew(deviceIds, nowMs) {
616
+ for (const deviceId of deviceIds) for (const entry of this.entries.values()) if (entry.deviceId === deviceId) entry.lastRequestedAt = nowMs;
617
+ }
618
+ /** The (device, stream) pairs still inside the warm window, dropping the rest. */
619
+ active(nowMs, windowMs = WARM_WINDOW_MS) {
620
+ const targets = [];
621
+ for (const [key, entry] of this.entries) {
622
+ if (nowMs - entry.lastRequestedAt >= windowMs) {
623
+ this.entries.delete(key);
624
+ continue;
625
+ }
626
+ for (const [width, at] of entry.widths) if (nowMs - at >= windowMs) entry.widths.delete(width);
627
+ targets.push({
628
+ deviceId: entry.deviceId,
629
+ streamId: entry.streamId,
630
+ widths: [...entry.widths.keys()].sort((a, b) => a - b)
631
+ });
632
+ }
633
+ return targets;
634
+ }
635
+ get size() {
636
+ return this.entries.size;
637
+ }
638
+ clear() {
639
+ this.entries.clear();
640
+ }
641
+ };
642
+ //#endregion
499
643
  //#region src/builtins/snapshot/snapshot.addon.ts
500
644
  /** Default cache window for non-battery cams (seconds). 10s feels live. */
501
645
  var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
@@ -562,8 +706,28 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
562
706
  * strictly-additive delivery path). Disposed on shutdown.
563
707
  */
564
708
  mediaDataPlane = null;
565
- constructor() {
709
+ /**
710
+ * Who is being looked at, inferred from data-plane traffic. The keep-warm
711
+ * loop's entire bound — see `snapshot-keep-warm.ts`.
712
+ */
713
+ subscriptions = new SnapshotSubscriptions();
714
+ /** Chained warm-tick timer (never `setInterval` — see `scheduleWarmTick`). */
715
+ warmTimer = null;
716
+ /** Set while a warm tick is running so ticks can never stack. */
717
+ warmTickRunning = false;
718
+ /** Flipped on shutdown so an in-flight tick does not reschedule itself. */
719
+ stopped = false;
720
+ /**
721
+ * How a full frame becomes a card-sized one. Defaults to the real ffmpeg
722
+ * resize; injectable so the wrapper's own logic is testable without spawning a
723
+ * process for every fixture. `JpegResizer` was declared for exactly this and
724
+ * had no injector — which is why the resize-failure path had never been
725
+ * exercised anywhere but production.
726
+ */
727
+ resize;
728
+ constructor(resize = resizeJpeg) {
566
729
  super({ staleTtlMs: 6e4 });
730
+ this.resize = resize;
567
731
  }
568
732
  async onInitialize() {
569
733
  this.ctx.logger.info("Snapshot wrapper initialized");
@@ -577,6 +741,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
577
741
  getSnapshotOverview: (input) => this.getSnapshotOverview(input)
578
742
  };
579
743
  await this.serveMediaDataPlane();
744
+ this.scheduleWarmTick();
580
745
  return [{
581
746
  capability: snapshotCapability,
582
747
  provider
@@ -592,7 +757,10 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
592
757
  * base64 path.
593
758
  */
594
759
  async serveMediaDataPlane() {
595
- const handler = createSnapshotMediaHandler({ getMedia: (deviceId, streamId, force, width) => this.resolveSnapshotMedia(deviceId, streamId, force, width) });
760
+ const handler = createSnapshotMediaHandler({
761
+ getMedia: (deviceId, streamId, force, width) => this.resolveSnapshotMedia(deviceId, streamId, force, width),
762
+ peekFresh: (deviceId, streamId) => this.peekFreshFrame(deviceId, streamId)
763
+ });
596
764
  try {
597
765
  this.mediaDataPlane = await this.ctx.dataPlane?.serve({
598
766
  prefix: "media",
@@ -614,6 +782,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
614
782
  * effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
615
783
  */
616
784
  async resolveSnapshotMedia(deviceId, streamId, force, width) {
785
+ this.subscriptions.note(deviceId, streamId, width, Date.now());
617
786
  const image = await this.getSnapshot({
618
787
  deviceId,
619
788
  ...streamId !== void 0 ? { streamId } : {},
@@ -623,14 +792,128 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
623
792
  const capturedAt = this.cache.get(deviceId, streamId)?.ts ?? Date.now();
624
793
  const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
625
794
  const isBattery = (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false;
795
+ if (width === void 0) return {
796
+ bytes: Buffer.from(image.base64, "base64"),
797
+ contentType: image.contentType,
798
+ capturedAt,
799
+ maxAgeS: effectiveMaxAgeS(prefs, isBattery),
800
+ servedWidth: void 0
801
+ };
802
+ const thumb = await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs);
626
803
  return {
627
- bytes: width === void 0 ? Buffer.from(image.base64, "base64") : await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs),
804
+ bytes: thumb.bytes,
628
805
  contentType: image.contentType,
629
806
  capturedAt,
630
- maxAgeS: effectiveMaxAgeS(prefs, isBattery)
807
+ maxAgeS: effectiveMaxAgeS(prefs, isBattery),
808
+ servedWidth: thumb.width
809
+ };
810
+ }
811
+ /**
812
+ * Cache-only freshness peek for the data-plane's conditional-GET fast path.
813
+ * Never captures, never resizes, never dials anything: a cached frame that is
814
+ * still inside its per-device max-age, or null.
815
+ *
816
+ * Returning null for a STALE entry is the important half. A 304 there would
817
+ * pin the tile to the old frame for as long as the client kept revalidating,
818
+ * which is the exact staleness this change exists to remove.
819
+ */
820
+ async peekFreshFrame(deviceId, streamId) {
821
+ const hit = this.cache.get(deviceId, streamId);
822
+ if (!hit) return null;
823
+ const maxAgeS = effectiveMaxAgeS(await this.readDeviceSettings(deviceId).catch(() => ({})), (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false);
824
+ if (Date.now() - hit.ts >= maxAgeS * 1e3) return null;
825
+ return {
826
+ capturedAt: hit.ts,
827
+ maxAgeS
631
828
  };
632
829
  }
633
830
  /**
831
+ * Schedule the next warm tick. A CHAINED timer, not `setInterval`: the next
832
+ * tick is armed only after the previous one settles, so a fleet that is slower
833
+ * than the interval degrades to "as fast as it can" instead of stacking ticks
834
+ * on top of each other and exhausting the ffmpeg pool.
835
+ *
836
+ * `unref` so the loop never holds the process open.
837
+ */
838
+ scheduleWarmTick() {
839
+ if (this.stopped) return;
840
+ this.warmTimer = setTimeout(() => {
841
+ this.runWarmTick().finally(() => {
842
+ this.scheduleWarmTick();
843
+ });
844
+ }, WARM_INTERVAL_MS);
845
+ this.warmTimer.unref?.();
846
+ }
847
+ /**
848
+ * Refresh the cache for every camera somebody is currently looking at.
849
+ *
850
+ * Deliberately NOT a blind poll of the whole fleet: with nobody watching this
851
+ * walks an empty list and costs nothing, which is the entire difference
852
+ * between this and a background poller.
853
+ *
854
+ * Battery cameras need no special case here and must not get one. A warm tick
855
+ * is a plain non-forced `getSnapshot`, so it meets the two gates that already
856
+ * exist — the 3600 s battery max-age (which makes the tick a cache hit) and
857
+ * the sleeping-battery gate (which refuses to capture without `force`). A
858
+ * second battery check here would be a rule that disagrees with the authority
859
+ * that already owns it.
860
+ */
861
+ async runWarmTick() {
862
+ if (this.warmTickRunning || this.stopped) return;
863
+ this.warmTickRunning = true;
864
+ const logger = this.ctx.logger;
865
+ try {
866
+ const targets = this.subscriptions.active(Date.now());
867
+ if (targets.length === 0) return;
868
+ const failed = (await Promise.allSettled(targets.map((t) => this.warmOne(t, logger)))).filter((r) => r.status === "rejected").length;
869
+ logger.debug("snapshot: keep-warm tick", { meta: {
870
+ watched: targets.length,
871
+ failed
872
+ } });
873
+ } catch (err) {
874
+ logger.warn("snapshot: keep-warm tick failed", { meta: { error: errMsg(err) } });
875
+ } finally {
876
+ this.warmTickRunning = false;
877
+ }
878
+ }
879
+ /**
880
+ * Warm one (device, stream): refresh the frame, then pre-derive the widths
881
+ * that were actually requested.
882
+ *
883
+ * Deriving the variants matters as much as the capture. Warming only the full
884
+ * frame would leave every grid tile still paying an ffmpeg resize on the
885
+ * request path — the cache would be fresh and the tile would still be slow.
886
+ */
887
+ async warmOne(target, logger) {
888
+ const work = this.warmOneUnbounded(target, logger);
889
+ work.catch(() => void 0);
890
+ if (!(await raceForResult(work, 5e3)).settled) logger.debug("snapshot: keep-warm still in flight past the tick — leaving it to land", { tags: { deviceId: target.deviceId } });
891
+ }
892
+ async warmOneUnbounded(target, logger) {
893
+ const { deviceId, streamId, widths } = target;
894
+ try {
895
+ if (this.stopped) return;
896
+ const image = await this.getSnapshot({
897
+ deviceId,
898
+ ...streamId !== void 0 ? { streamId } : {},
899
+ force: false
900
+ }, WARM_INTERVAL_MS);
901
+ if (!image) return;
902
+ const capturedAt = this.cache.get(deviceId, streamId)?.ts;
903
+ if (capturedAt === void 0) return;
904
+ const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
905
+ for (const width of widths) {
906
+ if (this.stopped) return;
907
+ await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs);
908
+ }
909
+ } catch (err) {
910
+ logger.debug("snapshot: keep-warm failed for device", {
911
+ tags: { deviceId },
912
+ meta: { error: errMsg(err) }
913
+ });
914
+ }
915
+ }
916
+ /**
634
917
  * The frame at a card-sized width, derived once per (device, stream, width)
635
918
  * per capture.
636
919
  *
@@ -641,10 +924,13 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
641
924
  */
642
925
  async thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs) {
643
926
  const cached = this.variants.get(deviceId, streamId, width, capturedAt);
644
- if (cached !== void 0) return cached;
927
+ if (cached !== void 0) return {
928
+ bytes: cached,
929
+ width
930
+ };
645
931
  const full = Buffer.from(image.base64, "base64");
646
932
  try {
647
- const resized = await resizeJpeg(full, width);
933
+ const resized = await this.resize(full, width);
648
934
  this.variants.set(deviceId, streamId, width, capturedAt, resized);
649
935
  if (prefs.snapshotDebug) this.ctx.logger.info("snapshot: thumbnail derived", {
650
936
  tags: { deviceId },
@@ -654,7 +940,10 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
654
940
  thumbBytes: resized.length
655
941
  }
656
942
  });
657
- return resized;
943
+ return {
944
+ bytes: resized,
945
+ width
946
+ };
658
947
  } catch (err) {
659
948
  this.ctx.logger.warn("snapshot: resize failed — serving the full frame", {
660
949
  tags: { deviceId },
@@ -664,10 +953,19 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
664
953
  error: errMsg(err)
665
954
  }
666
955
  });
667
- return full;
956
+ return {
957
+ bytes: full,
958
+ width: void 0
959
+ };
668
960
  }
669
961
  }
670
962
  async onShutdown() {
963
+ this.stopped = true;
964
+ if (this.warmTimer !== null) {
965
+ clearTimeout(this.warmTimer);
966
+ this.warmTimer = null;
967
+ }
968
+ this.subscriptions.clear();
671
969
  if (this.mediaDataPlane) {
672
970
  await this.mediaDataPlane.dispose().catch(() => void 0);
673
971
  this.mediaDataPlane = null;
@@ -706,7 +1004,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
706
1004
  })]
707
1005
  }] });
708
1006
  }
709
- async getSnapshot(input) {
1007
+ async getSnapshot(input, warmAheadMs = 0) {
710
1008
  const { deviceId } = input;
711
1009
  const force = input.force === true;
712
1010
  const meta = await this.lookupDeviceMeta(deviceId);
@@ -725,7 +1023,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
725
1023
  meta: { stream: effectiveStreamId ?? "auto" }
726
1024
  });
727
1025
  const hit = this.cache.get(deviceId, effectiveStreamId);
728
- const effectiveMaxAgeMs = effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3;
1026
+ const effectiveMaxAgeMs = Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3 - warmAheadMs);
729
1027
  const decision = decideSnapshotServe({
730
1028
  now,
731
1029
  cachedAt: hit?.ts ?? null,
@@ -1087,6 +1385,7 @@ var SnapshotAddon = class SnapshotAddon extends BaseAddon {
1087
1385
  */
1088
1386
  getSnapshotOverview(input) {
1089
1387
  const now = Date.now();
1388
+ this.subscriptions.renew(input.deviceIds, now);
1090
1389
  const rows = input.deviceIds.map((deviceId) => {
1091
1390
  const hit = this.cache.latest(deviceId);
1092
1391
  if (!hit) return {
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Subscription bookkeeping for the snapshot keep-warm loop.
3
+ *
4
+ * The snapshot cache has exactly one writer — a successful capture — and every
5
+ * capture is triggered by somebody asking. Nothing refreshes a frame on its own,
6
+ * so with no viewer open every thumbnail in the system is as old as the last
7
+ * time somebody looked. Measured on the live hub: 15 of 16 cameras cached, all
8
+ * captured in one 55 ms burst, all 10 minutes old and not moving.
9
+ *
10
+ * The fix is a warm loop — but a blind one would dial 16 cameras forever for
11
+ * nobody. This module holds the thing that makes it bounded: a warm tick only
12
+ * touches cameras SOMEBODY IS LOOKING AT, and "looking at" is inferred from the
13
+ * requests themselves. One data-plane request for a device makes it an observer
14
+ * for {@link WARM_WINDOW_MS}; when the last viewer closes, the loop goes quiet
15
+ * on its own after that window and costs exactly nothing.
16
+ *
17
+ * Pure and side-effect-free (no timer, no I/O, `now` injected) — the addon owns
18
+ * the timer, this owns the decision. Unit-tested in
19
+ * `__tests__/snapshot-keep-warm.spec.ts`.
20
+ */
21
+ /**
22
+ * How often the warm loop runs. Chosen so a tile is perceptibly fresh while
23
+ * watched; it is also the single biggest cost lever in the design (16 cameras ×
24
+ * 12 ticks/min against a pool of 3 ffmpeg). A chained timer, not `setInterval` —
25
+ * the next tick is scheduled only after the previous one settles, so a slow
26
+ * fleet cannot stack ticks on top of each other.
27
+ */
28
+ export declare const WARM_INTERVAL_MS = 5000;
29
+ /**
30
+ * How long one request keeps a device warm. Two minutes of inertia after the
31
+ * last observer goes away: long enough that flipping between screens (or a
32
+ * phone locking briefly) does not go cold, short enough that a closed app stops
33
+ * costing captures well before anyone notices.
34
+ */
35
+ export declare const WARM_WINDOW_MS = 120000;
36
+ /**
37
+ * Hard bound on tracked subscriptions. The map is keyed by whatever device ids
38
+ * arrive over HTTP, so it is attacker-influenced: a sweep over ids must not be
39
+ * able to grow it without limit, nor make a warm tick dial an unbounded fleet.
40
+ * Well above any real install's simultaneously-watched camera count.
41
+ */
42
+ export declare const MAX_WARM_TARGETS = 64;
43
+ /** One (device, stream) the warm loop should refresh, plus the widths a client
44
+ * actually asked for — so the tick can pre-derive exactly those variants and
45
+ * leave the next real request a hit at BOTH cache levels. */
46
+ export interface SnapshotWarmTarget {
47
+ readonly deviceId: number;
48
+ readonly streamId: string | undefined;
49
+ /** Requested widths still inside the window, ascending. Empty = full frame only. */
50
+ readonly widths: readonly number[];
51
+ }
52
+ /**
53
+ * Who is being watched right now, and at what sizes.
54
+ *
55
+ * `note` on every data-plane request; `active` on every warm tick. `active`
56
+ * PRUNES as it walks rather than merely filtering — the map is fed by request
57
+ * traffic, so a filter-only read would leave every device ever requested in
58
+ * memory for the life of the process.
59
+ */
60
+ export declare class SnapshotSubscriptions {
61
+ private readonly entries;
62
+ /** Record that somebody asked for this (device, stream) — at `width`, when the
63
+ * request carried one. */
64
+ note(deviceId: number, streamId: string | undefined, width: number | undefined, nowMs: number): void;
65
+ /**
66
+ * Refresh the window on subscriptions that ALREADY exist. Never creates one.
67
+ *
68
+ * This is what stops the design deadlocking, and the deadlock was measured on
69
+ * the live hub before it existed. The client re-requests an image only when
70
+ * the frame identity changes, and the identity changes only because the warm
71
+ * loop captured — so 120 s after the last image request the subscription
72
+ * expired, warming stopped, the identity froze, the client never asked again,
73
+ * and the tile was stuck. Ages went 13 s → 49.7 s at exactly t+120 s.
74
+ *
75
+ * The client's `getSnapshotOverview` poll is the heartbeat: asking "what is
76
+ * the state of these cameras" IS the statement that somebody is looking. It
77
+ * deliberately cannot CREATE a subscription, because that poll is app-wide —
78
+ * if it did, opening the app would warm every camera on the install, which is
79
+ * the blind poll this whole design avoids. Only a rendered surface subscribes.
80
+ */
81
+ renew(deviceIds: readonly number[], nowMs: number): void;
82
+ /** The (device, stream) pairs still inside the warm window, dropping the rest. */
83
+ active(nowMs: number, windowMs?: number): readonly SnapshotWarmTarget[];
84
+ get size(): number;
85
+ clear(): void;
86
+ }
@@ -9,6 +9,24 @@ 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
+ /**
13
+ * The width the returned BYTES are actually at, or undefined when they are the
14
+ * frame as captured.
15
+ *
16
+ * Deliberately not the same thing as the requested width. A failed resize
17
+ * serves the full frame, and stamping the requested `-w320` on it poisons the
18
+ * ETag: a later SUCCESSFUL resize of the same capture mints the identical tag
19
+ * with completely different bytes, so a client holding the megabyte revalidates,
20
+ * gets a 304, and keeps it. The tag has to describe what went out.
21
+ */
22
+ readonly servedWidth?: number | undefined;
23
+ }
24
+ /** What the cache can answer about a device WITHOUT capturing anything. */
25
+ export interface SnapshotPeek {
26
+ /** Epoch ms of the cached capture. */
27
+ readonly capturedAt: number;
28
+ /** Effective per-device max cache age (seconds). */
29
+ readonly maxAgeS: number;
12
30
  }
13
31
  export interface SnapshotMediaHandlerDeps {
14
32
  /**
@@ -17,6 +35,27 @@ export interface SnapshotMediaHandlerDeps {
17
35
  * produced (→ 404). Throwing produces an opaque 500 (caller logs).
18
36
  */
19
37
  readonly getMedia: (deviceId: number, streamId: string | undefined, force: boolean, width: number | undefined) => Promise<SnapshotMedia | null>;
38
+ /**
39
+ * Cache-only lookup: what frame is cached for this (device, stream) right now,
40
+ * and is it still FRESH? Returns null when nothing is cached or the entry is
41
+ * past its max-age.
42
+ *
43
+ * This exists so a conditional GET can be answered before `getMedia` runs.
44
+ * Without it a 304 is only cheap in bytes — the ladder and the resize have
45
+ * both already happened by the time the ETags are compared, which on a camera
46
+ * whose live paths are failing is two 15 s ffmpeg timeouts to send zero bytes.
47
+ *
48
+ * Width is deliberately absent: the ETag the client presents already names the
49
+ * width it holds, and it can only hold a tag this server minted, so matching
50
+ * the frame identity is sufficient. A client holding the FULL frame after a
51
+ * failed resize presents `"<id>-<ts>"`, which cannot collide with the
52
+ * `"<id>-<ts>-w320"` a `?w=320` request computes — it falls through and
53
+ * re-derives, which is exactly right.
54
+ *
55
+ * Optional: a host that does not supply it keeps the previous behaviour
56
+ * exactly (run the ladder, then compare).
57
+ */
58
+ readonly peekFresh?: (deviceId: number, streamId: string | undefined) => Promise<SnapshotPeek | null>;
20
59
  }
21
60
  /** Parsed `/<deviceId>[.jpg][?streamId=…&force=1&w=320]` request. */
22
61
  export interface SnapshotMediaRequest {
@@ -59,7 +98,15 @@ export declare function parseSnapshotMediaRequest(url: string): SnapshotMediaReq
59
98
  * `snapshot.getSnapshotOverview` advertises still matches the frame nobody
60
99
  * asked to resize.
61
100
  */
62
- export declare function snapshotEtag(request: SnapshotMediaRequest, capturedAt: number): string;
101
+ export declare function snapshotEtag(variant: SnapshotVariantIdentity, capturedAt: number): string;
102
+ /** The (device, stream, width) triple an ETag names. Structurally a
103
+ * {@link SnapshotMediaRequest}, but the width is the width SERVED — see
104
+ * {@link SnapshotMedia.servedWidth}. */
105
+ export interface SnapshotVariantIdentity {
106
+ readonly deviceId: number;
107
+ readonly streamId: string | undefined;
108
+ readonly width: number | undefined;
109
+ }
63
110
  /**
64
111
  * Create a data-plane handler that serves per-device snapshots as JPEG images.
65
112
  * `deps.getMedia` is called once per request; null → 404, throw → 500. A