@camstack/system 1.2.78 → 1.2.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  8. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  9. package/dist/builtins/console-logging/index.js +1 -1
  10. package/dist/builtins/console-logging/index.mjs +1 -1
  11. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  12. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  13. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  14. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  15. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  16. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  17. package/dist/builtins/hub-forwarder/index.js +1 -1
  18. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  19. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  20. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  21. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  22. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  23. package/dist/builtins/local-network/local-network.addon.js +1 -1
  24. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  25. package/dist/builtins/loki-logging/index.js +1 -1
  26. package/dist/builtins/loki-logging/index.mjs +1 -1
  27. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  28. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  29. package/dist/builtins/platform-probe/index.js +1 -1
  30. package/dist/builtins/platform-probe/index.mjs +1 -1
  31. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  32. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  33. package/dist/builtins/snapshot/index.js +253 -296
  34. package/dist/builtins/snapshot/index.mjs +252 -296
  35. package/dist/builtins/snapshot/snapshot-courtesy.d.ts +36 -0
  36. package/dist/builtins/snapshot/snapshot-resize.d.ts +43 -11
  37. package/dist/builtins/snapshot/snapshot.addon.d.ts +23 -45
  38. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  39. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  40. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  41. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  42. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  44. package/dist/builtins/system-config/system-config.addon.js +1 -1
  45. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  46. package/dist/builtins/winston-logging/index.js +1 -1
  47. package/dist/builtins/winston-logging/index.mjs +1 -1
  48. package/dist/{dist-cINpngi1.js → dist-CA2mim74.js} +48 -16
  49. package/dist/{dist-CIIFhXqm.mjs → dist-DZOZtn-0.mjs} +48 -16
  50. package/dist/index.js +1 -1
  51. package/dist/index.mjs +1 -1
  52. package/package.json +1 -1
  53. package/dist/builtins/snapshot/snapshot-keep-warm.d.ts +0 -86
@@ -0,0 +1,36 @@
1
+ /** Why a camera cannot produce a frame right now. */
2
+ export type CourtesyReason = 'disabled' | 'offline' | 'sleeping';
3
+ /** Default tile size when the caller asks for no particular width. */
4
+ export declare const COURTESY_DEFAULT_WIDTH = 640;
5
+ /** The word the frame carries. Deliberately the operator's vocabulary. */
6
+ export declare function courtesyLabel(reason: CourtesyReason): string;
7
+ /**
8
+ * Background per reason. A disabled camera is a DELIBERATE state and must not
9
+ * read as an alarm, so it is neutral grey; offline is a fault and is warmer.
10
+ */
11
+ export declare function courtesyBackground(reason: CourtesyReason): string;
12
+ /** XML escaping for text placed inside an SVG `<text>` node. */
13
+ export declare function escapeCourtesyText(value: string): string;
14
+ export interface CourtesyFrameSpec {
15
+ readonly deviceName: string;
16
+ readonly reason: CourtesyReason;
17
+ readonly width: number;
18
+ readonly height: number;
19
+ }
20
+ /**
21
+ * The SVG the frame is rasterized from: the state large and centred, the
22
+ * camera's name under it, smaller and dimmer.
23
+ *
24
+ * Sizes derive from the width so a 240 px grid tile and a 1920 px full-bleed
25
+ * frame read the same, with a floor so a thumbnail stays legible.
26
+ */
27
+ export declare function buildCourtesySvg(spec: CourtesyFrameSpec): string;
28
+ /** Cache key — a courtesy frame is a pure function of these four. */
29
+ export declare function courtesyCacheKey(spec: CourtesyFrameSpec): string;
30
+ /**
31
+ * Render the frame.
32
+ *
33
+ * Rejects on failure — the caller decides what to do, exactly as `resizeJpeg`
34
+ * does, so a broken courtesy path is never mistaken for a broken camera.
35
+ */
36
+ export declare function renderCourtesyJpeg(spec: CourtesyFrameSpec): Promise<Buffer>;
@@ -7,24 +7,56 @@
7
7
  * ffmpeg filter chain to hook into. Resizing the finished bytes gives both the
