@camstack/system 1.1.42 → 1.1.44

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 (48) 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.d.ts +8 -2
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +25 -10
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +25 -10
  8. package/dist/builtins/alerts/alerts.addon.js +1 -1
  9. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  10. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  12. package/dist/builtins/console-logging/index.js +1 -1
  13. package/dist/builtins/console-logging/index.mjs +1 -1
  14. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  15. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  16. package/dist/builtins/hub-forwarder/index.js +1 -1
  17. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  18. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  19. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  20. package/dist/builtins/local-network/local-network.addon.js +1 -1
  21. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  22. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  23. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  24. package/dist/builtins/platform-probe/index.js +1 -1
  25. package/dist/builtins/platform-probe/index.mjs +1 -1
  26. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  27. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  28. package/dist/builtins/snapshot/index.js +337 -27
  29. package/dist/builtins/snapshot/index.mjs +337 -27
  30. package/dist/builtins/snapshot/snapshot-coalescing.d.ts +130 -0
  31. package/dist/builtins/snapshot/snapshot.addon.d.ts +40 -4
  32. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  33. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  34. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  35. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  36. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  37. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  38. package/dist/builtins/system-config/system-config.addon.js +1 -1
  39. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  40. package/dist/builtins/winston-logging/index.js +1 -1
  41. package/dist/builtins/winston-logging/index.mjs +1 -1
  42. package/dist/{dist-BCseJMTD.js → dist-BP89I7gi.js} +33 -2
  43. package/dist/{dist-ClOjKsC4.mjs → dist-C68OyuEd.mjs} +33 -2
  44. package/dist/index.js +2 -2
  45. package/dist/index.mjs +2 -2
  46. package/dist/{manifest-python-deps-BVUZVpma.js → manifest-python-deps-CJn9aExb.js} +1 -1
  47. package/dist/{manifest-python-deps-DyIcZtF5.mjs → manifest-python-deps-MJEshIKy.mjs} +1 -1
  48. package/package.json +1 -1
@@ -3,8 +3,203 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BCseJMTD.js");
6
+ const require_dist = require("../../dist-BP89I7gi.js");
7
7
  let node_child_process = require("node:child_process");
