@camstack/system 1.2.61 → 1.2.63

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 (86) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  11. package/dist/builtins/console-logging/index.js +1 -1
  12. package/dist/builtins/console-logging/index.mjs +1 -1
  13. package/dist/builtins/core-blocks/block-package.d.ts +9 -27
  14. package/dist/builtins/core-blocks/block-supervisor.d.ts +15 -0
  15. package/dist/builtins/core-blocks/core-blocks.addon.js +91 -24
  16. package/dist/builtins/core-blocks/core-blocks.addon.mjs +91 -25
  17. package/dist/builtins/device-manager/device-aggregation.d.ts +49 -2
  18. package/dist/builtins/device-manager/device-bindings-store.d.ts +28 -5
  19. package/dist/builtins/device-manager/device-linked-devices.d.ts +2 -2
  20. package/dist/builtins/device-manager/device-manager.addon.d.ts +3 -45
  21. package/dist/builtins/device-manager/device-manager.addon.js +182 -888
  22. package/dist/builtins/device-manager/device-manager.addon.mjs +182 -888
  23. package/dist/builtins/device-manager/device-meta-actions.d.ts +0 -8
  24. package/dist/builtins/device-manager/device-meta-store.d.ts +1 -28
  25. package/dist/builtins/device-manager/device-meta-types.d.ts +2 -6
  26. package/dist/builtins/device-manager/device-projection.d.ts +1 -2
  27. package/dist/builtins/device-manager/device-provider-context.d.ts +2 -13
  28. package/dist/builtins/device-manager/device-queries.d.ts +1 -1
  29. package/dist/builtins/device-manager/device-state-mirror.d.ts +8 -49
  30. package/dist/builtins/device-manager/device-sync-resolvers.d.ts +0 -11
  31. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  32. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  33. package/dist/builtins/hub-forwarder/index.js +1 -1
  34. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  35. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  36. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  37. package/dist/builtins/local-auth/homeassistant-integration.d.ts +7 -0
  38. package/dist/builtins/local-auth/index.d.ts +1 -0
  39. package/dist/builtins/local-auth/index.js +1 -0
  40. package/dist/builtins/local-auth/index.mjs +2 -2
  41. package/dist/builtins/local-auth/local-auth.addon.js +561 -497
  42. package/dist/builtins/local-auth/local-auth.addon.mjs +580 -517
  43. package/dist/builtins/local-auth/oauth-grants.d.ts +11 -0
  44. package/dist/builtins/local-auth/oauth-session-manager.d.ts +1 -1
  45. package/dist/builtins/local-network/local-network.addon.js +1 -1
  46. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  47. package/dist/builtins/loki-logging/index.js +1 -1
  48. package/dist/builtins/loki-logging/index.mjs +1 -1
  49. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  50. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  51. package/dist/builtins/platform-probe/index.js +1 -1
  52. package/dist/builtins/platform-probe/index.mjs +1 -1
  53. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  54. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  55. package/dist/builtins/snapshot/index.js +319 -20
  56. package/dist/builtins/snapshot/index.mjs +319 -20
  57. package/dist/builtins/snapshot/snapshot-keep-warm.d.ts +86 -0
  58. package/dist/builtins/snapshot/snapshot-media-handler.d.ts +48 -1
  59. package/dist/builtins/snapshot/snapshot.addon.d.ts +65 -1
  60. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  61. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  62. package/dist/builtins/sqlite-storage/retired-settings-keys.d.ts +15 -0
  63. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +45 -5
  64. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +45 -5
  65. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  66. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  67. package/dist/builtins/system-config/system-config.addon.js +1 -1
  68. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  69. package/dist/builtins/winston-logging/index.js +1 -1
  70. package/dist/builtins/winston-logging/index.mjs +1 -1
  71. package/dist/{dist-L6QPJWsr.mjs → dist-BJ2w3uoN.mjs} +1307 -1313
  72. package/dist/{dist-BN681PgO.js → dist-LDrOFrd-.js} +1255 -1273
  73. package/dist/index.d.ts +1 -1
  74. package/dist/index.js +209 -5
  75. package/dist/index.mjs +206 -7
  76. package/dist/kernel/addon-residue.d.ts +49 -0
  77. package/dist/kernel/index.d.ts +2 -0
  78. package/dist/kernel/moleculer/device-cap-proxy.d.ts +2 -2
  79. package/dist/{manifest-python-deps-CX5tGRqf.mjs → manifest-python-deps-CopL1_5C.mjs} +20 -14
  80. package/dist/{manifest-python-deps-d4acYXVV.js → manifest-python-deps-DNfmBu20.js} +20 -14
  81. package/package.json +5 -1
  82. package/dist/builtins/device-manager/device-link-cycle.d.ts +0 -18
  83. package/dist/builtins/device-manager/device-link-overlay.d.ts +0 -33
  84. package/dist/builtins/device-manager/device-link-resolver.d.ts +0 -44
  85. package/dist/main-CVLq8IzA.js +0 -2356
  86. package/dist/main-CzH19GkU.mjs +0 -2353