8
8
  * same behaviour with one implementation.
9
9
  *
10
- * The cost is one ffmpeg run per (device, stream, width) per cache window — not
11
- * one per request. A camera whose card is on screen pays it once every
12
- * `snapshotMaxAgeS`.
10
+ * ── Why sharp and not ffmpeg (2026-08-11) ─────────────────────────────────
11
+ * This used to `spawn('ffmpeg')` per resize. The snapshot wrapper is a system
12
+ * builtin, so it loads in the hub's ROOT process: sampled on the live hub,
13
+ * ~1.6 of those children were running at any instant, every one of them
14
+ * parented by the root PID.
13
15
  *
14
- * `ffmpeg` is already this addon's dependency (the keyframe grab shells out to
15
- * it), so this adds no new one.
16
+ * Be precise about what that cost the root process, because it is easy to
17
+ * overstate. The transcode itself ran in the CHILD and was charged to ffmpeg
18
+ * (16 % + 13 % of a core in that same sample), not to the parent. What the
19
+ * parent paid was the fork, the exec, the JPEG written into one pipe and read
20
+ * back out of the other, and the base64 — real event-loop work, on the loop
21
+ * that also serves the tRPC API, but NOT the 52 ms below.
22
+ *
23
+ * Benchmarked in that container, 2560×1440 → 640 wide, wall-clock per resize:
24
+ *
25
+ * ffmpeg 52.0 ms (fork + exec + pipe round-trip + teardown)
26
+ * sharp 4.8 ms (in-process libvips, on its own threadpool)
27
+ *
28
+ * So this removes ~29 % of a core of container CPU and the per-resize process
29
+ * churn from the root process. It was NOT verified to be the cause of that
30
+ * process sitting at 90 % — nobody has profiled it.
31
+ *
32
+ * `sharp` is not a new dependency: it is already a host-external
33
+ * (`HOST_EXTERNAL_SPECIFIERS`), already resolved from the framework closure at
34
+ * runtime, and already used by the terminal frame renderer. The build preset
35
+ * keeps it out of the bundle, so this import costs nothing at pack time.
36
+ *
37
+ * The cost is still one resize per (device, stream, width) per cache window —
38
+ * not one per request. What changed is what a resize COSTS.
16
39
  */
17
40
  /** Runs the resize. Injected so the caller is testable without a real ffmpeg. */
18
41
  export type JpegResizer = (bytes: Buffer, width: number) => Promise<Buffer>;
19
42
  export declare const RESIZE_TIMEOUT_MS = 10000;
20
43
  /**
21
- * Scale to `width`, preserving aspect ratio (`-2` keeps the height even, which
22
- * mjpeg requires).
44
+ * Scale to `width`, preserving aspect ratio.
45
+ *
46
+ * Rejects on failure or timeout. It NEVER falls back to the original silently —
47
+ * a caller that quietly served the 4K frame when the resize failed would
48
+ * reproduce exactly the bug this whole module exists to fix, and nobody would
49
+ * see it. The caller decides, and logs.
23
50
  *
24
- * Rejects on a non-zero exit or a timeout. It NEVER falls back to the original
25
- * silently a caller that quietly served the 4K frame when the resize failed
26
- * would reproduce exactly the bug this whole change exists to fix, and nobody
27
- * would see it. The caller decides, and logs.
51
+ * It DOES upscale a source narrower than `width`, and that is deliberate. A
52
+ * `withoutEnlargement: true` was tried first it is the obvious saving, since
53
+ * upscaling pays encode cost for a blurrier, larger image. But the caller
54
+ * stamps the response ETag from the width it asked for, and its resize-FAILURE
55
+ * path already returns `width: undefined` specifically so a response can never
56
+ * "claim a width the bytes do not have". Silently returning 320 px bytes for a
57
+ * `w=640` request breaks that invariant on the SUCCESS path, where nobody is
58
+ * looking. Honest output width beats a few saved pixels; revisit only together
59
+ * with the ETag.
28
60
  */