8
+ //#region src/builtins/snapshot/snapshot-coalescing.ts
9
+ /**
10
+ * Pure, side-effect-free coalescing / stale-while-revalidate / bounded-pool
11
+ * primitives for the snapshot wrapper.
12
+ *
13
+ * These are extracted from `SnapshotAddon` so the decision logic is unit-testable
14
+ * without instantiating the addon (no camera, no ffmpeg, no tRPC). The addon wires
15
+ * them to its cache + ladder; this module owns only the mechanics:
16
+ *
17
+ * - {@link decideSnapshotServe} — the pure serve/refresh decision (fresh cache vs
18
+ * stale-while-revalidate vs cold blocking capture) keyed off the cached frame
19
+ * age, the per-device max-age, and whether the caller forced a refresh.
20
+ * - {@link Semaphore} — a counting semaphore bounding concurrent grabs.
21
+ * - {@link SingleFlight} — de-dupes concurrent captures for the same key and
22
+ * retains a SETTLED success for a short hold window so a mount burst collapses
23
+ * into one capture.
24
+ * - {@link raceForResult} — races a promise against a wait bound without
25
+ * cancelling the promise (the loser keeps running in the background).
26
+ */
27
+ /**
28
+ * Retain a SETTLED single-flight capture this long so a grid-mount burst of
29
+ * `getSnapshot` calls (and a row of refresh buttons) collapses into ONE capture
30
+ * instead of one dial per tile. Only success outcomes are held — a null/failed
31
+ * capture expires immediately so a transiently-unreachable camera is retried on
32
+ * the next call rather than pinned blank for the window.
33
+ */
34
+ var COALESCE_MS = 4e3;
35
+ /**
36
+ * Stale-while-revalidate: when a cached frame exists but is past its max-age,
37
+ * start the refresh and wait at most this long for it to land before returning
38
+ * the STALE frame (the refresh keeps running and populates the cache for the
39
+ * next poll). Keeps a grid mount painting instantly from cache.
40
+ */
41
+ var SWR_STALE_WAIT_MS = 2500;
42
+ /**
43
+ * Cold path (no cached frame) or a forced refresh: wait up to this long for a
44
+ * fresh capture. On a cold miss there is nothing to fall back to, so we block
45
+ * (bounded) on the capture; on a forced refresh with a stale frame available we
46
+ * still prefer a fresh one but cap the wait, falling back to stale on timeout.
47
+ */
48
+ var SWR_COLD_WAIT_MS = 1e4;
49
+ /**
50
+ * Pure serve/refresh decision.
51
+ *
52
+ * - fresh cache + !force -> serve-cache
53
+ * - stale cache + !force -> SWR: wait SWR_STALE_WAIT_MS, then stale
54
+ * - no cache + !force -> cold: wait SWR_COLD_WAIT_MS, no fallback
55
+ * - force (cache present or not) -> prefer fresh: wait SWR_COLD_WAIT_MS,
56
+ * fall back to stale only if a frame exists
57
+ */
58
+ function decideSnapshotServe(input) {
59
+ const { now, cachedAt, effectiveMaxAgeMs, force } = input;
60
+ const hasFrame = cachedAt !== null;
61
+ const fresh = hasFrame && now - cachedAt < effectiveMaxAgeMs;
62
+ if (!force && fresh) return { kind: "serve-cache" };
63
+ if (force) return {
64
+ kind: "await-refresh",
65
+ waitMs: SWR_COLD_WAIT_MS,
66
+ staleFallback: hasFrame
67
+ };
68
+ if (hasFrame) return {
69
+ kind: "await-refresh",
70
+ waitMs: SWR_STALE_WAIT_MS,
71
+ staleFallback: true
72
+ };
73
+ return {
74
+ kind: "await-refresh",
75
+ waitMs: SWR_COLD_WAIT_MS,
76
+ staleFallback: false
77
+ };
78
+ }
79
+ /**
80
+ * A counting semaphore. `run` acquires a permit, awaits `fn`, and releases the
81
+ * permit (even if `fn` throws), so at most `max` `fn`s run concurrently. Waiters
82
+ * are served FIFO.
83
+ */
84
+ var Semaphore = class {
85
+ available;
86
+ waiters = [];
87
+ constructor(max) {
88
+ if (!Number.isInteger(max) || max < 1) throw new Error(`Semaphore max must be a positive integer, got ${String(max)}`);
89
+ this.available = max;
90
+ }
91
+ async run(fn) {
92
+ const release = await this.acquire();
93
+ try {
94
+ return await fn();
95
+ } finally {
96
+ release();
97
+ }
98
+ }
99
+ async acquire() {
100
+ if (this.available > 0) {
101
+ this.available -= 1;
102
+ return this.makeReleaser();
103
+ }
104
+ await new Promise((resolve) => this.waiters.push(resolve));
105
+ return this.makeReleaser();
106
+ }
107
+ makeReleaser() {
108
+ let released = false;
109
+ return () => {
110
+ if (released) return;
111
+ released = true;
112
+ const next = this.waiters.shift();
113
+ if (next) {
114
+ next();
115
+ return;
116
+ }
117
+ this.available += 1;
118
+ };
119
+ }
120
+ };
121
+ /**
122
+ * One in-flight capture per key. A settled SUCCESS is retained for `holdMs` so a
123
+ * burst collapses into one capture; a settled failure/empty expires immediately
124
+ * so the next caller retries. Concurrent callers ALWAYS join the running flight
125
+ * regardless of eventual outcome.
126
+ */
127
+ var Flight = class {
128
+ promise;
129
+ settledAt = null;
130
+ hold = false;
131
+ constructor(factory, now, holdWorthy) {
132
+ this.promise = (async () => {
133
+ try {
134
+ const value = await factory();
135
+ this.hold = holdWorthy(value);
136
+ return value;
137
+ } catch (err) {
138
+ this.hold = false;
139
+ throw err;
140
+ } finally {
141
+ this.settledAt = now();
142
+ }
143
+ })();
144
+ }
145
+ };
146
+ var SingleFlight = class {
147
+ holdMs;
148
+ holdWorthy;
149
+ now;
150
+ flights = /* @__PURE__ */ new Map();
151
+ /**
152
+ * @param holdMs how long a settled hold-worthy result is reused
153
+ * @param holdWorthy predicate deciding whether a settled value is worth holding
154
+ * (e.g. a successful capture with a real frame)
155
+ * @param now clock injection for tests
156
+ */
157
+ constructor(holdMs, holdWorthy, now = () => Date.now()) {
158
+ this.holdMs = holdMs;
159
+ this.holdWorthy = holdWorthy;
160
+ this.now = now;
161
+ }
162
+ run(key, factory) {
163
+ const existing = this.flights.get(key);
164
+ if (existing && this.isReusable(existing)) return existing.promise;
165
+ const flight = new Flight(factory, this.now, this.holdWorthy);
166
+ this.flights.set(key, flight);
167
+ return flight.promise;
168
+ }
169
+ /** Drop every held flight whose key starts with `prefix` (e.g. `"<deviceId>:"`). */
170
+ invalidatePrefix(prefix) {
171
+ for (const key of [...this.flights.keys()]) if (key.startsWith(prefix)) this.flights.delete(key);
172
+ }
173
+ clear() {
174
+ this.flights.clear();
175
+ }
176
+ isReusable(flight) {
177
+ if (flight.settledAt === null) return true;
178
+ if (!flight.hold) return false;
179
+ return this.now() - flight.settledAt < this.holdMs;
180
+ }
181
+ };
182
+ /**
183
+ * Race `promise` against a `timeoutMs` bound WITHOUT cancelling it — on timeout
184
+ * the promise keeps running (its result lands in the cache for the next poll).
185
+ * Rejections propagate: if `promise` rejects before the timeout, this rejects.
186
+ * The caller passes a promise that never rejects (a settled outcome union) when
187
+ * background continuation must stay unhandled-safe.
188
+ */
189
+ function raceForResult(promise, timeoutMs) {
190
+ let timer;
191
+ const timeout = new Promise((resolve) => {
192
+ timer = setTimeout(() => resolve({ settled: false }), timeoutMs);
193
+ });
194
+ const settled = promise.then((value) => ({
195
+ settled: true,
196
+ value
197
+ }));
198
+ return Promise.race([settled, timeout]).finally(() => {
199
+ if (timer) clearTimeout(timer);
200
+ });
201
+ }
202
+ //#endregion
8
203
  //#region src/builtins/snapshot/snapshot.addon.ts