@@ -7,7 +7,17 @@ interface IssueCodeInput {
7
7
  scopes: TokenScope[];
8
8
  redirectUri: string;
9
9
  hubUrl: string;
10
+ codeChallenge?: string;
10
11
  }
12
+ /**
13
+ * PKCE S256 check (RFC 7636 §4.6): `BASE64URL(SHA256(verifier)) === challenge`.
14
+ *
15
+ * Keyed off the CLAIM, never off the descriptor: a code minted with a challenge
16
+ * stays unusable without the verifier even if the integration later stops
17
+ * requiring PKCE. Constant-time compare so a wrong verifier cannot be walked
18
+ * byte by byte inside the code's 60 s window.
19
+ */
20
+ export declare function verifyPkceS256(challenge: string, verifier: string | undefined): boolean;
11
21
  interface TokenPair {
12
22
  accessToken: string;
13
23
  refreshToken: string;
@@ -34,6 +44,7 @@ export declare function createOauthGrants(ssoBridge: ISsoBridgeProvider, session
34
44
  oauthExchangeCode(input: {
35
45
  code: string;
36
46
  redirectUri: string;
47
+ codeVerifier?: string;
37
48
  }): Promise<TokenPair | null>;
38
49
  oauthRefresh(input: {
39
50
  refreshToken: string;
@@ -1,4 +1,4 @@
1
- import { TokenScope, SettingsStoreClient } from '@camstack/types';
1
+ import { SettingsStoreClient, TokenScope } from '@camstack/types';
2
2
  export interface OauthSession {
3
3
  readonly id: string;
4
4
  readonly userId: string;
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("../../chunk-Cek0wNdY.js");
3
- const require_dist = require("../../dist-BN681PgO.js");
3
+ const require_dist = require("../../dist-LDrOFrd-.js");
4
4
  let node_os = require("node:os");
5
5
  node_os = require_chunk.__toESM(node_os);
6
6
  //#region src/builtins/local-network/local-network.addon.ts
@@ -1,4 +1,4 @@
1
- import { W as localNetworkCapability, pt as BaseAddon, zt as EventCategory } from "../../dist-L6QPJWsr.mjs";
1
+ import { Lt as EventCategory, W as localNetworkCapability, dt as BaseAddon } from "../../dist-BJ2w3uoN.mjs";
2
2
  import * as os from "node:os";
3
3
  //#region src/builtins/local-network/local-network.addon.ts
4
4
  /**
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BN681PgO.js");
6
+ const require_dist = require("../../dist-LDrOFrd-.js");
7
7
  //#region src/builtins/loki-logging/loki-payload.ts
8
8
  /**
9
9
  * Loki rejects a label name that is not a valid Prometheus label
@@ -1,4 +1,4 @@
1
- import { G as logDestinationCapability, pt as BaseAddon } from "../../dist-L6QPJWsr.mjs";
1
+ import { G as logDestinationCapability, dt as BaseAddon } from "../../dist-BJ2w3uoN.mjs";
2
2
  //#region src/builtins/loki-logging/loki-payload.ts
3
3
  /**
4
4
  * Loki rejects a label name that is not a valid Prometheus label
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BN681PgO.js");
6
+ const require_dist = require("../../dist-LDrOFrd-.js");
7
7
  let node_fs = require("node:fs");
8
8
  node_fs = require_chunk.__toESM(node_fs);
9
9
  let node_child_process = require("node:child_process");
@@ -1,4 +1,4 @@
1
- import { Et as createEvent, J as metricsProviderCapability, pt as BaseAddon, zt as EventCategory } from "../../dist-L6QPJWsr.mjs";
1
+ import { J as metricsProviderCapability, Lt as EventCategory, dt as BaseAddon, wt as createEvent } from "../../dist-BJ2w3uoN.mjs";
2
2
  import * as fs from "node:fs";
3
3
  import { execFile, execFileSync } from "node:child_process";
4
4
  import { promisify } from "node:util";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BN681PgO.js");
6
+ const require_dist = require("../../dist-LDrOFrd-.js");
7
7
  let node_fs = require("node:fs");
8
8
  node_fs = require_chunk.__toESM(node_fs);
9
9
  let node_child_process = require("node:child_process");
@@ -1,5 +1,5 @@
1
1
  import { o as __toESM } from "../../chunk-CNf5ZN-e.mjs";
2
- import { A as enumerateInferenceDevices, Ot as emitReadiness, Q as platformProbeCapability, et as scoreRuntimes, ft as errMsg, pt as BaseAddon, zt as EventCategory } from "../../dist-L6QPJWsr.mjs";
2
+ import { $ as platformProbeCapability, Et as emitReadiness, Lt as EventCategory, M as enumerateInferenceDevices, dt as BaseAddon, tt as scoreRuntimes, ut as errMsg } from "../../dist-BJ2w3uoN.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BN681PgO.js");
6
+ const require_dist = require("../../dist-LDrOFrd-.js");
7
7
  //#region src/builtins/remote-access-orchestrator/enabled-providers-reconcile.ts
8
8
  /**
9
9
  * Reconcile the durable `enabledProviders` set against authoritative
@@ -1,4 +1,4 @@
1
- import { pt as BaseAddon, zt as EventCategory } from "../../dist-L6QPJWsr.mjs";
1
+ import { Lt as EventCategory, dt as BaseAddon } from "../../dist-BJ2w3uoN.mjs";
2
2
  //#region src/builtins/remote-access-orchestrator/enabled-providers-reconcile.ts
3
3
  /**
4
4
  * Reconcile the durable `enabledProviders` set against authoritative
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BN681PgO.js");
6
+ const require_dist = require("../../dist-LDrOFrd-.js");
7
7
  let node_child_process = require("node:child_process");
8
8
  //#region src/builtins/snapshot/snapshot-coalescing.ts
9
9
  /**
@@ -266,10 +266,10 @@ function parseSnapshotMediaRequest(url) {
266
266
  * `snapshot.getSnapshotOverview` advertises still matches the frame nobody
267
267
  * asked to resize.
268
268
  */
269
- function snapshotEtag(request, capturedAt) {
270
- const parts = [`${String(request.deviceId)}-${String(capturedAt)}`];
271
- if (request.streamId !== void 0) parts.push(`s${request.streamId}`);
272
- if (request.width !== void 0) parts.push(`w${String(request.width)}`);
269
+ function snapshotEtag(variant, capturedAt) {
270
+ const parts = [`${String(variant.deviceId)}-${String(capturedAt)}`];
271
+ if (variant.streamId !== void 0) parts.push(`s${variant.streamId}`);
272
+ if (variant.width !== void 0) parts.push(`w${String(variant.width)}`);
273
273
  return `"${parts.join("-")}"`;
274
274
  }
275
275
  /**
@@ -288,6 +288,29 @@ function createSnapshotMediaHandler(deps) {
288
288
  res.writeHead(404).end();
289
289
  return;
290
290
  }
291
+ const inm = req.headers["if-none-match"];
292
+ if (typeof inm === "string" && !parsed.force && deps.peekFresh !== void 0) {
293
+ let peeked = null;
294
+ try {
295
+ peeked = await deps.peekFresh(parsed.deviceId, parsed.streamId);
296
+ } catch {
297
+ peeked = null;
298
+ }
299
+ if (peeked !== null) {
300
+ const peekedEtag = snapshotEtag({
301
+ deviceId: parsed.deviceId,
302
+ streamId: parsed.streamId,
303
+ width: parsed.width
304
+ }, peeked.capturedAt);
305
+ if (inm === peekedEtag) {
306
+ res.writeHead(304, {
307
+ etag: peekedEtag,
308
+ "cache-control": `private, max-age=${Math.max(0, Math.floor(peeked.maxAgeS))}`
309
+ }).end();
310
+ return;
311
+ }
312
+ }
313
+ }
291
314
  let media;
292
315
  try {
293
316
  media = await deps.getMedia(parsed.deviceId, parsed.streamId, parsed.force, parsed.width);
@@ -305,9 +328,13 @@ function createSnapshotMediaHandler(deps) {
305
328
  res.writeHead(404).end();
306
329
  return;
307
330
  }
308
- const etag = snapshotEtag(parsed, media.capturedAt);
331
+ const etag = snapshotEtag({
332
+ deviceId: parsed.deviceId,
333
+ streamId: parsed.streamId,
334
+ width: "servedWidth" in media ? media.servedWidth : parsed.width
335
+ }, media.capturedAt);
309
336
  const cacheControl = `private, max-age=${Math.max(0, Math.floor(media.maxAgeS))}`;
310
- if (req.headers["if-none-match"] === etag) {
337
+ if (inm === etag) {
311
338
  res.writeHead(304, {
312
339
  etag,
313
340
  "cache-control": cacheControl
@@ -330,7 +357,7 @@ function createSnapshotMediaHandler(deps) {
330
357
  * per-device preference decides what it captures, and conflating it with an
331
358
  * explicit request is how the original bug read. */
332
359
  var AUTO = "auto";
333
- function keyOf(deviceId, streamId) {
360
+ function keyOf$1(deviceId, streamId) {
334
361
  return `${deviceId}:${streamId ?? AUTO}`;
335
362
  }
336
363
  var SnapshotCache = class {
@@ -338,10 +365,10 @@ var SnapshotCache = class {
338
365
  /** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
339
366
  keysByDevice = /* @__PURE__ */ new Map();
340
367
  get(deviceId, streamId) {
341
- return this.byKey.get(keyOf(deviceId, streamId));
368
+ return this.byKey.get(keyOf$1(deviceId, streamId));
342
369
  }
343
370
  set(deviceId, streamId, entry) {
344
- const key = keyOf(deviceId, streamId);
371
+ const key = keyOf$1(deviceId, streamId);
345
372
  this.byKey.set(key, entry);
346
373
  const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
347
374
  keys.add(key);
@@ -501,6 +528,123 @@ var SnapshotVariantCache = class SnapshotVariantCache {
501
528
  }
502
529
  };
503
530
  //#endregion
531
+ //#region src/builtins/snapshot/snapshot-keep-warm.ts
532
+ /**
533
+ * Subscription bookkeeping for the snapshot keep-warm loop.
534
+ *
535
+ * The snapshot cache has exactly one writer — a successful capture — and every
536
+ * capture is triggered by somebody asking. Nothing refreshes a frame on its own,
537
+ * so with no viewer open every thumbnail in the system is as old as the last
538
+ * time somebody looked. Measured on the live hub: 15 of 16 cameras cached, all
539
+ * captured in one 55 ms burst, all 10 minutes old and not moving.
540
+ *
541
+ * The fix is a warm loop — but a blind one would dial 16 cameras forever for
542
+ * nobody. This module holds the thing that makes it bounded: a warm tick only
543
+ * touches cameras SOMEBODY IS LOOKING AT, and "looking at" is inferred from the
544
+ * requests themselves. One data-plane request for a device makes it an observer
545
+ * for {@link WARM_WINDOW_MS}; when the last viewer closes, the loop goes quiet
546
+ * on its own after that window and costs exactly nothing.
547
+ *
548
+ * Pure and side-effect-free (no timer, no I/O, `now` injected) — the addon owns
549
+ * the timer, this owns the decision. Unit-tested in
550
+ * `__tests__/snapshot-keep-warm.spec.ts`.
551
+ */
552
+ /**
553
+ * How often the warm loop runs. Chosen so a tile is perceptibly fresh while
554
+ * watched; it is also the single biggest cost lever in the design (16 cameras ×
555
+ * 12 ticks/min against a pool of 3 ffmpeg). A chained timer, not `setInterval` —
556
+ * the next tick is scheduled only after the previous one settles, so a slow
557
+ * fleet cannot stack ticks on top of each other.
558
+ */
559
+ var WARM_INTERVAL_MS = 5e3;
560
+ /**
561
+ * How long one request keeps a device warm. Two minutes of inertia after the
562
+ * last observer goes away: long enough that flipping between screens (or a
563
+ * phone locking briefly) does not go cold, short enough that a closed app stops
564
+ * costing captures well before anyone notices.
565
+ */
566
+ var WARM_WINDOW_MS = 12e4;
567
+ function keyOf(deviceId, streamId) {
568
+ return `${deviceId}:${streamId ?? "auto"}`;
569
+ }
570
+ /**
571
+ * Who is being watched right now, and at what sizes.
572
+ *
573
+ * `note` on every data-plane request; `active` on every warm tick. `active`
574
+ * PRUNES as it walks rather than merely filtering — the map is fed by request
575
+ * traffic, so a filter-only read would leave every device ever requested in
576
+ * memory for the life of the process.
577
+ */
578
+ var SnapshotSubscriptions = class {
579
+ entries = /* @__PURE__ */ new Map();
580
+ /** Record that somebody asked for this (device, stream) — at `width`, when the
581
+ * request carried one. */
582
+ note(deviceId, streamId, width, nowMs) {
583
+ const key = keyOf(deviceId, streamId);
584
+ const existing = this.entries.get(key);
585
+ if (existing) {
586
+ existing.lastRequestedAt = nowMs;
587
+ if (width !== void 0) existing.widths.set(width, nowMs);
588
+ this.entries.delete(key);
589
+ this.entries.set(key, existing);
590
+ return;
591
+ }
592
+ this.entries.set(key, {
593
+ deviceId,
594
+ streamId,
595
+ lastRequestedAt: nowMs,
596
+ widths: width === void 0 ? /* @__PURE__ */ new Map() : new Map([[width, nowMs]])
597
+ });
598
+ while (this.entries.size > 64) {
599
+ const oldest = this.entries.keys().next();
600
+ if (oldest.done === true) break;
601
+ this.entries.delete(oldest.value);
602
+ }
603
+ }
604
+ /**
605
+ * Refresh the window on subscriptions that ALREADY exist. Never creates one.
606
+ *
607
+ * This is what stops the design deadlocking, and the deadlock was measured on
608
+ * the live hub before it existed. The client re-requests an image only when
609
+ * the frame identity changes, and the identity changes only because the warm
610
+ * loop captured — so 120 s after the last image request the subscription
611
+ * expired, warming stopped, the identity froze, the client never asked again,
612
+ * and the tile was stuck. Ages went 13 s → 49.7 s at exactly t+120 s.
613
+ *
614
+ * The client's `getSnapshotOverview` poll is the heartbeat: asking "what is
615
+ * the state of these cameras" IS the statement that somebody is looking. It
616
+ * deliberately cannot CREATE a subscription, because that poll is app-wide —
617
+ * if it did, opening the app would warm every camera on the install, which is
618
+ * the blind poll this whole design avoids. Only a rendered surface subscribes.
619
+ */
620
+ renew(deviceIds, nowMs) {
621
+ for (const deviceId of deviceIds) for (const entry of this.entries.values()) if (entry.deviceId === deviceId) entry.lastRequestedAt = nowMs;
622
+ }
623
+ /** The (device, stream) pairs still inside the warm window, dropping the rest. */
624
+ active(nowMs, windowMs = WARM_WINDOW_MS) {
625
+ const targets = [];
626
+ for (const [key, entry] of this.entries) {
627
+ if (nowMs - entry.lastRequestedAt >= windowMs) {
628
+ this.entries.delete(key);
629
+ continue;
630
+ }
631
+ for (const [width, at] of entry.widths) if (nowMs - at >= windowMs) entry.widths.delete(width);
632
+ targets.push({
633
+ deviceId: entry.deviceId,
634
+ streamId: entry.streamId,
635
+ widths: [...entry.widths.keys()].sort((a, b) => a - b)
636
+ });
637
+ }
638
+ return targets;
639
+ }
640
+ get size() {
641
+ return this.entries.size;
642
+ }
643
+ clear() {
644
+ this.entries.clear();
645
+ }
646
+ };
647
+ //#endregion
504
648
  //#region src/builtins/snapshot/snapshot.addon.ts
505
649
  /** Default cache window for non-battery cams (seconds). 10s feels live. */
506
650
  var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
@@ -567,8 +711,28 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
567
711
  * strictly-additive delivery path). Disposed on shutdown.
568
712
  */
569
713
  mediaDataPlane = null;
570
- constructor() {
714
+ /**
715
+ * Who is being looked at, inferred from data-plane traffic. The keep-warm
716
+ * loop's entire bound — see `snapshot-keep-warm.ts`.
717
+ */
718
+ subscriptions = new SnapshotSubscriptions();
719
+ /** Chained warm-tick timer (never `setInterval` — see `scheduleWarmTick`). */
720
+ warmTimer = null;
721
+ /** Set while a warm tick is running so ticks can never stack. */
722
+ warmTickRunning = false;
723
+ /** Flipped on shutdown so an in-flight tick does not reschedule itself. */
724
+ stopped = false;
725
+ /**
726
+ * How a full frame becomes a card-sized one. Defaults to the real ffmpeg
727
+ * resize; injectable so the wrapper's own logic is testable without spawning a
728
+ * process for every fixture. `JpegResizer` was declared for exactly this and
729
+ * had no injector — which is why the resize-failure path had never been
730
+ * exercised anywhere but production.
731
+ */
732
+ resize;
733
+ constructor(resize = resizeJpeg) {
571
734
  super({ staleTtlMs: 6e4 });
735
+ this.resize = resize;
572
736
  }
573
737
  async onInitialize() {
574
738
  this.ctx.logger.info("Snapshot wrapper initialized");
@@ -582,6 +746,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
582
746
  getSnapshotOverview: (input) => this.getSnapshotOverview(input)
583
747
  };
584
748
  await this.serveMediaDataPlane();
749
+ this.scheduleWarmTick();
585
750
  return [{
586
751
  capability: require_dist.snapshotCapability,
587
752
  provider
@@ -597,7 +762,10 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
597
762
  * base64 path.
598
763
  */
599
764
  async serveMediaDataPlane() {
600
- const handler = createSnapshotMediaHandler({ getMedia: (deviceId, streamId, force, width) => this.resolveSnapshotMedia(deviceId, streamId, force, width) });
765
+ const handler = createSnapshotMediaHandler({
766
+ getMedia: (deviceId, streamId, force, width) => this.resolveSnapshotMedia(deviceId, streamId, force, width),
767
+ peekFresh: (deviceId, streamId) => this.peekFreshFrame(deviceId, streamId)
768
+ });
601
769
  try {
602
770
  this.mediaDataPlane = await this.ctx.dataPlane?.serve({
603
771
  prefix: "media",
@@ -619,6 +787,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
619
787
  * effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
620
788
  */
621
789
  async resolveSnapshotMedia(deviceId, streamId, force, width) {
790
+ this.subscriptions.note(deviceId, streamId, width, Date.now());
622
791
  const image = await this.getSnapshot({
623
792
  deviceId,
624
793
  ...streamId !== void 0 ? { streamId } : {},
@@ -628,14 +797,128 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
628
797
  const capturedAt = this.cache.get(deviceId, streamId)?.ts ?? Date.now();
629
798
  const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
630
799
  const isBattery = (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false;
800
+ if (width === void 0) return {
801
+ bytes: Buffer.from(image.base64, "base64"),
802
+ contentType: image.contentType,
803
+ capturedAt,
804
+ maxAgeS: effectiveMaxAgeS(prefs, isBattery),
805
+ servedWidth: void 0
806
+ };
807
+ const thumb = await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs);
631
808
  return {
632
- bytes: width === void 0 ? Buffer.from(image.base64, "base64") : await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs),
809
+ bytes: thumb.bytes,
633
810
  contentType: image.contentType,
634
811
  capturedAt,
635
- maxAgeS: effectiveMaxAgeS(prefs, isBattery)
812
+ maxAgeS: effectiveMaxAgeS(prefs, isBattery),
813
+ servedWidth: thumb.width
814
+ };
815
+ }
816
+ /**
817
+ * Cache-only freshness peek for the data-plane's conditional-GET fast path.
818
+ * Never captures, never resizes, never dials anything: a cached frame that is
819
+ * still inside its per-device max-age, or null.
820
+ *
821
+ * Returning null for a STALE entry is the important half. A 304 there would
822
+ * pin the tile to the old frame for as long as the client kept revalidating,
823
+ * which is the exact staleness this change exists to remove.
824
+ */
825
+ async peekFreshFrame(deviceId, streamId) {
826
+ const hit = this.cache.get(deviceId, streamId);
827
+ if (!hit) return null;
828
+ const maxAgeS = effectiveMaxAgeS(await this.readDeviceSettings(deviceId).catch(() => ({})), (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false);
829
+ if (Date.now() - hit.ts >= maxAgeS * 1e3) return null;
830
+ return {
831
+ capturedAt: hit.ts,
832
+ maxAgeS
636
833
  };
637
834
  }
638
835
  /**
836
+ * Schedule the next warm tick. A CHAINED timer, not `setInterval`: the next
837
+ * tick is armed only after the previous one settles, so a fleet that is slower
838
+ * than the interval degrades to "as fast as it can" instead of stacking ticks
839
+ * on top of each other and exhausting the ffmpeg pool.
840
+ *
841
+ * `unref` so the loop never holds the process open.
842
+ */
843
+ scheduleWarmTick() {
844
+ if (this.stopped) return;
845
+ this.warmTimer = setTimeout(() => {
846
+ this.runWarmTick().finally(() => {
847
+ this.scheduleWarmTick();
848
+ });
849
+ }, WARM_INTERVAL_MS);
850
+ this.warmTimer.unref?.();
851
+ }
852
+ /**
853
+ * Refresh the cache for every camera somebody is currently looking at.
854
+ *
855
+ * Deliberately NOT a blind poll of the whole fleet: with nobody watching this
856
+ * walks an empty list and costs nothing, which is the entire difference
857
+ * between this and a background poller.
858
+ *
859
+ * Battery cameras need no special case here and must not get one. A warm tick
860
+ * is a plain non-forced `getSnapshot`, so it meets the two gates that already
861
+ * exist — the 3600 s battery max-age (which makes the tick a cache hit) and
862
+ * the sleeping-battery gate (which refuses to capture without `force`). A
863
+ * second battery check here would be a rule that disagrees with the authority
864
+ * that already owns it.
865
+ */
866
+ async runWarmTick() {
867
+ if (this.warmTickRunning || this.stopped) return;
868
+ this.warmTickRunning = true;
869
+ const logger = this.ctx.logger;
870
+ try {
871
+ const targets = this.subscriptions.active(Date.now());
872
+ if (targets.length === 0) return;
873
+ const failed = (await Promise.allSettled(targets.map((t) => this.warmOne(t, logger)))).filter((r) => r.status === "rejected").length;
874
+ logger.debug("snapshot: keep-warm tick", { meta: {
875
+ watched: targets.length,
876
+ failed
877
+ } });
878
+ } catch (err) {
879
+ logger.warn("snapshot: keep-warm tick failed", { meta: { error: require_dist.errMsg(err) } });
880
+ } finally {
881
+ this.warmTickRunning = false;
882
+ }
883
+ }
884
+ /**
885
+ * Warm one (device, stream): refresh the frame, then pre-derive the widths
886
+ * that were actually requested.
887
+ *
888
+ * Deriving the variants matters as much as the capture. Warming only the full
889
+ * frame would leave every grid tile still paying an ffmpeg resize on the
890
+ * request path — the cache would be fresh and the tile would still be slow.
891
+ */
892
+ async warmOne(target, logger) {
893
+ const work = this.warmOneUnbounded(target, logger);
894
+ work.catch(() => void 0);
895
+ 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 } });
896
+ }
897
+ async warmOneUnbounded(target, logger) {
898
+ const { deviceId, streamId, widths } = target;
899
+ try {
900
+ if (this.stopped) return;
901
+ const image = await this.getSnapshot({
902
+ deviceId,
903
+ ...streamId !== void 0 ? { streamId } : {},
904
+ force: false
905
+ }, WARM_INTERVAL_MS);
906
+ if (!image) return;
907
+ const capturedAt = this.cache.get(deviceId, streamId)?.ts;
908
+ if (capturedAt === void 0) return;
909
+ const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
910
+ for (const width of widths) {
911
+ if (this.stopped) return;
912
+ await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs);
913
+ }
914
+ } catch (err) {
915
+ logger.debug("snapshot: keep-warm failed for device", {
916
+ tags: { deviceId },
917
+ meta: { error: require_dist.errMsg(err) }
918
+ });
919
+ }
920
+ }
921
+ /**
639
922
  * The frame at a card-sized width, derived once per (device, stream, width)
640
923
  * per capture.
641
924
  *
@@ -646,10 +929,13 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
646
929
  */
647
930
  async thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs) {
648
931
  const cached = this.variants.get(deviceId, streamId, width, capturedAt);
649
- if (cached !== void 0) return cached;
932
+ if (cached !== void 0) return {
933
+ bytes: cached,
934
+ width
935
+ };
650
936
  const full = Buffer.from(image.base64, "base64");
651
937
  try {
652
- const resized = await resizeJpeg(full, width);
938
+ const resized = await this.resize(full, width);
653
939
  this.variants.set(deviceId, streamId, width, capturedAt, resized);
654
940
  if (prefs.snapshotDebug) this.ctx.logger.info("snapshot: thumbnail derived", {
655
941
  tags: { deviceId },
@@ -659,7 +945,10 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
659
945
  thumbBytes: resized.length
660
946
  }
661
947
  });
662
- return resized;
948
+ return {
949
+ bytes: resized,
950
+ width
951
+ };
663
952
  } catch (err) {
664
953
  this.ctx.logger.warn("snapshot: resize failed — serving the full frame", {
665
954
  tags: { deviceId },
@@ -669,10 +958,19 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
669
958
  error: require_dist.errMsg(err)
670
959
  }
671
960
  });
672
- return full;
961
+ return {
962
+ bytes: full,
963
+ width: void 0
964
+ };
673
965
  }
674
966
  }
675
967
  async onShutdown() {
968
+ this.stopped = true;
969
+ if (this.warmTimer !== null) {
970
+ clearTimeout(this.warmTimer);
971
+ this.warmTimer = null;
972
+ }
973
+ this.subscriptions.clear();
676
974
  if (this.mediaDataPlane) {
677
975
  await this.mediaDataPlane.dispose().catch(() => void 0);
678
976
  this.mediaDataPlane = null;
@@ -711,7 +1009,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
711
1009
  })]
712
1010
  }] });
713
1011
  }
714
- async getSnapshot(input) {
1012
+ async getSnapshot(input, warmAheadMs = 0) {
715
1013
  const { deviceId } = input;
716
1014
  const force = input.force === true;
717
1015
  const meta = await this.lookupDeviceMeta(deviceId);
@@ -730,7 +1028,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
730
1028
  meta: { stream: effectiveStreamId ?? "auto" }
731
1029
  });
732
1030
  const hit = this.cache.get(deviceId, effectiveStreamId);
733
- const effectiveMaxAgeMs = effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3;
1031
+ const effectiveMaxAgeMs = Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3 - warmAheadMs);
734
1032
  const decision = decideSnapshotServe({
735
1033
  now,
736
1034
  cachedAt: hit?.ts ?? null,
@@ -1092,6 +1390,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
1092
1390
  */
1093
1391
  getSnapshotOverview(input) {
1094
1392
  const now = Date.now();
1393
+ this.subscriptions.renew(input.deviceIds, now);
1095
1394
  const rows = input.deviceIds.map((deviceId) => {
1096
1395
  const hit = this.cache.latest(deviceId);
1097
1396
  if (!hit) return {