29
61
  export declare function resizeJpeg(bytes: Buffer, width: number, timeoutMs?: number): Promise<Buffer>;
30
62
  /**
@@ -34,6 +34,15 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
34
34
  /** Card-sized derivatives of the frames above, keyed by width and validated
35
35
  * against the source frame's timestamp. */
36
36
  private readonly variants;
37
+ /**
38
+ * Rendered courtesy frames, by (reason, size, device name).
39
+ *
40
+ * A courtesy frame is a pure function of those three, so it is rendered once
41
+ * and reused for as long as the process lives — a disabled camera must not
42
+ * cost an ffmpeg run per poll. Unbounded on purpose: the key space is
43
+ * (3 reasons × this node's own cameras), not user input.
44
+ */
45
+ private readonly courtesyFrames;
37
46
  /**
38
47
  * De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
39
48
  * settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
@@ -72,17 +81,6 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
72
81
  * secret that rolled on every boot would invalidate every link a client is
73
82
  * currently holding, which is a fleet-wide blank grid after each restart. */
74
83
  private linkSecret;
75
- /**
76
- * Who is being looked at, inferred from data-plane traffic. The keep-warm
77
- * loop's entire bound — see `snapshot-keep-warm.ts`.
78
- */
79
- private readonly subscriptions;
80
- /** Chained warm-tick timer (never `setInterval` — see `scheduleWarmTick`). */
81
- private warmTimer;
82
- /** Set while a warm tick is running so ticks can never stack. */
83
- private warmTickRunning;
84
- /** Flipped on shutdown so an in-flight tick does not reschedule itself. */
85
- private stopped;
86
84
  /**
87
85
  * How a full frame becomes a card-sized one. Defaults to the real ffmpeg
88
86
  * resize; injectable so the wrapper's own logic is testable without spawning a
@@ -190,40 +188,6 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
190
188
  * which is the exact staleness this change exists to remove.
191
189
  */
192
190
  private peekFreshFrame;
