@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.
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +1 -1
- package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +253 -296
- package/dist/builtins/snapshot/index.mjs +252 -296
- package/dist/builtins/snapshot/snapshot-courtesy.d.ts +36 -0
- package/dist/builtins/snapshot/snapshot-resize.d.ts +43 -11
- package/dist/builtins/snapshot/snapshot.addon.d.ts +23 -45
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-cINpngi1.js → dist-CA2mim74.js} +48 -16
- package/dist/{dist-CIIFhXqm.mjs → dist-DZOZtn-0.mjs} +48 -16
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +1 -1
- 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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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
|
|
22
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
17504
|
-
*
|
|
17505
|
-
*
|
|
17506
|
-
*
|
|
17507
|
-
*
|
|
17508
|
-
*
|
|
17509
|
-
*
|
|
17510
|
-
*
|
|
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
|
|
17516
|
-
*
|
|
17517
|
-
*
|
|
17518
|
-
*
|
|
17519
|
-
*
|
|
17520
|
-
*
|
|
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
|
|
17504
|
-
*
|
|
17505
|
-
*
|
|
17506
|
-
*
|
|
17507
|
-
*
|
|
17508
|
-
*
|
|
17509
|
-
*
|
|
17510
|
-
*
|
|
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
|
|
17516
|
-
*
|
|
17517
|
-
*
|
|
17518
|
-
*
|
|
17519
|
-
*
|
|
17520
|
-
*
|
|
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-
|
|
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-
|
|
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,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
|
-
}
|