9
204
  /** Default cache window for non-battery cams (seconds). 10s feels live. */
10
205
  var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
@@ -31,6 +226,16 @@ var BATTERY_DEFAULT_MAX_AGE_S = 3600;
31
226
  var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
32
227
  cache = /* @__PURE__ */ new Map();
33
228
  /**
229
+ * De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
230
+ * settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
231
+ * buttons) collapses into one capture instead of one dial per tile.
232
+ */
233
+ captureFlight = new SingleFlight(COALESCE_MS, (outcome) => outcome.ok && outcome.image !== null);
234
+ /** Bounds simultaneous ffmpeg keyframe grabs (the wrapper path — common case). */
235
+ grabPool = new Semaphore(3);
236
+ /** Bounds simultaneous native (vendor HTTP/ONVIF) snapshot fetches. */
237
+ nativePool = new Semaphore(6);
238
+ /**
34
239
  * Cached resolution of `pipelineOrchestrator.getIngestOwner` — SnapshotAddon
35
240
  * has no per-request attach hook to resolve this fresh, so it's cached with
36
241
  * a short TTL and invalidated on `onConfigChanged` (covers the common case
@@ -53,12 +258,14 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
53
258
  getDeviceSettingsContribution: (input) => this.buildDeviceSettingsContribution(input.deviceId),
54
259
  getDeviceLiveContribution: async () => null,
55
260
  applyDeviceSettingsPatch: (input) => this.saveDeviceSettingsPatch(input.deviceId, input.patch),
56
- getStatus: async (input) => this.getStatus(input.deviceId)
261
+ getStatus: async (input) => this.getStatus(input.deviceId),
262
+ getSnapshotOverview: (input) => this.getSnapshotOverview(input)
57
263
  }
58
264
  }];
59
265
  }
60
266
  async onShutdown() {
61
267
  this.cache.clear();
268
+ this.captureFlight.clear();
62
269
  this.ownerCache = null;
63
270
  }
64
271
  /**
@@ -91,7 +298,8 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
91
298
  }] });
92
299
  }
93
300
  async getSnapshot(input) {
94
- const { deviceId, force } = input;
301
+ const { deviceId } = input;
302
+ const force = input.force === true;
95
303
  const meta = await this.lookupDeviceMeta(deviceId);
96
304
  const deviceName = meta?.name;
97
305
  const isBatteryDevice = meta?.isBattery ?? false;
@@ -110,8 +318,14 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
110
318
  });
111
319
  const defaultMaxAgeS = isBatteryDevice ? BATTERY_DEFAULT_MAX_AGE_S : NON_BATTERY_DEFAULT_MAX_AGE_S;
112
320
  const effectiveMaxAgeMs = (typeof prefs.snapshotMaxAgeS === "number" && prefs.snapshotMaxAgeS >= 0 ? prefs.snapshotMaxAgeS : defaultMaxAgeS) * 1e3;
113
- if (!force && hit && now - hit.ts < effectiveMaxAgeMs) {
114
- if (prefs.snapshotDebug) log.debug("snapshot: cache hit", {
321
+ const decision = decideSnapshotServe({
322
+ now,
323
+ cachedAt: hit?.ts ?? null,
324
+ effectiveMaxAgeMs,
325
+ force
326
+ });
327
+ if (decision.kind === "serve-cache") {
328
+ if (prefs.snapshotDebug && hit) log.debug("snapshot: cache hit", {
115
329
  tags: { deviceId },
116
330
  meta: {
117
331
  ageMs: now - hit.ts,
@@ -119,21 +333,97 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
119
333
  isBattery: isBatteryDevice
120
334
  }
121
335
  });
336
+ return hit ? hit.data : null;
337
+ }
338
+ const flightKey = `${deviceId}:${effectiveStreamId ?? "auto"}`;
339
+ const flight = this.captureFlight.run(flightKey, () => this.captureFresh({
340
+ deviceId,
341
+ input,
342
+ effectiveStreamId,
343
+ isBatteryDevice,
344
+ log
345
+ }));
346
+ flight.catch(() => void 0);
347
+ const raced = await raceForResult(flight, decision.waitMs);
348
+ if (raced.settled) return this.resolveOutcome(raced.value, deviceId, hit, log);
349
+ if (decision.staleFallback && hit) {
350
+ if (prefs.snapshotDebug) log.debug("snapshot: SWR — returning stale frame; refresh continues in background", {
351
+ tags: { deviceId },
352
+ meta: {
353
+ ageMs: now - hit.ts,
354
+ waitMs: decision.waitMs
355
+ }
356
+ });
357
+ return hit.data;
358
+ }
359
+ return null;
360
+ }
361
+ /**
362
+ * Map a settled capture outcome onto the response, preserving the legacy
363
+ * semantics: a fresh frame wins; a soft miss (native null + broker null)
364
+ * falls back to stale cache (or null); a HARD native error with no frame
365
+ * and no cache propagates.
366
+ */
367
+ resolveOutcome(outcome, deviceId, hit, log) {
368
+ if (outcome.ok) {
369
+ if (outcome.image) return outcome.image;
370
+ if (hit) {
371
+ const ageMs = Date.now() - hit.ts;
372
+ if (ageMs > this.config.staleTtlMs) log.warn("snapshot: all live paths failed — serving stale cache", {
373
+ tags: { deviceId },
374
+ meta: { ageMs }
375
+ });
376
+ return hit.data;
377
+ }
378
+ return null;
379
+ }
380
+ if (hit) {
381
+ const ageMs = Date.now() - hit.ts;
382
+ if (ageMs > this.config.staleTtlMs) log.warn("snapshot: native failed — serving stale cache", {
383
+ tags: { deviceId },
384
+ meta: { ageMs }
385
+ });
122
386
  return hit.data;
123
387
  }
388
+ throw outcome.error;
389
+ }
390
+ /**
391
+ * Run the capture ladder ONCE for a device: native provider first, then the
392
+ * stream-broker ffmpeg fallback. Never rejects — resolves a {@link
393
+ * CaptureOutcome}. On a produced frame it populates the cache (so a
394
+ * background SWR refresh lands for the next poll) and returns it. A hard
395
+ * native error with no broker frame resolves `{ ok:false }` so the caller
396
+ * can surface it; a soft miss resolves `{ ok:true, image:null }`.
397
+ *
398
+ * TODO(decoded-frame fast path): for a device whose detection pipeline is
399
+ * already decoding (the always-on subscription, see docs/design/decode-path.md),
400
+ * the freshest decoded frame is already in the shared-memory ring — a single
401
+ * JPEG encode of the latest FrameHandle would cost zero dials and beat the
402
+ * ffmpeg grab for the wrapper fleet. Slotting it as native -> latest-decoded
403
+ * -> broker needs cross-addon plumbing not cheaply reachable from the hub
404
+ * builtin today: frames are session-scoped (decoder `pullHandles`/`getFrame`
405
+ * need the device's active session id) and raw-pixel (need a JPEG encoder the
406
+ * builtin lacks). Deferred as its own slice.
407
+ */
408
+ async captureFresh(args) {
409
+ const { deviceId, input, effectiveStreamId, isBatteryDevice, log } = args;
410
+ const now = Date.now();
124
411
  let nativeError = null;
125
412
  let nativeAbsent = false;
126
413
  try {
127
414
  const native = this.ctx.getNativeProvider(require_dist.snapshotCapability, deviceId);
128
415
  if (native) {
129
- const result = await native.getSnapshot(input);
416
+ const result = await this.nativePool.run(() => native.getSnapshot(input));
130
417
  if (result) {
131
418
  this.cache.set(deviceId, {
132
419
  data: result,
133
420
  ts: now,
134
421
  streamId: effectiveStreamId ?? null
135
422
  });
136
- return result;
423
+ return {
424
+ ok: true,
425
+ image: result
426
+ };
137
427
  }
138
428
  log.debug("native snapshot returned null — falling through to broker", { tags: { deviceId } });
139
429
  } else {
@@ -164,7 +454,10 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
164
454
  ts: now,
165
455
  streamId: effectiveStreamId ?? null
166
456
  });
167
- return fallback;
457
+ return {
458
+ ok: true,
459
+ image: fallback
460
+ };
168
461
  }