193
- /**
194
- * Schedule the next warm tick. A CHAINED timer, not `setInterval`: the next
195
- * tick is armed only after the previous one settles, so a fleet that is slower
196
- * than the interval degrades to "as fast as it can" instead of stacking ticks
197
- * on top of each other and exhausting the ffmpeg pool.
198
- *
199
- * `unref` so the loop never holds the process open.
200
- */
201
- private scheduleWarmTick;
202
- /**
203
- * Refresh the cache for every camera somebody is currently looking at.
204
- *
205
- * Deliberately NOT a blind poll of the whole fleet: with nobody watching this
206
- * walks an empty list and costs nothing, which is the entire difference
207
- * between this and a background poller.
208
- *
209
- * Battery cameras need no special case here and must not get one. A warm tick
210
- * is a plain non-forced `getSnapshot`, so it meets the two gates that already
211
- * exist — the 3600 s battery max-age (which makes the tick a cache hit) and
212
- * the sleeping-battery gate (which refuses to capture without `force`). A
213
- * second battery check here would be a rule that disagrees with the authority
214
- * that already owns it.
215
- */
216
- private runWarmTick;
217
- /**
218
- * Warm one (device, stream): refresh the frame, then pre-derive the widths
219
- * that were actually requested.
220
- *
221
- * Deriving the variants matters as much as the capture. Warming only the full
222
- * frame would leave every grid tile still paying an ffmpeg resize on the
223
- * request path — the cache would be fresh and the tile would still be slow.
224
- */
225
- private warmOne;
226
- private warmOneUnbounded;
227
191
  /**
228
192
  * The frame at a card-sized width, derived once per (device, stream, width)
229
193
  * per capture.
@@ -378,6 +342,20 @@ export declare class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {
378
342
  private getStatus;
379
343
  private readDeviceSettings;
380
344
  private buildDeviceSettingsContribution;
345
+ /**
346
+ * The frame a camera that CANNOT capture answers with.
347
+ *
348
+ * Returns null only if rendering itself fails — the caller then falls through
349
+ * to its previous behaviour, so a broken courtesy path degrades to the old
350
+ * 404 rather than masking a real capture failure as a placeholder.
351
+ *
352
+ * Rendered at the full tile size; the existing resize ladder derives the
353
+ * card-sized variants from it exactly as it does for a real frame, so no
354
+ * downstream consumer needs to know this frame is synthetic.
355
+ */
356
+ private courtesyFrame;
357
+ /** A courtesy frame shaped as the cap's response, or null when unrenderable. */
358
+ private courtesyImage;
381
359
  /**
382
360
  * Single-trip device lookup against device-manager. Returns the
383
361
  * fields the wrapper actually consults — name (logging) + battery
@@ -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-cINpngi1.js");
6
+ const require_dist = require("../../dist-CA2mim74.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let node_fs_promises = require("node:fs/promises");
9
9
  let node_path = require("node:path");
@@ -1,4 +1,4 @@
1
- import { gt as BaseAddon, ut as storageProviderCapability, z as filesystemBrowseCapability } from "../../dist-CIIFhXqm.mjs";
1
+ import { gt as BaseAddon, ut as storageProviderCapability, z as filesystemBrowseCapability } from "../../dist-DZOZtn-0.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { mkdir, readdir, realpath, statfs } from "node:fs/promises";
4
4
  import * as path$1 from "node:path";
@@ -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-cINpngi1.js");
6
+ const require_dist = require("../../dist-CA2mim74.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let node_fs = require("node:fs");
9
9
  let node_module = require("node:module");
@@ -1,4 +1,4 @@
1
- import { A as decodeVectorBase64, Et as asJsonObject, Lt as parseJsonUnknown, f as RUNTIME_DEFAULTS, gt as BaseAddon, ht as errMsg, k as dataStoreProviderCapability, mt as vectorStoreCapability, pt as vectorDimFromBase64, w as bareAddonId } from "../../dist-CIIFhXqm.mjs";
1
+ import { A as decodeVectorBase64, Et as asJsonObject, Lt as parseJsonUnknown, f as RUNTIME_DEFAULTS, gt as BaseAddon, ht as errMsg, k as dataStoreProviderCapability, mt as vectorStoreCapability, pt as vectorDimFromBase64, w as bareAddonId } from "../../dist-DZOZtn-0.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { statSync } from "node:fs";
@@ -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-cINpngi1.js");
6
+ const require_dist = require("../../dist-CA2mim74.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let node_fs_promises = require("node:fs/promises");
9
9
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
@@ -1,4 +1,4 @@
1
- import { It as parseJsonObject, ct as storageCapability, g as StorageMigrationJobSchema, gt as BaseAddon, h as StorageLocationTypeSchema, lt as storageMigrationCapability, ot as settingsStoreCapability } from "../../dist-CIIFhXqm.mjs";
1
+ import { It as parseJsonObject, ct as storageCapability, g as StorageMigrationJobSchema, gt as BaseAddon, h as StorageLocationTypeSchema, lt as storageMigrationCapability, ot as settingsStoreCapability } from "../../dist-DZOZtn-0.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path$1 from "node:path";
@@ -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-cINpngi1.js");
6
+ const require_dist = require("../../dist-CA2mim74.js");
7
7
  //#region src/builtins/system-config/system-config.addon.ts
8
8
  /**
9
9
  * Built-in `system-config` addon — Phase 4 of the settings redesign.
@@ -1,4 +1,4 @@
1
- import { Nt as hydrateSchema, gt as BaseAddon, ht as errMsg } from "../../dist-CIIFhXqm.mjs";
1
+ import { Nt as hydrateSchema, gt as BaseAddon, ht as errMsg } from "../../dist-DZOZtn-0.mjs";
2
2
  //#region src/builtins/system-config/system-config.addon.ts
3
3
  /**
4
4
  * Built-in `system-config` addon — Phase 4 of the settings redesign.
@@ -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-cINpngi1.js");
6
+ const require_dist = require("../../dist-CA2mim74.js");
7
7
  const require_formatter = require("../../formatter-DqAKDlvN.js");
8
8
  let node_path = require("node:path");
9
9
  node_path = require_chunk.__toESM(node_path);
@@ -1,4 +1,4 @@
1
- import { J as logDestinationCapability, gt as BaseAddon } from "../../dist-CIIFhXqm.mjs";
1
+ import { J as logDestinationCapability, gt as BaseAddon } from "../../dist-DZOZtn-0.mjs";
2
2
  import { t as formatLogLine } from "../../formatter-B7qW8bPJ.mjs";
3
3
  import * as path$1 from "node:path";
4
4
  import path from "node:path";
@@ -17500,24 +17500,28 @@ var snapshotCapability = {
17500
17500
  *
17501
17501
  * `getSnapshotOverview` is cache-only by contract: it answers from whatever
17502
17502
  * the wrapper happens to hold and never captures. Under D93 the client
17503
- * versions its image URL on that answer, and an image REQUEST is what enrols
17504
- * a camera in the keep-warm loop. Both of those are satisfiable by the
17505
- * client's own image cache — `expo-image` is URL-keyed and never revalidates
17506
- * — so a URL painted in a previous session comes off disk with no network,
17507
- * no enrolment, and nothing warming. Measured on the live hub: reopening
17508
- * after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
17509
- * HTTP requests, and the fleet only recovered because a later poll happened
17510
- * to observe a different identity.
17503
+ * versions its image URL on that answer, and an image REQUEST was the only
17504
+ * demand signal. Both of those are satisfiable by the client's own image
17505
+ * cache — `expo-image` is URL-keyed and never revalidates — so a URL painted
17506
+ * in a previous session comes off disk with no network, no demand, and no
17507
+ * capture. Measured on the live hub: reopening after two minutes idle
17508
+ * painted 15 of 16 tiles at **168 s old** with zero HTTP requests, and the
17509
+ * fleet only recovered because a later poll happened to observe a different
17510
+ * identity.
17511
17511
  *
17512
17512
  * ## The two properties that fix it
17513
17513
  *
17514
17514
  * **It is an RPC, so no client cache can answer it.** The demand signal
17515
- * always reaches the wrapper. This method therefore MAY create keep-warm
17516
- * subscriptions, where `getSnapshotOverview` must never (D93) — the
17517
- * distinction is not "one is newer" but that the overview poll is app-wide
17518
- * (a creating overview would warm every camera on the install) while this is
17519
- * called by a rendered surface naming the tiles it is actually painting, at
17520
- * the width it is painting them.
17515
+ * always reaches the wrapper. This method therefore CAPTURES, where
17516
+ * `getSnapshotOverview` must never (D93) — the distinction is not "one is
17517
+ * newer" but that the overview poll is app-wide (a capturing overview would
17518
+ * dial every camera on the install) while this is called by a rendered
17519
+ * surface naming the tiles it is actually painting, at the width it is
17520
+ * painting them.
17521
+ *
17522
+ * Since 2026-08-11 this is the ONLY thing that refreshes a snapshot: the
17523
+ * server-side keep-warm loop was removed (operator directive — on-demand,
17524
+ * always), so a camera nobody is looking at costs nothing at all.
17521
17525
  *
17522
17526
  * **It waits, briefly and boundedly, for the capture it triggered.** The
17523
17527
  * returned `capturedAt` is the frame the link will serve, not the frame the
@@ -27344,9 +27348,10 @@ var DeclaredDevices = class {
27344
27348
  }
27345
27349
  const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
27346
27350
  const index = await this.readIndex();
27351
+ const live = await this.readLiveByStableId();
27347
27352
  const outcomes = [];
27348
27353
  for (const declaration of spec.devices) {
27349
- const outcome = await this.applyDeclaration(declaration, integrationId, index);
27354
+ const outcome = await this.applyDeclaration(declaration, integrationId, index, live);
27350
27355
  if (outcome !== null) outcomes.push(outcome);
27351
27356
  }
27352
27357
  return {
@@ -27392,6 +27397,26 @@ var DeclaredDevices = class {
27392
27397
  return new Map(rows.map((row) => [row.stableId, row]));
27393
27398
  }
27394
27399
  /**
27400
+ * Devices this kernel already has CONSTRUCTED, by stableId.
27401
+ *
27402
+ * Distinct from {@link readIndex}, and the distinction is the bug: the index
27403
+ * is persisted rows, this is live objects. A row without an object must be
27404
+ * adopted; an object must be left exactly as it is.
27405
+ *
27406
+ * Failure is non-fatal and deliberately so — an empty map degrades to the
27407
+ * previous behaviour (attempt the adopt) rather than skipping a device that
27408
+ * genuinely needs bringing up.
27409
+ */
27410
+ async readLiveByStableId() {
27411
+ try {
27412
+ const devices = await this.ports.devices.getAll();
27413
+ return new Map(devices.map((device) => [device.stableId, device]));
27414
+ } catch (err) {
27415
+ this.ports.logger.warn("could not read live devices — falling back to adopt-by-row", { meta: { error: err instanceof Error ? err.message : String(err) } });
27416
+ return /* @__PURE__ */ new Map();
27417
+ }
27418
+ }
27419
+ /**
27395
27420
  * One declaration: adopt what exists, create what does not.
27396
27421
  *
27397
27422
  * The create branch is the destructive one — it seeds `initialMeta`, and
@@ -27400,8 +27425,15 @@ var DeclaredDevices = class {
27400
27425
  * the declared name over the operator's rename. D49: that branch needs a
27401
27426
  * second read to agree.
27402
27427
  */
27403
- async applyDeclaration(declaration, integrationId, index) {
27428
+ async applyDeclaration(declaration, integrationId, index, live) {
27404
27429
  try {
27430
+ const alreadyLive = live.get(declaration.stableId);
27431
+ if (alreadyLive !== void 0) return {
27432
+ stableId: declaration.stableId,
27433
+ deviceId: alreadyLive.id,
27434
+ device: alreadyLive,
27435
+ created: false
27436
+ };
27405
27437
  let existing = index.get(declaration.stableId);
27406
27438
  if (existing === void 0) {
27407
27439
  existing = (await this.readIndex()).get(declaration.stableId);
@@ -17500,24 +17500,28 @@ var snapshotCapability = {
17500
17500
  *
17501
17501
  * `getSnapshotOverview` is cache-only by contract: it answers from whatever
17502
17502
  * the wrapper happens to hold and never captures. Under D93 the client
17503
- * versions its image URL on that answer, and an image REQUEST is what enrols
17504
- * a camera in the keep-warm loop. Both of those are satisfiable by the
17505
- * client's own image cache — `expo-image` is URL-keyed and never revalidates
17506
- * — so a URL painted in a previous session comes off disk with no network,
17507
- * no enrolment, and nothing warming. Measured on the live hub: reopening
17508
- * after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
17509
- * HTTP requests, and the fleet only recovered because a later poll happened
17510
- * to observe a different identity.
17503
+ * versions its image URL on that answer, and an image REQUEST was the only
17504
+ * demand signal. Both of those are satisfiable by the client's own image
17505
+ * cache — `expo-image` is URL-keyed and never revalidates — so a URL painted
17506
+ * in a previous session comes off disk with no network, no demand, and no
17507
+ * capture. Measured on the live hub: reopening after two minutes idle
17508
+ * painted 15 of 16 tiles at **168 s old** with zero HTTP requests, and the
17509
+ * fleet only recovered because a later poll happened to observe a different
17510
+ * identity.
17511
17511
  *
17512
17512
  * ## The two properties that fix it
17513
17513
  *
17514
17514
  * **It is an RPC, so no client cache can answer it.** The demand signal
17515
- * always reaches the wrapper. This method therefore MAY create keep-warm
17516
- * subscriptions, where `getSnapshotOverview` must never (D93) — the
17517
- * distinction is not "one is newer" but that the overview poll is app-wide
17518
- * (a creating overview would warm every camera on the install) while this is
17519
- * called by a rendered surface naming the tiles it is actually painting, at
17520
- * the width it is painting them.
17515
+ * always reaches the wrapper. This method therefore CAPTURES, where
17516
+ * `getSnapshotOverview` must never (D93) — the distinction is not "one is
17517
+ * newer" but that the overview poll is app-wide (a capturing overview would
17518
+ * dial every camera on the install) while this is called by a rendered
17519
+ * surface naming the tiles it is actually painting, at the width it is
17520
+ * painting them.
17521
+ *
17522
+ * Since 2026-08-11 this is the ONLY thing that refreshes a snapshot: the
17523
+ * server-side keep-warm loop was removed (operator directive — on-demand,
17524
+ * always), so a camera nobody is looking at costs nothing at all.
17521
17525
  *
17522
17526
  * **It waits, briefly and boundedly, for the capture it triggered.** The
17523
17527
  * returned `capturedAt` is the frame the link will serve, not the frame the
@@ -27344,9 +27348,10 @@ var DeclaredDevices = class {
27344
27348
  }
27345
27349
  const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
27346
27350
  const index = await this.readIndex();
27351
+ const live = await this.readLiveByStableId();
27347
27352
  const outcomes = [];
27348
27353
  for (const declaration of spec.devices) {
27349
- const outcome = await this.applyDeclaration(declaration, integrationId, index);
27354
+ const outcome = await this.applyDeclaration(declaration, integrationId, index, live);
27350
27355
  if (outcome !== null) outcomes.push(outcome);
27351
27356
  }
27352
27357
  return {
@@ -27392,6 +27397,26 @@ var DeclaredDevices = class {
27392
27397
  return new Map(rows.map((row) => [row.stableId, row]));
27393
27398
  }
27394
27399
  /**
27400
+ * Devices this kernel already has CONSTRUCTED, by stableId.
27401
+ *
27402
+ * Distinct from {@link readIndex}, and the distinction is the bug: the index
27403
+ * is persisted rows, this is live objects. A row without an object must be
27404
+ * adopted; an object must be left exactly as it is.
27405
+ *
27406
+ * Failure is non-fatal and deliberately so — an empty map degrades to the
27407
+ * previous behaviour (attempt the adopt) rather than skipping a device that
27408
+ * genuinely needs bringing up.
27409
+ */
27410
+ async readLiveByStableId() {
27411
+ try {
27412
+ const devices = await this.ports.devices.getAll();
27413
+ return new Map(devices.map((device) => [device.stableId, device]));
27414
+ } catch (err) {
27415
+ this.ports.logger.warn("could not read live devices — falling back to adopt-by-row", { meta: { error: err instanceof Error ? err.message : String(err) } });
27416
+ return /* @__PURE__ */ new Map();
27417
+ }
27418
+ }
27419
+ /**
27395
27420
  * One declaration: adopt what exists, create what does not.
27396
27421
  *
27397
27422
  * The create branch is the destructive one — it seeds `initialMeta`, and
@@ -27400,8 +27425,15 @@ var DeclaredDevices = class {
27400
27425
  * the declared name over the operator's rename. D49: that branch needs a
27401
27426
  * second read to agree.
27402
27427
  */
27403
- async applyDeclaration(declaration, integrationId, index) {
27428
+ async applyDeclaration(declaration, integrationId, index, live) {
27404
27429
  try {
27430
+ const alreadyLive = live.get(declaration.stableId);
27431
+ if (alreadyLive !== void 0) return {
27432
+ stableId: declaration.stableId,
27433
+ deviceId: alreadyLive.id,
27434
+ device: alreadyLive,
27435
+ created: false
27436
+ };
27405
27437
  let existing = index.get(declaration.stableId);
27406
27438
  if (existing === void 0) {
27407
27439
  existing = (await this.readIndex()).get(declaration.stableId);
package/dist/index.js CHANGED
@@ -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-cINpngi1.js");
3
+ const require_dist = require("./dist-CA2mim74.js");
4
4
  const require_builtins_alerts_alerts_addon = require("./builtins/alerts/alerts.addon.js");
5
5
  require("./builtins/alerts/index.js");
6
6
  const require_formatter = require("./formatter-DqAKDlvN.js");
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as __toCommonJS, i as __require, n as __esmMin, o as __toESM$1, r as __exportAll, t as __commonJSMin$1 } from "./chunk-CNf5ZN-e.mjs";
2
- import { $ as objectInputDeclaresAddonId, At as emitDownForOwnedCaps, B as isArrayOutputSchema, Bt as scopeKey, Ct as ReadinessRegistry, Dt as asNumber, Et as asJsonObject$1, G as kebabToCamel, H as isObjectInput, Ht as EventCategory$1, It as parseJsonObject, K as lifecycleJobSchema, Lt as parseJsonUnknown$1, Mt as expandCapMethods, Ot as asString$1, R as extractNestedAddonId, Rt as readinessKey, V as isCollectionArrayMethod, W as isVoidInput, X as looseSchema, Y as logLevelAtMost, _t as DATAPLANE_SECRET_HEADER$1, d as METHOD_ACCESS_MAP, f as RUNTIME_DEFAULTS, ht as errMsg$1, it as scopesAllowDeviceCap, kt as createEvent, nt as procedureAuthKey, rt as scopesAllowAddon, t as ALL_CAPABILITY_DEFINITIONS, vt as DEVICE_SETTINGS_CONTRIBUTION_METHODS, wt as ReadinessTimeoutError, y as addonSettingsCapability, yt as DEVICE_STATUS_METHOD, zt as resolveCapMount } from "./dist-CIIFhXqm.mjs";
2
+ import { $ as objectInputDeclaresAddonId, At as emitDownForOwnedCaps, B as isArrayOutputSchema, Bt as scopeKey, Ct as ReadinessRegistry, Dt as asNumber, Et as asJsonObject$1, G as kebabToCamel, H as isObjectInput, Ht as EventCategory$1, It as parseJsonObject, K as lifecycleJobSchema, Lt as parseJsonUnknown$1, Mt as expandCapMethods, Ot as asString$1, R as extractNestedAddonId, Rt as readinessKey, V as isCollectionArrayMethod, W as isVoidInput, X as looseSchema, Y as logLevelAtMost, _t as DATAPLANE_SECRET_HEADER$1, d as METHOD_ACCESS_MAP, f as RUNTIME_DEFAULTS, ht as errMsg$1, it as scopesAllowDeviceCap, kt as createEvent, nt as procedureAuthKey, rt as scopesAllowAddon, t as ALL_CAPABILITY_DEFINITIONS, vt as DEVICE_SETTINGS_CONTRIBUTION_METHODS, wt as ReadinessTimeoutError, y as addonSettingsCapability, yt as DEVICE_STATUS_METHOD, zt as resolveCapMount } from "./dist-DZOZtn-0.mjs";
3
3
  import { AlertCenterAddon } from "./builtins/alerts/alerts.addon.mjs";
4
4
  import "./builtins/alerts/index.mjs";
5
5
  import { t as formatLogLine } from "./formatter-B7qW8bPJ.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.2.78",
3
+ "version": "1.2.80",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",
@@ -1,86 +0,0 @@
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
- }