169
462
  } catch (err) {
170
463
  log.warn("stream-broker snapshot fallback failed", {
@@ -173,25 +466,16 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
173
466
  });
174
467
  }
175
468
  else log.debug("snapshot: skipping broker fallback — battery device with absent native and no streaming broker", { tags: { deviceId } });
176
- if (hit) {
177
- const ageMs = now - hit.ts;
178
- if (ageMs > this.config.staleTtlMs) log.warn("snapshot: all live paths failed — serving stale cache", {
179
- tags: { deviceId },
180
- meta: { ageMs }
181
- });
182
- return hit.data;
183
- }
184
- if (nativeError) throw nativeError;
185
- if (nativeAbsent) return null;
186
- return null;
469
+ if (nativeError) return {
470
+ ok: false,
471
+ error: nativeError
472
+ };
473
+ return {
474
+ ok: true,
475
+ image: null
476
+ };
187
477
  }
188
478
  /**
189
- * Tell apart "native provider isn't registered for this device" from
190
- * "native provider ran and threw a real error". The former is the steady
191
- * state for cameras without a vendor snapshot endpoint and should not
192
- * propagate as a 500; the latter should.
193
- */
194
- /**
195
479
  * Pull one JPEG from the device's stream-broker RTSP restream using
196
480
  * a short-lived ffmpeg invocation.
197
481
  *
@@ -296,12 +580,12 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
296
580
  async runGrabWithResumeRetry(url, deviceId) {
297
581
  let buf;
298
582
  try {
299
- buf = await runFfmpegFrameGrab(url, 15e3);
583
+ buf = await this.grabPool.run(() => runFfmpegFrameGrab(url, 15e3));
300
584
  } catch (err) {
301
585
  if (isBrokerColdError(require_dist.errMsg(err))) {
302
586
  this.ctx.logger.debug("grabFrame: broker-resume race — retrying in 1500ms", { tags: { deviceId } });
303
587
  await new Promise((r) => setTimeout(r, 1500));
304
- buf = await runFfmpegFrameGrab(url, 15e3);
588
+ buf = await this.grabPool.run(() => runFfmpegFrameGrab(url, 15e3));
305
589
  } else throw err;
306
590
  }
307
591
  if (buf.length === 0) return null;
@@ -312,6 +596,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
312
596
  }
313
597
  async invalidateCache(input) {
314
598
  this.cache.delete(input.deviceId);
599
+ this.captureFlight.invalidatePrefix(`${input.deviceId}:`);
315
600
  }
316
601
  /**
317
602
  * Non-throwing probe of the device's battery cap. Returns true only
@@ -351,6 +636,30 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
351
636
  }
352
637
  }
353
638
  /**
639
+ * Cache-only batch overview — O(n) over the in-memory cache, NEVER triggers
640
+ * a capture. Lets a grid skip requesting images for devices that never
641
+ * produced a frame, and hands each an ETag for conditional image fetches.
642
+ */
643
+ getSnapshotOverview(input) {
644
+ const now = Date.now();
645
+ const rows = input.deviceIds.map((deviceId) => {
646
+ const hit = this.cache.get(deviceId);
647
+ if (!hit) return {
648
+ deviceId,
649
+ lastCapturedAt: null,
650
+ cacheAgeMs: null,
651
+ etag: null
652
+ };
653
+ return {
654
+ deviceId,
655
+ lastCapturedAt: hit.ts,
656
+ cacheAgeMs: now - hit.ts,
657
+ etag: `"${deviceId}-${hit.ts}"`
658
+ };
659
+ });
660
+ return Promise.resolve(rows);
661
+ }
662
+ /**
354
663
  * Diagnostic status for the `status` auto-injected cap method. Reports
355
664
  * the cache bookkeeping for this device — when the last snapshot was
356
665
  * captured, how stale the cached image is, its size, and which stream
@@ -507,6 +816,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
507
816
  }
508
817
  await this.ctx.settings.writeDeviceStore(deviceId, next);
509
818
  this.cache.delete(deviceId);
819
+ this.captureFlight.invalidatePrefix(`${deviceId}:`);
510
820
  return { success: true };
511
821
  }
512
822
  };