@camstack/types 1.2.46 → 1.2.48

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 (37) hide show
  1. package/dist/addon.js +4 -3
  2. package/dist/addon.mjs +4 -3
  3. package/dist/{fmp4-box-splitter-B53u9-Nu.mjs → canonical-hash-rO1sRmEK.mjs} +34 -34
  4. package/dist/capabilities/core-blocks.cap.d.ts +52 -0
  5. package/dist/capabilities/device-manager.cap.d.ts +11 -427
  6. package/dist/capabilities/face-gallery.cap.d.ts +4 -0
  7. package/dist/capabilities/index.d.ts +2 -2
  8. package/dist/capabilities/motion-detection.cap.d.ts +6 -1
  9. package/dist/capabilities/notification-rules.cap.d.ts +24 -24
  10. package/dist/capabilities/oauth-integration.cap.d.ts +4 -0
  11. package/dist/capabilities/osd-manager.cap.d.ts +12 -12
  12. package/dist/capabilities/pipeline-analytics.cap.d.ts +7 -1
  13. package/dist/capabilities/sso-bridge.cap.d.ts +3 -0
  14. package/dist/capabilities/stream-broker.cap.d.ts +1 -0
  15. package/dist/capabilities/user-management.cap.d.ts +3 -1
  16. package/dist/capabilities/videoclips.cap.d.ts +5 -0
  17. package/dist/device/declared-device.d.ts +197 -0
  18. package/dist/device/device-binding.d.ts +15 -9
  19. package/dist/device/device-management.d.ts +1 -83
  20. package/dist/device/index.d.ts +20 -19
  21. package/dist/expression/binding-source.d.ts +85 -0
  22. package/dist/expression/{link-expression.d.ts → expression-source.d.ts} +22 -13
  23. package/dist/expression/index.d.ts +17 -14
  24. package/dist/expression/limits.d.ts +1 -1
  25. package/dist/generated/addon-api.d.ts +11 -11
  26. package/dist/generated/device-proxy.d.ts +1 -1
  27. package/dist/generated/system-proxy.d.ts +1 -1
  28. package/dist/index.d.ts +9 -8
  29. package/dist/index.js +2361 -2058
  30. package/dist/index.mjs +2336 -2044
  31. package/dist/node.js +7 -7
  32. package/dist/node.mjs +1 -1
  33. package/dist/{sleep-BbYwFLG6.mjs → sleep-7WqNZVcL.mjs} +0 -1
  34. package/dist/{sleep-CyN9nHr_.js → sleep-ocMLM2o5.js} +0 -1
  35. package/package.json +1 -1
  36. package/dist/device/device-link-transform.d.ts +0 -5
  37. package/dist/{fmp4-box-splitter-BkWH7O3L.js → canonical-hash-DNV8S5ET.js} +33 -33
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_event_category = require("./event-category-BE4PDZ_3.js");
3
- const require_sleep = require("./sleep-CyN9nHr_.js");
4
- const require_fmp4_box_splitter = require("./fmp4-box-splitter-BkWH7O3L.js");
3
+ const require_sleep = require("./sleep-ocMLM2o5.js");
4
+ const require_canonical_hash = require("./canonical-hash-DNV8S5ET.js");
5
5
  const require_enums = require("./enums.js");
6
6
  const require_err_msg = require("./err-msg-COpsHMw2.js");
7
7
  let zod = require("zod");
@@ -481,7 +481,7 @@ function canonicalEgressPlan(request, delivery = egressTransportFromRequest(requ
481
481
  * every other consumer to 360p.
482
482
  */
483
483
  function egressTranscodeSharingKey(request, delivery = egressTransportFromRequest(request)) {
484
- return `egress:${require_fmp4_box_splitter.canonicalHash(canonicalEgressPlan(request, delivery))}`;
484
+ return `egress:${require_canonical_hash.canonicalHash(canonicalEgressPlan(request, delivery))}`;
485
485
  }
486
486
  //#endregion
487
487
  //#region src/health/wiring-health.ts
@@ -4326,12 +4326,34 @@ var streamBrokerCapability = {
4326
4326
  kinds: zod.z.array(EventMediaKindSchema).min(1).default(["mp4"]),
4327
4327
  /** GIF geometry. The video keeps the source's own. */
4328
4328
  gifMaxWidth: zod.z.number().int().min(120).max(1280).default(640),
4329
- gifFps: zod.z.number().int().min(1).max(15).default(8),
4330
4329
  /**
4331
- * Playback rate, applied to EVERY container so they stay one clip.
4332
- * `1` is real time and is what allows the copy branch.
4330
+ * The gif's own PLAYBACK rate in frames per second what the finished
4331
+ * gif runs at, not how many source frames feed it. The decimation that
4332
+ * feeds it samples `gifFps / gifSpeed` source frames per second, so at
4333
+ * the defaults a 12 fps gif is built out of 3 source frames a second.
4333
4334
  */
4334
- speed: zod.z.number().min(1).max(8).default(1)
4335
+ gifFps: zod.z.number().int().min(1).max(15).default(12),
4336
+ /**
4337
+ * How fast the GIF plays against real time, independent of `speed`.
4338
+ *
4339
+ * 4× by default, by operator request: a notification gif is glanced at
4340
+ * on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
4341
+ * a separate knob from `speed` even though both now default to 4 —
4342
+ * a caller wanting a real-time video and a fast gif must not have to
4343
+ * choose.
4344
+ */
4345
+ gifSpeed: zod.z.number().min(1).max(8).default(4),
4346
+ /**
4347
+ * Playback rate of the VIDEO. Also 4× by default, by operator decision.
4348
+ *
4349
+ * `1` is real time and is the ONLY value that allows the copy branch —
4350
+ * anything else forces `libx264` over the window. That was priced
4351
+ * before it was chosen: a per-event burst measured at 0.23 s and 254 KB
4352
+ * on a real 615 720p cut, against 922 KB for the copy it replaces. A
4353
+ * re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
4354
+ * once the decode is forced the width stops being free.
4355
+ */
4356
+ speed: zod.z.number().min(1).max(8).default(4)
4335
4357
  }), EventMediaProductionSchema, {
4336
4358
  kind: "mutation",
4337
4359
  auth: "admin"
@@ -4928,6 +4950,28 @@ function kebabToCamel(s) {
4928
4950
  * instead of taking the hub with it (D6). Every method here is admin-only, and
4929
4951
  * must stay so.
4930
4952
  */
4953
+ /**
4954
+ * Prefix for a block's generated addon id AND its runner id — one addon, one
4955
+ * process, so the two are the same string (D2).
4956
+ *
4957
+ * It lives on the CONTRACT rather than beside the code that materialises the
4958
+ * package, because it is not an implementation detail: the operator surface
4959
+ * reads a block's logs by asking `logs.query` for `addonId = core-block-<id>`,
4960
+ * with no cap and no store of its own. Two copies of this string is one copy
4961
+ * too many — a drift would show as a logs pane that is simply always empty.
4962
+ */
4963
+ var CORE_BLOCK_ADDON_PREFIX = "core-block-";
4964
+ /** The addon/runner id a block's process runs under. */
4965
+ function coreBlockAddonId(blockId) {
4966
+ return `${CORE_BLOCK_ADDON_PREFIX}${blockId}`;
4967
+ }
4968
+ /** The block id behind a generated addon/runner id, or null when the id belongs
4969
+ * to something else. The inverse of {@link coreBlockAddonId}. */
4970
+ function coreBlockIdFromAddonId(addonId) {
4971
+ if (!addonId.startsWith("core-block-")) return null;
4972
+ const rest = addonId.slice(11);
4973
+ return rest.length > 0 ? rest : null;
4974
+ }
4931
4975
  /** Where a block runs. The operator chooses — a block driving a device on an
4932
4976
  * agent is the reason placement is not fixed to the hub. */
4933
4977
  var CoreBlockPlacementSchema = zod.z.union([zod.z.literal("hub"), zod.z.string().min(1)]);
@@ -5016,6 +5060,23 @@ var coreBlocksCapability = {
5016
5060
  auth: "admin"
5017
5061
  }),
5018
5062
  /**
5063
+ * Stop this block's runner and let the supervisor bring it back.
5064
+ *
5065
+ * **It stores nothing.** There is no "restart requested" flag and no second
5066
+ * lifecycle field: the supervisor already stops and respawns a runner when
5067
+ * the code hash changes, and this exposes that same path deliberately
5068
+ * (D62 — a switch writes the authority that already owned the function).
5069
+ * The next reconcile pass remains the authority, so a restart that lost a
5070
+ * race is corrected within one pass rather than leaving a stale flag behind.
5071
+ *
5072
+ * Refused for a DISABLED block: nothing would come back, and a button that
5073
+ * silently does nothing is worse than one that says why.
5074
+ */
5075
+ restart: require_sleep.method(zod.z.object({ blockId: zod.z.string() }), zod.z.object({ block: CoreBlockSchema }), {
5076
+ kind: "mutation",
5077
+ auth: "admin"
5078
+ }),
5079
+ /**
5019
5080
  * Type-check without saving — what the editor calls as the author types, so
5020
5081
  * the compiler's verdict is the same one the server will reach.
5021
5082
  */
@@ -6231,1114 +6292,247 @@ var deviceExportCapability = {
6231
6292
  }
6232
6293
  };
6233
6294
  //#endregion
6234
- //#region src/expression/limits.ts
6295
+ //#region src/capabilities/device-provider.cap.ts
6296
+ var ProviderStatusSchema = zod.z.object({
6297
+ connected: zod.z.boolean(),
6298
+ deviceCount: zod.z.number(),
6299
+ error: zod.z.string().optional()
6300
+ });
6301
+ var DiscoveredDeviceSchema = zod.z.object({
6302
+ externalId: zod.z.string(),
6303
+ name: zod.z.string(),
6304
+ type: zod.z.string(),
6305
+ metadata: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
6306
+ });
6235
6307
  /**
6236
- * Resource-bound constants for the safe expression engine.
6237
- *
6238
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
6239
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
6240
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
6241
- * work a single author-supplied expression can request, so a hostile or
6242
- * accidental pathological string can never spend unbounded CPU/memory.
6308
+ * Candidate handed back from discovery and accepted by
6309
+ * `adoptDiscoveredDevice`. Shape mirrors the in-process
6310
+ * `DiscoveredDevice` interface used by `DeviceDiscovery`.
6243
6311
  */
6244
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
6245
- * rejected without allocation. */
6246
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
6247
- /** Max AST nodes — checked during parse; a deeply nested grouping that exceeds
6248
- * this is rejected as "expression too complex". */
6249
- var MAX_EXPRESSION_AST_NODES = 256;
6250
- /** Defense-in-depth walker step budget one increment per node visit during
6251
- * evaluation. The grammar guarantees O(nodeCount) walks, so this can only trip
6252
- * on a crafted maximum-size AST. */
6253
- var MAX_EXPRESSION_EVAL_STEPS = 4096;
6254
- /** Max named bindings on one `DeviceLinkExpressionSource`. */
6255
- var MAX_EXPRESSION_BINDINGS = 32;
6256
- /** Max positional arguments to any builtin call. */
6257
- var MAX_EXPRESSION_CALL_ARGS = 16;
6258
- /** LRU compile-cache capacity (parsed ASTs keyed by raw source string). */
6259
- var EXPRESSION_COMPILE_CACHE_CAPACITY = 256;
6260
- /** A legal binding / identifier name. */
6261
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
6262
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
6263
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
6264
- var RESERVED_BINDING_NAMES = new Set([
6265
- "now",
6266
- "true",
6267
- "false",
6268
- "null"
6269
- ]);
6270
- //#endregion
6271
- //#region src/expression/errors.ts
6312
+ var DiscoveryCandidateSchema = zod.z.object({
6313
+ stableId: zod.z.string(),
6314
+ type: zod.z.enum(require_sleep.DeviceType),
6315
+ suggestedName: zod.z.string(),
6316
+ prefilledConfig: zod.z.record(zod.z.string(), zod.z.unknown()),
6317
+ /**
6318
+ * Optional upstream-system identity (HA entity_id, vendor MAC, …).
6319
+ * Discovery pre-populates this for systems that know the upstream
6320
+ * identity ahead of adoption. Rendering metadata (unit, precision)
6321
+ * flows live through the cap STATUS SLICE after adoption.
6322
+ */
6323
+ sourceInfo: SourceInfoSchema.optional()
6324
+ });
6272
6325
  /**
6273
- * Error types for the safe expression engine. Two distinct classes so callers
6274
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
6275
- * failure both are non-fatal to the host: read paths degrade to "skip link".
6326
+ * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
6327
+ * Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
6328
+ * tRPC layer can pass it through without reshaping.
6276
6329
  */
6277
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
6278
- * the failure is anchored to a character (author-facing inline feedback). */
6279
- var ExpressionParseError = class extends Error {
6280
- position;
6281
- constructor(message, position) {
6282
- super(message);
6283
- this.name = "ExpressionParseError";
6284
- this.position = position;
6285
- }
6286
- };
6287
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
6288
- * result, unknown builtin, step-budget exceeded). */
6289
- var ExpressionEvalError = class extends Error {
6290
- constructor(message) {
6291
- super(message);
6292
- this.name = "ExpressionEvalError";
6293
- }
6294
- };
6295
- //#endregion
6296
- //#region src/expression/builtins.ts
6330
+ var DeviceSummarySchema = zod.z.object({
6331
+ id: zod.z.number(),
6332
+ stableId: zod.z.string(),
6333
+ addonId: zod.z.string(),
6334
+ type: zod.z.string(),
6335
+ name: zod.z.string(),
6336
+ parentDeviceId: zod.z.number().nullable(),
6337
+ online: zod.z.boolean(),
6338
+ features: zod.z.array(zod.z.string()),
6339
+ config: zod.z.record(zod.z.string(), zod.z.unknown()),
6340
+ /** Optional upstream-system identity (dispatch key + system tag).
6341
+ * See `SourceInfo`. Present when the device has a non-synthetic
6342
+ * source identifier (HA entities, vendor MAC, …); omitted when the
6343
+ * synthetic backfill is in effect. */
6344
+ sourceInfo: SourceInfoSchema.optional()
6345
+ });
6297
6346
  /**
6298
- * Frozen, null-prototype builtin function table for the expression engine
6299
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
6300
- * parser rejects any callee not in it, and the evaluator gates each call on an
6301
- * own-property check against it.
6302
- *
6303
- * Because the object has a NULL prototype AND is `Object.freeze`d:
6304
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
6305
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
6306
- * (there is no `Object.prototype` in the chain), so those names are not
6307
- * callable — they are simply "unknown function" at parse time.
6308
- *
6309
- * Every numeric argument is validated as a finite number and every numeric
6310
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
6311
- * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
6312
- * closed rather than emitting a garbage value.
6347
+ * Result of a live field test (e.g. probing an RTSP URL during device
6348
+ * creation). Matches the UI-side `FieldProbeResult` in
6349
+ * `interfaces/config-ui.ts` the admin `FormBuilder` renders the
6350
+ * returned `labels` as chips next to the input.
6313
6351
  */
6314
- function asFiniteNumber(value, name, index) {
6315
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
6316
- return value;
6317
- }
6318
- function asString$1(value, name, index) {
6319
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
6320
- return value;
6321
- }
6322
- function finiteResult(value, name) {
6323
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
6324
- return value;
6325
- }
6326
- function allFiniteNumbers(args, name) {
6327
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
6328
- }
6329
- var INF = Number.POSITIVE_INFINITY;
6330
- var table = {
6331
- min: {
6332
- minArgs: 1,
6333
- maxArgs: INF,
6334
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
6335
- },
6336
- max: {
6337
- minArgs: 1,
6338
- maxArgs: INF,
6339
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
6340
- },
6341
- abs: {
6342
- minArgs: 1,
6343
- maxArgs: 1,
6344
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
6345
- },
6346
- floor: {
6347
- minArgs: 1,
6348
- maxArgs: 1,
6349
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
6350
- },
6351
- ceil: {
6352
- minArgs: 1,
6353
- maxArgs: 1,
6354
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
6355
- },
6356
- sqrt: {
6357
- minArgs: 1,
6358
- maxArgs: 1,
6359
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
6360
- },
6361
- round: {
6362
- minArgs: 1,
6363
- maxArgs: 2,
6364
- apply: (args) => {
6365
- const x = asFiniteNumber(args[0], "round", 0);
6366
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
6367
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
6368
- const factor = 10 ** digits;
6369
- return finiteResult(Math.round(x * factor) / factor, "round");
6370
- }
6371
- },
6372
- pow: {
6373
- minArgs: 2,
6374
- maxArgs: 2,
6375
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
6376
- },
6377
- clamp: {
6378
- minArgs: 3,
6379
- maxArgs: 3,
6380
- apply: (args) => {
6381
- const x = asFiniteNumber(args[0], "clamp", 0);
6382
- const lo = asFiniteNumber(args[1], "clamp", 1);
6383
- const hi = asFiniteNumber(args[2], "clamp", 2);
6384
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
6385
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
6386
- }
6387
- },
6388
- avg: {
6389
- minArgs: 1,
6390
- maxArgs: INF,
6391
- apply: (args) => {
6392
- const nums = allFiniteNumbers(args, "avg");
6393
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
6394
- }
6395
- },
6396
- sum: {
6397
- minArgs: 1,
6398
- maxArgs: INF,
6399
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
6400
- },
6401
- coalesce: {
6402
- minArgs: 1,
6403
- maxArgs: INF,
6404
- apply: (args) => {
6405
- for (const a of args) if (a !== null) return a;
6406
- return null;
6407
- }
6408
- },
6409
- age: {
6410
- minArgs: 2,
6411
- maxArgs: 2,
6412
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
6413
- },
6414
- convert: {
6415
- minArgs: 3,
6416
- maxArgs: 3,
6417
- apply: (args, hooks) => {
6418
- const x = asFiniteNumber(args[0], "convert", 0);
6419
- const from = asString$1(args[1], "convert", 1).trim();
6420
- const to = asString$1(args[2], "convert", 2).trim();
6421
- if (hooks.convert) {
6422
- const out = hooks.convert(x, from, to);
6423
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
6424
- return finiteResult(out, "convert");
6425
- }
6426
- if (from === to) return x;
6427
- throw new ExpressionEvalError("convert: unit conversion table not installed");
6428
- }
6429
- }
6430
- };
6431
- /** Frozen, null-prototype builtin table. */
6432
- var EXPRESSION_BUILTINS = Object.freeze(Object.assign(Object.create(null), table));
6433
- /** The set of valid builtin names — used by the parser to reject unknown
6434
- * callees at parse time (immediate author feedback). */
6435
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
6436
- //#endregion
6437
- //#region src/expression/tokenizer.ts
6352
+ var FieldProbeResultSchema = zod.z.object({
6353
+ status: zod.z.enum(["ok", "error"]),
6354
+ labels: zod.z.array(zod.z.string()).optional(),
6355
+ error: zod.z.string().optional()
6356
+ });
6438
6357
  /**
6439
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
6440
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
6441
- * single/double-quoted strings with a tiny escape set, identifiers, the three
6442
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
6443
- * outside that a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
6444
- * is a parse error with a source position, so member access / assignment /
6445
- * template literals are lexically impossible.
6358
+ * The output of `getChildCreationSchema` is a UI schema tree. We store
6359
+ * it as `unknown` at the capability layer — the router just passes it
6360
+ * through and the admin UI renders it via `FormBuilder`. The actual
6361
+ * type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
6362
+ * but we deliberately avoid a Zod mirror because the union is large and
6363
+ * not meant for runtime validation at this seam.
6446
6364
  */
6447
- var KEYWORDS = new Set([
6448
- "true",
6449
- "false",
6450
- "null"
6451
- ]);
6452
- function isDigit(ch) {
6453
- return ch >= "0" && ch <= "9";
6454
- }
6455
- function isIdentStart(ch) {
6456
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
6457
- }
6458
- function isIdentPart(ch) {
6459
- return isIdentStart(ch) || isDigit(ch);
6460
- }
6461
- function isWhitespace(ch) {
6462
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
6463
- }
6464
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
6465
- * Throws `ExpressionParseError` on any illegal character or unterminated
6466
- * string. */
6467
- function tokenize(source) {
6468
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
6469
- const tokens = [];
6470
- let i = 0;
6471
- const n = source.length;
6472
- while (i < n) {
6473
- const ch = source[i];
6474
- if (isWhitespace(ch)) {
6475
- i += 1;
6476
- continue;
6477
- }
6478
- if (isDigit(ch)) {
6479
- const start = i;
6480
- while (i < n && isDigit(source[i])) i += 1;
6481
- if (i < n && source[i] === ".") {
6482
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
6483
- i += 1;
6484
- while (i < n && isDigit(source[i])) i += 1;
6485
- }
6486
- const text = source.slice(start, i);
6487
- const value = Number(text);
6488
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
6489
- tokens.push({
6490
- type: "number",
6491
- value,
6492
- pos: start
6493
- });
6494
- continue;
6495
- }
6496
- if (ch === "'" || ch === "\"") {
6497
- const quote = ch;
6498
- const start = i;
6499
- i += 1;
6500
- let out = "";
6501
- let closed = false;
6502
- while (i < n) {
6503
- const c = source[i];
6504
- if (c === "\\") {
6505
- const next = i + 1 < n ? source[i + 1] : "";
6506
- if (next === "\\" || next === "'" || next === "\"") {
6507
- out += next;
6508
- i += 2;
6509
- continue;
6510
- }
6511
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
6512
- }
6513
- if (c === quote) {
6514
- closed = true;
6515
- i += 1;
6516
- break;
6517
- }
6518
- out += c;
6519
- i += 1;
6520
- }
6521
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
6522
- tokens.push({
6523
- type: "string",
6524
- value: out,
6525
- pos: start
6526
- });
6527
- continue;
6528
- }
6529
- if (isIdentStart(ch)) {
6530
- const start = i;
6531
- while (i < n && isIdentPart(source[i])) i += 1;
6532
- const text = source.slice(start, i);
6533
- if (KEYWORDS.has(text)) tokens.push({
6534
- type: "keyword",
6535
- keyword: keywordOf(text),
6536
- pos: start
6537
- });
6538
- else tokens.push({
6539
- type: "identifier",
6540
- name: text,
6541
- pos: start
6542
- });
6543
- continue;
6544
- }
6545
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
6546
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
6547
- tokens.push({
6548
- type: "punct",
6549
- punct: two,
6550
- pos: i
6551
- });
6552
- i += 2;
6553
- continue;
6554
- }
6555
- if (isSinglePunct(ch)) {
6556
- tokens.push({
6557
- type: "punct",
6558
- punct: ch,
6559
- pos: i
6560
- });
6561
- i += 1;
6562
- continue;
6563
- }
6564
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
6365
+ var CreationSchemaOutputSchema = zod.z.unknown();
6366
+ var deviceProviderCapability = {
6367
+ name: "device-provider",
6368
+ scope: "system",
6369
+ mode: "collection",
6370
+ methods: {
6371
+ start: require_sleep.method(zod.z.void(), zod.z.void(), { kind: "mutation" }),
6372
+ stop: require_sleep.method(zod.z.void(), zod.z.void(), { kind: "mutation" }),
6373
+ getStatus: require_sleep.method(zod.z.void(), ProviderStatusSchema),
6374
+ getDevices: require_sleep.method(zod.z.void(), zod.z.array(zod.z.object({
6375
+ id: zod.z.string(),
6376
+ name: zod.z.string(),
6377
+ type: zod.z.string()
6378
+ }))),
6379
+ supportsDiscovery: require_sleep.method(zod.z.object({}), zod.z.boolean()),
6380
+ /**
6381
+ * Run a network scan. `params` carries optional provider-specific scan
6382
+ * inputs (e.g. a broadcast address / subnet for cross-subnet discovery),
6383
+ * shaped by `getDiscoveryParamsSchema`. Omitted for the generic scan
6384
+ * (provider uses its local-network default).
6385
+ */
6386
+ discoverDevices: require_sleep.method(zod.z.object({ params: zod.z.record(zod.z.string(), zod.z.unknown()).optional() }), zod.z.array(DiscoveryCandidateSchema), {
6387
+ kind: "mutation",
6388
+ auth: "admin"
6389
+ }),
6390
+ /**
6391
+ * Optional form schema (`ConfigUISchema`) for the EXTRA per-scan inputs a
6392
+ * provider accepts (e.g. Gree's broadcast address for a different subnet).
6393
+ * `null` when the provider takes no extra scan params — the generic
6394
+ * aggregated scan never renders this; the per-integration scan does.
6395
+ */
6396
+ getDiscoveryParamsSchema: require_sleep.method(zod.z.object({}), CreationSchemaOutputSchema),
6397
+ /**
6398
+ * The DeviceType this provider creates via manual add (Camera for
6399
+ * Reolink/ONVIF, Container for Gree, Hub for Ecowitt). `null` when the
6400
+ * provider does not support manual creation. Lets the Add-Device dialog
6401
+ * pick the right type instead of assuming Camera.
6402
+ */
6403
+ getManualCreationType: require_sleep.method(zod.z.object({}), zod.z.object({ deviceType: zod.z.enum(require_sleep.DeviceType).nullable() })),
6404
+ adoptDiscoveredDevice: require_sleep.method(zod.z.object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
6405
+ kind: "mutation",
6406
+ auth: "admin"
6407
+ }),
6408
+ supportsManualCreation: require_sleep.method(zod.z.object({}), zod.z.boolean()),
6409
+ /**
6410
+ * Fetch the creation form schema for a given DeviceType. Returns
6411
+ * `null` when the provider does not support manually creating
6412
+ * devices of that type. The output is a `ConfigUISchema` — the
6413
+ * router type-asserts it at the boundary.
6414
+ */
6415
+ getChildCreationSchema: require_sleep.method(zod.z.object({ type: zod.z.enum(require_sleep.DeviceType) }), CreationSchemaOutputSchema),
6416
+ createDevice: require_sleep.method(zod.z.object({
6417
+ type: zod.z.enum(require_sleep.DeviceType),
6418
+ config: zod.z.record(zod.z.string(), zod.z.unknown())
6419
+ }), DeviceSummarySchema, {
6420
+ kind: "mutation",
6421
+ auth: "admin"
6422
+ }),
6423
+ /**
6424
+ * Test a single field in the creation form before the device has
6425
+ * been persisted. Typical use: probing an RTSP URL entered by the
6426
+ * user. Providers that don't support field probing return
6427
+ * `{ success: true, message: 'Field test not supported' }`.
6428
+ *
6429
+ * `formValues` is the live snapshot of every field in the form at
6430
+ * the moment the user clicked Test — useful for probes that depend
6431
+ * on multiple fields together (e.g. Reolink autodetect needs host
6432
+ * + credentials + UID + transport mode in a single call). Optional
6433
+ * for backwards compatibility; providers free to ignore it.
6434
+ */
6435
+ testCreationField: require_sleep.method(zod.z.object({
6436
+ type: zod.z.enum(require_sleep.DeviceType),
6437
+ key: zod.z.string(),
6438
+ value: zod.z.unknown(),
6439
+ formValues: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
6440
+ }), FieldProbeResultSchema, {
6441
+ kind: "mutation",
6442
+ auth: "admin"
6443
+ })
6565
6444
  }
6566
- tokens.push({
6567
- type: "eof",
6568
- pos: n
6569
- });
6570
- return tokens;
6571
- }
6572
- function keywordOf(text) {
6573
- if (text === "true") return "true";
6574
- if (text === "false") return "false";
6575
- return "null";
6576
- }
6577
- function isSinglePunct(ch) {
6578
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
6579
- }
6445
+ };
6580
6446
  //#endregion
6581
- //#region src/expression/parser.ts
6447
+ //#region src/capabilities/device-manager.cap.ts
6582
6448
  /**
6583
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
6449
+ * Device Manager capability — hub-side singleton that unifies device persistence,
6450
+ * live registry access, and all management operations into a single tRPC surface.
6584
6451
  *
6585
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
6586
- * relational additive multiplicative → unary `! -` → call / primary.
6587
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
6588
- * string validated against the builtin table at parse time, so an unknown
6589
- * function is rejected immediately (author feedback) and a persisted expression
6590
- * that references a since-removed builtin degrades at read.
6452
+ * Replaces:
6453
+ * - `device-persistence` capability (persistence methods absorbed here)
6454
+ * - `device-management.router.ts` (deleted in Phase 2)
6455
+ * - `device-ops.router.ts` (compat layer deleted; device-provider ops absorbed here)
6591
6456
  *
6592
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
6593
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) both raise `ExpressionParseError`.
6594
- */
6595
- /** Binary/logical operator precedence (higher binds tighter). */
6596
- var BINARY_PRECEDENCE = {
6597
- "||": 1,
6598
- "&&": 2,
6599
- "==": 3,
6600
- "!=": 3,
6601
- "<": 4,
6602
- "<=": 4,
6603
- ">": 4,
6604
- ">=": 4,
6605
- "+": 5,
6606
- "-": 5,
6607
- "*": 6,
6608
- "/": 6,
6609
- "%": 6
6610
- };
6611
- function isLogicalOp(op) {
6612
- return op === "&&" || op === "||";
6613
- }
6614
- function isBinaryOp(op) {
6615
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
6616
- }
6617
- var Parser = class {
6618
- tokens;
6619
- pos = 0;
6620
- nodeCount = 0;
6621
- identifiers = /* @__PURE__ */ new Set();
6622
- callees = /* @__PURE__ */ new Set();
6623
- constructor(tokens) {
6624
- this.tokens = tokens;
6625
- }
6626
- parse() {
6627
- const ast = this.parseTernary();
6628
- const tok = this.peek();
6629
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
6630
- return {
6631
- ast,
6632
- identifiers: this.identifiers,
6633
- callees: this.callees,
6634
- nodeCount: this.nodeCount
6635
- };
6636
- }
6637
- peek() {
6638
- return this.tokens[this.pos];
6639
- }
6640
- next() {
6641
- return this.tokens[this.pos++];
6642
- }
6643
- /** Consume a punctuator token, erroring if the next token isn't it. */
6644
- expectPunct(punct) {
6645
- const tok = this.peek();
6646
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
6647
- this.pos += 1;
6648
- }
6649
- matchPunct(punct) {
6650
- const tok = this.peek();
6651
- if (tok.type === "punct" && tok.punct === punct) {
6652
- this.pos += 1;
6653
- return true;
6654
- }
6655
- return false;
6656
- }
6657
- countNode() {
6658
- this.nodeCount += 1;
6659
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
6660
- }
6661
- parseTernary() {
6662
- const test = this.parseBinary(1);
6663
- if (this.matchPunct("?")) {
6664
- const consequent = this.parseTernary();
6665
- this.expectPunct(":");
6666
- const alternate = this.parseTernary();
6667
- this.countNode();
6668
- return {
6669
- kind: "conditional",
6670
- test,
6671
- consequent,
6672
- alternate
6673
- };
6674
- }
6675
- return test;
6676
- }
6677
- parseBinary(minPrec) {
6678
- let left = this.parseUnary();
6679
- for (;;) {
6680
- const tok = this.peek();
6681
- if (tok.type !== "punct") break;
6682
- const prec = BINARY_PRECEDENCE[tok.punct];
6683
- if (prec === void 0 || prec < minPrec) break;
6684
- const op = tok.punct;
6685
- this.pos += 1;
6686
- const right = this.parseBinary(prec + 1);
6687
- this.countNode();
6688
- if (isLogicalOp(op)) left = {
6689
- kind: "logical",
6690
- op,
6691
- left,
6692
- right
6693
- };
6694
- else if (isBinaryOp(op)) left = {
6695
- kind: "binary",
6696
- op,
6697
- left,
6698
- right
6699
- };
6700
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
6701
- }
6702
- return left;
6703
- }
6704
- parseUnary() {
6705
- const tok = this.peek();
6706
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
6707
- const op = tok.punct;
6708
- this.pos += 1;
6709
- const operand = this.parseUnary();
6710
- this.countNode();
6711
- return {
6712
- kind: "unary",
6713
- op,
6714
- operand
6715
- };
6716
- }
6717
- return this.parsePrimary();
6718
- }
6719
- parsePrimary() {
6720
- const tok = this.next();
6721
- switch (tok.type) {
6722
- case "number":
6723
- this.countNode();
6724
- return {
6725
- kind: "literal",
6726
- value: tok.value
6727
- };
6728
- case "string":
6729
- this.countNode();
6730
- return {
6731
- kind: "literal",
6732
- value: tok.value
6733
- };
6734
- case "keyword":
6735
- this.countNode();
6736
- return {
6737
- kind: "literal",
6738
- value: tok.keyword === "null" ? null : tok.keyword === "true"
6739
- };
6740
- case "identifier": {
6741
- const nextTok = this.peek();
6742
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
6743
- this.identifiers.add(tok.name);
6744
- this.countNode();
6745
- return {
6746
- kind: "identifier",
6747
- name: tok.name
6748
- };
6749
- }
6750
- case "punct":
6751
- if (tok.punct === "(") {
6752
- const inner = this.parseTernary();
6753
- this.expectPunct(")");
6754
- return inner;
6755
- }
6756
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
6757
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
6758
- }
6759
- }
6760
- parseCall(callee, pos) {
6761
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
6762
- this.expectPunct("(");
6763
- const args = [];
6764
- if (!this.matchPunct(")")) for (;;) {
6765
- args.push(this.parseTernary());
6766
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
6767
- if (this.matchPunct(",")) continue;
6768
- this.expectPunct(")");
6769
- break;
6770
- }
6771
- this.callees.add(callee);
6772
- this.countNode();
6773
- return {
6774
- kind: "call",
6775
- callee,
6776
- args
6777
- };
6778
- }
6779
- };
6780
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
6781
- * `ExpressionParseError` on any lexical or grammatical failure. */
6782
- function parseExpression(source) {
6783
- return new Parser(tokenize(source)).parse();
6784
- }
6785
- //#endregion
6786
- //#region src/expression/compile.ts
6787
- /**
6788
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
6789
- * by expr"). The cache stores BOTH successes and failures (negative caching),
6790
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
6791
- * one per read on a hot resolve path.
6792
- *
6793
- * The cache is a module-level singleton: entries are pure, content-addressed
6794
- * ASTs keyed by the raw source string, so sharing one instance across all
6795
- * callers is safe and maximises hit rate.
6796
- */
6797
- var cache = /* @__PURE__ */ new Map();
6798
- function getCached(source) {
6799
- const hit = cache.get(source);
6800
- if (hit !== void 0) {
6801
- cache.delete(source);
6802
- cache.set(source, hit);
6803
- return hit;
6804
- }
6805
- let result;
6806
- try {
6807
- result = {
6808
- ok: true,
6809
- parsed: parseExpression(source)
6810
- };
6811
- } catch (err) {
6812
- result = {
6813
- ok: false,
6814
- error: err instanceof ExpressionParseError ? err.message : String(err)
6815
- };
6816
- }
6817
- cache.set(source, result);
6818
- if (cache.size > 256) {
6819
- const oldest = cache.keys().next().value;
6820
- if (oldest !== void 0) cache.delete(oldest);
6821
- }
6822
- return result;
6823
- }
6824
- /** Compile `source` to a `ParsedExpression`, throwing `ExpressionParseError`
6825
- * on failure. LRU/negative-cached. */
6826
- function compileExpression(source) {
6827
- const result = getCached(source);
6828
- if (result.ok) return result.parsed;
6829
- throw new ExpressionParseError(result.error);
6830
- }
6831
- /** Compile `source`, returning a discriminated result instead of throwing.
6832
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
6833
- function compileExpressionSafe(source) {
6834
- return getCached(source);
6835
- }
6836
- //#endregion
6837
- //#region src/expression/evaluator.ts
6838
- /**
6839
- * Tree-walking evaluator for the safe expression mini-language.
6457
+ * All device provider addons (rtsp, onvif, frigate, ) are hub-local: they may
6458
+ * fork into separate processes but never run on remote cluster agents. Therefore:
6459
+ * - No nodeId routing needed — this is a pure hub singleton.
6460
+ * - The hub's DeviceRegistry is the single source of truth for all live devices.
6461
+ * - No shadow registry or cross-node aggregation required.
6840
6462
  *
6841
- * SECURITY (spec §4 rule 2/5):
6842
- * - The scope is an `Object.create(null)` copy of ONLY the caller's own
6843
- * enumerable binding entries, so `name in scope` is a pure own-key check and
6844
- * `constructor` / `__proto__` / `toString` are plain unknown identifiers.
6845
- * - Performs ZERO I/O and never touches `globalThis` / `Date` / `Math`
6846
- * directly — the only external calls are into the frozen builtin table.
6847
- * - The grammar has no loops/recursion/lambdas, so a walk is O(nodeCount) by
6848
- * construction; the step counter is defense-in-depth for a crafted max-size
6849
- * AST. Nothing blocks: there are no timers, awaits or unbounded loops.
6850
- */
6851
- var EMPTY_HOOKS = Object.freeze({});
6852
- /** Build a null-prototype scope from own-enumerable binding entries. Inherited
6853
- * keys of the input (e.g. from a `{__proto__: {...}}` payload) are NOT copied,
6854
- * so nothing smuggles in via the prototype chain. */
6855
- function createExpressionScope(bindings) {
6856
- const scope = Object.create(null);
6857
- for (const key of Object.keys(bindings)) if (Object.prototype.hasOwnProperty.call(bindings, key)) scope[key] = bindings[key];
6858
- return scope;
6859
- }
6860
- function isFiniteNumber(value) {
6861
- return typeof value === "number" && Number.isFinite(value);
6862
- }
6863
- /** JS truthiness of a primitive value. */
6864
- function truthy(value) {
6865
- return Boolean(value);
6866
- }
6867
- function requireFinite(value, context) {
6868
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${context} produced a non-finite result`);
6869
- return value;
6870
- }
6871
- function step(ctx) {
6872
- ctx.steps += 1;
6873
- if (ctx.steps > ctx.maxSteps) throw new ExpressionEvalError("expression evaluation step budget exceeded");
6874
- }
6875
- function evalNode(node, ctx) {
6876
- step(ctx);
6877
- switch (node.kind) {
6878
- case "literal": return node.value;
6879
- case "identifier":
6880
- if (!(node.name in ctx.scope)) throw new ExpressionEvalError(`unknown identifier: ${node.name}`);
6881
- return ctx.scope[node.name];
6882
- case "unary": return evalUnary(node.op, evalNode(node.operand, ctx));
6883
- case "binary": return evalBinary(node.op, evalNode(node.left, ctx), evalNode(node.right, ctx));
6884
- case "logical": {
6885
- const left = evalNode(node.left, ctx);
6886
- if (node.op === "&&") return truthy(left) ? evalNode(node.right, ctx) : left;
6887
- return truthy(left) ? left : evalNode(node.right, ctx);
6888
- }
6889
- case "conditional": return truthy(evalNode(node.test, ctx)) ? evalNode(node.consequent, ctx) : evalNode(node.alternate, ctx);
6890
- case "call": return evalCall(node.callee, node.args.map((a) => evalNode(a, ctx)), ctx.hooks);
6891
- }
6892
- }
6893
- function evalUnary(op, operand) {
6894
- if (op === "!") return !truthy(operand);
6895
- if (!isFiniteNumber(operand)) throw new ExpressionEvalError("unary \"-\" requires a finite number");
6896
- return requireFinite(-operand, "unary \"-\"");
6897
- }
6898
- function evalBinary(op, left, right) {
6899
- switch (op) {
6900
- case "==": return left === right;
6901
- case "!=": return left !== right;
6902
- case "+":
6903
- if (typeof left === "string" && typeof right === "string") return left + right;
6904
- if (isFiniteNumber(left) && isFiniteNumber(right)) return requireFinite(left + right, "\"+\"");
6905
- throw new ExpressionEvalError("\"+\" requires two numbers or two strings");
6906
- case "-":
6907
- case "*":
6908
- case "/":
6909
- case "%":
6910
- if (!isFiniteNumber(left) || !isFiniteNumber(right)) throw new ExpressionEvalError(`"${op}" requires two finite numbers`);
6911
- return requireFinite(op === "-" ? left - right : op === "*" ? left * right : op === "/" ? left / right : left % right, `"${op}"`);
6912
- case "<":
6913
- case "<=":
6914
- case ">":
6915
- case ">=":
6916
- if (isFiniteNumber(left) && isFiniteNumber(right)) return op === "<" ? left < right : op === "<=" ? left <= right : op === ">" ? left > right : left >= right;
6917
- if (typeof left === "string" && typeof right === "string") return op === "<" ? left < right : op === "<=" ? left <= right : op === ">" ? left > right : left >= right;
6918
- throw new ExpressionEvalError(`"${op}" requires two numbers or two strings`);
6919
- }
6920
- }
6921
- function evalCall(callee, args, hooks) {
6922
- if (!Object.prototype.hasOwnProperty.call(EXPRESSION_BUILTINS, callee)) throw new ExpressionEvalError(`unknown function: ${callee}`);
6923
- const builtin = EXPRESSION_BUILTINS[callee];
6924
- if (args.length < builtin.minArgs || args.length > builtin.maxArgs) throw new ExpressionEvalError(`${callee}: wrong number of arguments (${args.length})`);
6925
- return builtin.apply(args, hooks);
6926
- }
6927
- /** Evaluate an AST node against a scope. Throws `ExpressionEvalError` on any
6928
- * runtime failure (unknown identifier, type mismatch, non-finite result,
6929
- * step-budget exhaustion). */
6930
- function evaluateAst(node, scope, opts) {
6931
- return evalNode(node, {
6932
- scope,
6933
- hooks: opts?.hooks ?? EMPTY_HOOKS,
6934
- maxSteps: opts?.maxSteps ?? 4096,
6935
- steps: 0
6936
- });
6937
- }
6938
- //#endregion
6939
- //#region src/expression/link-expression.ts
6940
- /**
6941
- * DeviceLink-facing helpers for the expression engine — the single seam both
6942
- * resolver channels (async provider-read + sync mirror) and the wire-schema
6943
- * `superRefine` share, so validation and evaluation semantics stay identical
6944
- * everywhere.
6945
- */
6946
- /** The `now` epoch-ms binding is auto-injected into every evaluation and is a
6947
- * reserved binding name (authors may not rebind it). */
6948
- var EXPRESSION_INJECTED_NOW = "now";
6949
- /**
6950
- * Coerce an untrusted `getByPath` / mirror read to an `ExpressionValue`.
6951
- * Non-primitive values (objects, arrays, `undefined`, functions, bigint,
6952
- * symbol) and non-finite numbers become `undefined` so the caller can apply
6953
- * its binding-miss policy (→ `null`). `null` itself is a valid value.
6954
- */
6955
- function toExpressionValue(raw) {
6956
- if (raw === null) return null;
6957
- if (typeof raw === "string") return raw;
6958
- if (typeof raw === "boolean") return raw;
6959
- if (typeof raw === "number") return Number.isFinite(raw) ? raw : void 0;
6960
- }
6961
- /**
6962
- * Author-time validation. Returns `null` when the source is valid, else a
6963
- * human-readable error message. Checks: the expression compiles; binding count
6964
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
6965
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
6966
- * FREE identifier of the AST is covered by a binding or the injected `now`.
6967
- */
6968
- function validateExpressionSource(src) {
6969
- const names = Object.keys(src.bindings);
6970
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
6971
- for (const name of names) {
6972
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
6973
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
6974
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
6975
- }
6976
- const compiled = compileExpressionSafe(src.expr);
6977
- if (!compiled.ok) return compiled.error;
6978
- const bound = new Set(names);
6979
- for (const id of compiled.parsed.identifiers) {
6980
- if (id === "now") continue;
6981
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
6982
- }
6983
- return null;
6984
- }
6985
- /**
6986
- * Shared read-path evaluation for BOTH resolver channels. Builds a null-proto
6987
- * scope from `bindingValues` plus the injected `now` (supplied by the caller
6988
- * for determinism/testability), compiles via the LRU, and evaluates. Any
6989
- * failure (parse or eval) returns `{ ok: false }` — the caller treats that as
6990
- * "skip this link".
6463
+ * Forked workers register devices back to the hub via `ctx.devices`
6464
+ * (DeviceManagerApi ctx.api.deviceManager.registerDevice), same as today.
6991
6465
  */
6992
- function evaluateLinkExpression(expr, bindingValues, now, opts) {
6993
- const compiled = compileExpressionSafe(expr);
6994
- if (!compiled.ok) return {
6995
- ok: false,
6996
- error: compiled.error
6997
- };
6998
- const scope = createExpressionScope({
6999
- ...bindingValues,
7000
- ["now"]: now
7001
- });
7002
- try {
7003
- return {
7004
- ok: true,
7005
- value: evaluateAst(compiled.parsed.ast, scope, opts)
7006
- };
7007
- } catch (err) {
7008
- return {
7009
- ok: false,
7010
- error: err instanceof ExpressionEvalError ? err.message : String(err)
7011
- };
7012
- }
7013
- }
7014
- //#endregion
7015
- //#region src/capabilities/device-provider.cap.ts
7016
- var ProviderStatusSchema = zod.z.object({
7017
- connected: zod.z.boolean(),
7018
- deviceCount: zod.z.number(),
7019
- error: zod.z.string().optional()
6466
+ /** One child-placement directive on a container's `childLayout`. Structurally
6467
+ * identical to `ChildLayoutEntry` in `device-management.ts` — the cap wire
6468
+ * shape for the same field. The child is identified by its re-sync-stable
6469
+ * accessory `stableIdSuffix` (`childKey`); listed children are grouped into
6470
+ * named accordion sections (with optional intra-section order). */
6471
+ var ChildLayoutEntrySchema = zod.z.object({
6472
+ childKey: zod.z.string(),
6473
+ section: zod.z.string(),
6474
+ order: zod.z.number().optional(),
6475
+ collapsed: zod.z.boolean().optional()
7020
6476
  });
7021
- var DiscoveredDeviceSchema = zod.z.object({
7022
- externalId: zod.z.string(),
7023
- name: zod.z.string(),
7024
- type: zod.z.string(),
7025
- metadata: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
6477
+ /** Cap-wire shape of a per-cap display refinement — mirrors
6478
+ * `DeviceCapDisplayOverride` in `device-management.ts`. */
6479
+ var DeviceCapDisplayOverrideSchema = zod.z.object({
6480
+ unit: zod.z.string().min(1).optional(),
6481
+ precision: zod.z.number().int().min(0).max(10).optional()
7026
6482
  });
7027
- /**
7028
- * Candidate handed back from discovery and accepted by
7029
- * `adoptDiscoveredDevice`. Shape mirrors the in-process
7030
- * `DiscoveredDevice` interface used by `DeviceDiscovery`.
7031
- */
7032
- var DiscoveryCandidateSchema = zod.z.object({
7033
- stableId: zod.z.string(),
7034
- type: zod.z.enum(require_sleep.DeviceType),
7035
- suggestedName: zod.z.string(),
7036
- prefilledConfig: zod.z.record(zod.z.string(), zod.z.unknown()),
7037
- /**
7038
- * Optional upstream-system identity (HA entity_id, vendor MAC, …).
7039
- * Discovery pre-populates this for systems that know the upstream
7040
- * identity ahead of adoption. Rendering metadata (unit, precision)
7041
- * flows live through the cap STATUS SLICE after adoption.
7042
- */
7043
- sourceInfo: SourceInfoSchema.optional()
6483
+ /** Cap-wire shape of an operator-authored per-device display override —
6484
+ * mirrors `DeviceDisplayOverride` in `device-management.ts`. `precision`
6485
+ * bounds mirror `numeric-sensor.cap.ts` (`int 0-10`). */
6486
+ var DeviceDisplayOverrideSchema = zod.z.object({
6487
+ icon: zod.z.string().min(1).optional(),
6488
+ label: zod.z.string().min(1).optional(),
6489
+ unit: zod.z.string().min(1).optional(),
6490
+ precision: zod.z.number().int().min(0).max(10).optional(),
6491
+ hidden: zod.z.boolean().optional(),
6492
+ perCap: zod.z.record(zod.z.string(), DeviceCapDisplayOverrideSchema).optional()
6493
+ });
6494
+ /** Cap-wire shape of a per-role display default mirrors `RoleDisplayDefault`
6495
+ * in `device-management.ts`. Keyed by `DeviceRole` string (role strings cross
6496
+ * the wire as plain strings everywhere else cf. `DeviceInfoSchema.role`). */
6497
+ var RoleDisplayDefaultSchema = zod.z.object({
6498
+ unit: zod.z.string().min(1).optional(),
6499
+ precision: zod.z.number().int().min(0).max(10).optional(),
6500
+ icon: zod.z.string().min(1).optional()
7044
6501
  });
7045
6502
  /**
7046
- * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
7047
- * Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
7048
- * tRPC layer can pass it through without reshaping.
6503
+ * Serializable projection of a live IDevice.
6504
+ * Returned by listAll, getDevice, getChildren.
6505
+ * Live methods (getStreamSources, getConfigSchema) are separate calls.
7049
6506
  */
7050
- var DeviceSummarySchema = zod.z.object({
6507
+ var DeviceInfoSchema = zod.z.object({
6508
+ /** Progressive, system-wide unique number. Allocated synchronously by
6509
+ * `device-manager.allocateDeviceId` BEFORE the owning `IDevice` is
6510
+ * constructed, so every live device exposes an `id` — no transient
6511
+ * null window. Distinct from `stableId`, which is unique per
6512
+ * integration. Ids are monotonic and never reissued on removal. */
7051
6513
  id: zod.z.number(),
7052
6514
  stableId: zod.z.string(),
7053
6515
  addonId: zod.z.string(),
7054
- type: zod.z.string(),
6516
+ type: zod.z.enum(require_sleep.DeviceType),
7055
6517
  name: zod.z.string(),
6518
+ /** Operator-organisational location label. `null` when unset. */
6519
+ location: zod.z.string().nullable(),
6520
+ /** Soft-disabled flag. */
6521
+ disabled: zod.z.boolean(),
7056
6522
  parentDeviceId: zod.z.number().nullable(),
6523
+ /** Optional semantic role — `DeviceRole` string. null for top-level devices. */
6524
+ role: zod.z.string().nullable().optional(),
7057
6525
  online: zod.z.boolean(),
6526
+ /** True when the device's initial feature-probe has completed (or it has
6527
+ * no probe) — exported shape is stable; exporters gate advertise on this.
6528
+ * Optional for deploy-order resilience: a record produced by an older
6529
+ * device-manager build omits it, and consumers treat absent as "not ready"
6530
+ * (export gate carries forward, never advertises a partial shape). */
6531
+ probed: zod.z.boolean().optional(),
7058
6532
  features: zod.z.array(zod.z.string()),
7059
- config: zod.z.record(zod.z.string(), zod.z.unknown()),
7060
- /** Optional upstream-system identity (dispatch key + system tag).
7061
- * See `SourceInfo`. Present when the device has a non-synthetic
7062
- * source identifier (HA entities, vendor MAC, …); omitted when the
7063
- * synthetic backfill is in effect. */
7064
- sourceInfo: SourceInfoSchema.optional()
7065
- });
7066
- /**
7067
- * Result of a live field test (e.g. probing an RTSP URL during device
7068
- * creation). Matches the UI-side `FieldProbeResult` in
7069
- * `interfaces/config-ui.ts` — the admin `FormBuilder` renders the
7070
- * returned `labels` as chips next to the input.
7071
- */
7072
- var FieldProbeResultSchema = zod.z.object({
7073
- status: zod.z.enum(["ok", "error"]),
7074
- labels: zod.z.array(zod.z.string()).optional(),
7075
- error: zod.z.string().optional()
7076
- });
7077
- /**
7078
- * The output of `getChildCreationSchema` is a UI schema tree. We store
7079
- * it as `unknown` at the capability layer — the router just passes it
7080
- * through and the admin UI renders it via `FormBuilder`. The actual
7081
- * type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
7082
- * but we deliberately avoid a Zod mirror because the union is large and
7083
- * not meant for runtime validation at this seam.
7084
- */
7085
- var CreationSchemaOutputSchema = zod.z.unknown();
7086
- var deviceProviderCapability = {
7087
- name: "device-provider",
7088
- scope: "system",
7089
- mode: "collection",
7090
- methods: {
7091
- start: require_sleep.method(zod.z.void(), zod.z.void(), { kind: "mutation" }),
7092
- stop: require_sleep.method(zod.z.void(), zod.z.void(), { kind: "mutation" }),
7093
- getStatus: require_sleep.method(zod.z.void(), ProviderStatusSchema),
7094
- getDevices: require_sleep.method(zod.z.void(), zod.z.array(zod.z.object({
7095
- id: zod.z.string(),
7096
- name: zod.z.string(),
7097
- type: zod.z.string()
7098
- }))),
7099
- supportsDiscovery: require_sleep.method(zod.z.object({}), zod.z.boolean()),
7100
- /**
7101
- * Run a network scan. `params` carries optional provider-specific scan
7102
- * inputs (e.g. a broadcast address / subnet for cross-subnet discovery),
7103
- * shaped by `getDiscoveryParamsSchema`. Omitted for the generic scan
7104
- * (provider uses its local-network default).
7105
- */
7106
- discoverDevices: require_sleep.method(zod.z.object({ params: zod.z.record(zod.z.string(), zod.z.unknown()).optional() }), zod.z.array(DiscoveryCandidateSchema), {
7107
- kind: "mutation",
7108
- auth: "admin"
7109
- }),
7110
- /**
7111
- * Optional form schema (`ConfigUISchema`) for the EXTRA per-scan inputs a
7112
- * provider accepts (e.g. Gree's broadcast address for a different subnet).
7113
- * `null` when the provider takes no extra scan params — the generic
7114
- * aggregated scan never renders this; the per-integration scan does.
7115
- */
7116
- getDiscoveryParamsSchema: require_sleep.method(zod.z.object({}), CreationSchemaOutputSchema),
7117
- /**
7118
- * The DeviceType this provider creates via manual add (Camera for
7119
- * Reolink/ONVIF, Container for Gree, Hub for Ecowitt). `null` when the
7120
- * provider does not support manual creation. Lets the Add-Device dialog
7121
- * pick the right type instead of assuming Camera.
7122
- */
7123
- getManualCreationType: require_sleep.method(zod.z.object({}), zod.z.object({ deviceType: zod.z.enum(require_sleep.DeviceType).nullable() })),
7124
- adoptDiscoveredDevice: require_sleep.method(zod.z.object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
7125
- kind: "mutation",
7126
- auth: "admin"
7127
- }),
7128
- supportsManualCreation: require_sleep.method(zod.z.object({}), zod.z.boolean()),
7129
- /**
7130
- * Fetch the creation form schema for a given DeviceType. Returns
7131
- * `null` when the provider does not support manually creating
7132
- * devices of that type. The output is a `ConfigUISchema` — the
7133
- * router type-asserts it at the boundary.
7134
- */
7135
- getChildCreationSchema: require_sleep.method(zod.z.object({ type: zod.z.enum(require_sleep.DeviceType) }), CreationSchemaOutputSchema),
7136
- createDevice: require_sleep.method(zod.z.object({
7137
- type: zod.z.enum(require_sleep.DeviceType),
7138
- config: zod.z.record(zod.z.string(), zod.z.unknown())
7139
- }), DeviceSummarySchema, {
7140
- kind: "mutation",
7141
- auth: "admin"
7142
- }),
7143
- /**
7144
- * Test a single field in the creation form before the device has
7145
- * been persisted. Typical use: probing an RTSP URL entered by the
7146
- * user. Providers that don't support field probing return
7147
- * `{ success: true, message: 'Field test not supported' }`.
7148
- *
7149
- * `formValues` is the live snapshot of every field in the form at
7150
- * the moment the user clicked Test — useful for probes that depend
7151
- * on multiple fields together (e.g. Reolink autodetect needs host
7152
- * + credentials + UID + transport mode in a single call). Optional
7153
- * for backwards compatibility; providers free to ignore it.
7154
- */
7155
- testCreationField: require_sleep.method(zod.z.object({
7156
- type: zod.z.enum(require_sleep.DeviceType),
7157
- key: zod.z.string(),
7158
- value: zod.z.unknown(),
7159
- formValues: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
7160
- }), FieldProbeResultSchema, {
7161
- kind: "mutation",
7162
- auth: "admin"
7163
- })
7164
- }
7165
- };
7166
- //#endregion
7167
- //#region src/capabilities/device-manager.cap.ts
7168
- /**
7169
- * Device Manager capability — hub-side singleton that unifies device persistence,
7170
- * live registry access, and all management operations into a single tRPC surface.
7171
- *
7172
- * Replaces:
7173
- * - `device-persistence` capability (persistence methods absorbed here)
7174
- * - `device-management.router.ts` (deleted in Phase 2)
7175
- * - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
7176
- *
7177
- * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
7178
- * fork into separate processes but never run on remote cluster agents. Therefore:
7179
- * - No nodeId routing needed — this is a pure hub singleton.
7180
- * - The hub's DeviceRegistry is the single source of truth for all live devices.
7181
- * - No shadow registry or cross-node aggregation required.
7182
- *
7183
- * Forked workers register devices back to the hub via `ctx.devices`
7184
- * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
7185
- */
7186
- /** One child-placement directive on a container's `childLayout`. Structurally
7187
- * identical to `ChildLayoutEntry` in `device-management.ts` — the cap wire
7188
- * shape for the same field. The child is identified by its re-sync-stable
7189
- * accessory `stableIdSuffix` (`childKey`); listed children are grouped into
7190
- * named accordion sections (with optional intra-section order). */
7191
- var ChildLayoutEntrySchema = zod.z.object({
7192
- childKey: zod.z.string(),
7193
- section: zod.z.string(),
7194
- order: zod.z.number().optional(),
7195
- collapsed: zod.z.boolean().optional()
7196
- });
7197
- /** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
7198
- * `device-management.ts`. Source is a union: a FIELD source copies a sibling
7199
- * accessory's status field (`kind` optional/absent for wire compat); a
7200
- * LITERAL source carries a per-device constant (no sibling is read); a
7201
- * GLOBAL source (P2e) copies ANY device's status field, addressed by the
7202
- * source device's full re-sync-stable `stableId`. */
7203
- var DeviceLinkFieldSourceSchema = zod.z.object({
7204
- kind: zod.z.literal("field").optional(),
7205
- sourceKey: zod.z.string(),
7206
- cap: zod.z.string(),
7207
- fieldPath: zod.z.string()
7208
- });
7209
- var DeviceLinkLiteralSourceSchema = zod.z.object({
7210
- kind: zod.z.literal("literal"),
7211
- value: zod.z.union([
7212
- zod.z.string(),
7213
- zod.z.number(),
7214
- zod.z.boolean(),
7215
- zod.z.null()
7216
- ])
7217
- });
7218
- var DeviceLinkGlobalSourceSchema = zod.z.object({
7219
- kind: zod.z.literal("global"),
7220
- sourceStableId: zod.z.string(),
7221
- cap: zod.z.string(),
7222
- fieldPath: zod.z.string()
7223
- });
7224
- /** Expression source (Stage X): compute the target field from N named bindings
7225
- * via the safe expression engine. Bindings are field | literal | global — never
7226
- * another expression (no nesting). The `superRefine` runs the SAME author-time
7227
- * validation as `validateExpressionSource` (compiles the expr, checks binding
7228
- * names + identifier coverage) so every wire boundary that parses a DeviceLink
7229
- * (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
7230
- * Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
7231
- var DeviceLinkExpressionSourceSchema = zod.z.object({
7232
- kind: zod.z.literal("expression"),
7233
- expr: zod.z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
7234
- bindings: zod.z.record(zod.z.string().regex(EXPRESSION_IDENTIFIER_RE), zod.z.union([
7235
- DeviceLinkFieldSourceSchema,
7236
- DeviceLinkLiteralSourceSchema,
7237
- DeviceLinkGlobalSourceSchema
7238
- ]))
7239
- }).superRefine((src, ctx) => {
7240
- const err = validateExpressionSource(src);
7241
- if (err !== null) ctx.addIssue({
7242
- code: "custom",
7243
- message: err,
7244
- path: ["expr"]
7245
- });
7246
- });
7247
- var DeviceLinkSchema = zod.z.object({
7248
- id: zod.z.string(),
7249
- source: zod.z.union([
7250
- DeviceLinkFieldSourceSchema,
7251
- DeviceLinkLiteralSourceSchema,
7252
- DeviceLinkGlobalSourceSchema,
7253
- DeviceLinkExpressionSourceSchema
7254
- ]),
7255
- target: zod.z.object({
7256
- cap: zod.z.string(),
7257
- fieldPath: zod.z.string(),
7258
- itemKey: zod.z.string().optional()
7259
- }),
7260
- transform: zod.z.discriminatedUnion("kind", [
7261
- zod.z.object({ kind: zod.z.literal("identity") }),
7262
- zod.z.object({
7263
- kind: zod.z.literal("enum-map"),
7264
- mapping: zod.z.record(zod.z.string(), zod.z.union([
7265
- zod.z.string(),
7266
- zod.z.number(),
7267
- zod.z.boolean()
7268
- ])),
7269
- fallback: zod.z.union([
7270
- zod.z.string(),
7271
- zod.z.number(),
7272
- zod.z.boolean()
7273
- ]).optional()
7274
- }),
7275
- zod.z.object({
7276
- kind: zod.z.literal("linear"),
7277
- scale: zod.z.number(),
7278
- offset: zod.z.number(),
7279
- clamp: zod.z.tuple([zod.z.number(), zod.z.number()]).readonly().optional()
7280
- })
7281
- ]).optional()
7282
- });
7283
- /** Cap-wire shape of a per-cap display refinement — mirrors
7284
- * `DeviceCapDisplayOverride` in `device-management.ts`. */
7285
- var DeviceCapDisplayOverrideSchema = zod.z.object({
7286
- unit: zod.z.string().min(1).optional(),
7287
- precision: zod.z.number().int().min(0).max(10).optional()
7288
- });
7289
- /** Cap-wire shape of an operator-authored per-device display override —
7290
- * mirrors `DeviceDisplayOverride` in `device-management.ts`. `precision`
7291
- * bounds mirror `numeric-sensor.cap.ts` (`int 0-10`). */
7292
- var DeviceDisplayOverrideSchema = zod.z.object({
7293
- icon: zod.z.string().min(1).optional(),
7294
- label: zod.z.string().min(1).optional(),
7295
- unit: zod.z.string().min(1).optional(),
7296
- precision: zod.z.number().int().min(0).max(10).optional(),
7297
- hidden: zod.z.boolean().optional(),
7298
- perCap: zod.z.record(zod.z.string(), DeviceCapDisplayOverrideSchema).optional()
7299
- });
7300
- /** Cap-wire shape of a per-role display default — mirrors `RoleDisplayDefault`
7301
- * in `device-management.ts`. Keyed by `DeviceRole` string (role strings cross
7302
- * the wire as plain strings everywhere else — cf. `DeviceInfoSchema.role`). */
7303
- var RoleDisplayDefaultSchema = zod.z.object({
7304
- unit: zod.z.string().min(1).optional(),
7305
- precision: zod.z.number().int().min(0).max(10).optional(),
7306
- icon: zod.z.string().min(1).optional()
7307
- });
7308
- /**
7309
- * Serializable projection of a live IDevice.
7310
- * Returned by listAll, getDevice, getChildren.
7311
- * Live methods (getStreamSources, getConfigSchema) are separate calls.
7312
- */
7313
- var DeviceInfoSchema = zod.z.object({
7314
- /** Progressive, system-wide unique number. Allocated synchronously by
7315
- * `device-manager.allocateDeviceId` BEFORE the owning `IDevice` is
7316
- * constructed, so every live device exposes an `id` — no transient
7317
- * null window. Distinct from `stableId`, which is unique per
7318
- * integration. Ids are monotonic and never reissued on removal. */
7319
- id: zod.z.number(),
7320
- stableId: zod.z.string(),
7321
- addonId: zod.z.string(),
7322
- type: zod.z.enum(require_sleep.DeviceType),
7323
- name: zod.z.string(),
7324
- /** Operator-organisational location label. `null` when unset. */
7325
- location: zod.z.string().nullable(),
7326
- /** Soft-disabled flag. */
7327
- disabled: zod.z.boolean(),
7328
- parentDeviceId: zod.z.number().nullable(),
7329
- /** Optional semantic role — `DeviceRole` string. null for top-level devices. */
7330
- role: zod.z.string().nullable().optional(),
7331
- online: zod.z.boolean(),
7332
- /** True when the device's initial feature-probe has completed (or it has
7333
- * no probe) — exported shape is stable; exporters gate advertise on this.
7334
- * Optional for deploy-order resilience: a record produced by an older
7335
- * device-manager build omits it, and consumers treat absent as "not ready"
7336
- * (export gate carries forward, never advertises a partial shape). */
7337
- probed: zod.z.boolean().optional(),
7338
- features: zod.z.array(zod.z.string()),
7339
- /** true when the device has a getStreamSources() method (ICameraDevice) */
7340
- isCamera: zod.z.boolean(),
7341
- /** Current config values — serializable snapshot */
6533
+ /** true when the device has a getStreamSources() method (ICameraDevice) */
6534
+ isCamera: zod.z.boolean(),
6535
+ /** Current config values serializable snapshot */
7342
6536
  config: zod.z.record(zod.z.string(), zod.z.unknown()),
7343
6537
  /** Hardware + identity blob (manufacturer / model / firmware / sn /
7344
6538
  * uid / mac / …). Populated by drivers; editable via setMetadata. */
@@ -7359,8 +6553,6 @@ var DeviceInfoSchema = zod.z.object({
7359
6553
  * named accordion sections (with optional intra-section order). See
7360
6554
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
7361
6555
  childLayout: zod.z.array(ChildLayoutEntrySchema).readonly().optional(),
7362
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
7363
- deviceLinks: zod.z.array(DeviceLinkSchema).readonly().optional(),
7364
6556
  /** Operator-authored per-device display override. See `DeviceMeta.display`. */
7365
6557
  display: DeviceDisplayOverrideSchema.optional()
7366
6558
  });
@@ -7369,7 +6561,7 @@ var ConfigEntrySchema = zod.z.object({
7369
6561
  value: zod.z.unknown(),
7370
6562
  description: zod.z.string().optional()
7371
6563
  });
7372
- var DeviceLinkModeSchema = zod.z.enum(["auto", "manual"]);
6564
+ var LinkedDevicesModeSchema = zod.z.enum(["auto", "manual"]);
7373
6565
  /** One resolved linked device — the compact projection consumers need. */
7374
6566
  var LinkedDeviceSchema = zod.z.object({
7375
6567
  deviceId: zod.z.number(),
@@ -7432,8 +6624,6 @@ var DeviceMetaSchema = zod.z.object({
7432
6624
  * accordion sections (with optional intra-section order). See
7433
6625
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
7434
6626
  childLayout: zod.z.array(ChildLayoutEntrySchema).readonly().optional(),
7435
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
7436
- deviceLinks: zod.z.array(DeviceLinkSchema).readonly().optional(),
7437
6627
  /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
7438
6628
  * Optional: only present for accessory children that carry a known role. */
7439
6629
  role: zod.z.string().nullable().optional(),
@@ -7585,16 +6775,6 @@ var deviceManagerCapability = {
7585
6775
  kind: "mutation",
7586
6776
  auth: "admin"
7587
6777
  }),
7588
- /** Set (or replace) the cross-device field wirings on a device's meta row.
7589
- * Mirrors `setChildLayout`; persisted, projected, preserved across
7590
- * re-register/restore. Idempotent. */
7591
- setDeviceLinks: require_sleep.method(zod.z.object({
7592
- deviceId: zod.z.number(),
7593
- deviceLinks: zod.z.array(DeviceLinkSchema).readonly()
7594
- }), zod.z.void(), {
7595
- kind: "mutation",
7596
- auth: "admin"
7597
- }),
7598
6778
  /** Set (or clear) the per-device display override on the meta row. Mirrors
7599
6779
  * `setChildLayout` persistence; `null` clears the override entirely. The
7600
6780
  * override unit(s) are normalized (`normalizeUnit`) at write so the render
@@ -7618,15 +6798,15 @@ var deviceManagerCapability = {
7618
6798
  kind: "mutation",
7619
6799
  auth: "admin"
7620
6800
  }),
7621
- /** List the wireable status-schema fields per cap bound to a device.
7622
- * Powers the Wiring tab's field pickers. Caps without a status schema are
7623
- * omitted. Item-array caps (`status.itemArray`, e.g. consumables) also
7624
- * emit their per-item fields tagged `item: true` (a link targeting one
7625
- * must carry a `target.itemKey`) plus the cap-level `itemArray`
7626
- * descriptor. `includeSynthesizable: true` (TARGET pickers only) unions
7627
- * in unbound device-scoped caps that declare `status.empty` and match
7628
- * the device's type so the FIRST link to a synthesize-only cap
7629
- * (consumables on an HA vacuum) can be authored. */
6801
+ /** The per-cap catalog of readable status-schema fields for one device.
6802
+ * This is the SOURCE PICKER it outlived the Wiring tab that first
6803
+ * needed it (deleted 2026-08-08) and becomes the picker of a composition
6804
+ * recipe: every field the old tab could target, a composed device can
6805
+ * read. Caps without a status schema are omitted. Item-array caps
6806
+ * (`status.itemArray`, e.g. consumables) also emit their per-item fields
6807
+ * tagged `item: true` plus the cap-level `itemArray` descriptor.
6808
+ * `includeSynthesizable: true` unions in unbound device-scoped caps that
6809
+ * declare `status.empty` and match the device's type. */
7630
6810
  getWireableFields: require_sleep.method(zod.z.object({
7631
6811
  deviceId: zod.z.number(),
7632
6812
  includeSynthesizable: zod.z.boolean().optional()
@@ -7768,7 +6948,7 @@ var deviceManagerCapability = {
7768
6948
  * (see the module docblock) — there are no bespoke link mutations.
7769
6949
  */
7770
6950
  getLinkedDevices: require_sleep.method(zod.z.object({ deviceId: zod.z.number() }), zod.z.object({
7771
- mode: DeviceLinkModeSchema,
6951
+ mode: LinkedDevicesModeSchema,
7772
6952
  devices: zod.z.array(LinkedDeviceSchema)
7773
6953
  })),
7774
6954
  /** Get stream sources for a camera device. */
@@ -7832,11 +7012,7 @@ var deviceManagerCapability = {
7832
7012
  deviceId: zod.z.number(),
7833
7013
  entries: zod.z.array(zod.z.object({
7834
7014
  capName: zod.z.string(),
7835
- kind: zod.z.enum([
7836
- "native",
7837
- "wrapped",
7838
- "linked"
7839
- ]),
7015
+ kind: zod.z.enum(["native", "wrapped"]),
7840
7016
  providerAddonId: zod.z.string(),
7841
7017
  providerNodeId: zod.z.string(),
7842
7018
  nativeAddonId: zod.z.string()
@@ -7854,11 +7030,7 @@ var deviceManagerCapability = {
7854
7030
  deviceId: zod.z.number(),
7855
7031
  entries: zod.z.array(zod.z.object({
7856
7032
  capName: zod.z.string(),
7857
- kind: zod.z.enum([
7858
- "native",
7859
- "wrapped",
7860
- "linked"
7861
- ]),
7033
+ kind: zod.z.enum(["native", "wrapped"]),
7862
7034
  providerAddonId: zod.z.string(),
7863
7035
  providerNodeId: zod.z.string(),
7864
7036
  nativeAddonId: zod.z.string()
@@ -9104,6 +8276,10 @@ var motionDetectionCapability = {
9104
8276
  mode: "singleton",
9105
8277
  kind: "wrapper",
9106
8278
  defaultActive: true,
8279
+ /** Frame-differencing motion analysis over a video stream — only a camera
8280
+ * produces the frames. Read by the `defaultActive` auto-bind in
8281
+ * `device-manager.getBindings` to decide which devices it may claim. */
8282
+ deviceTypes: [require_sleep.DeviceType.Camera],
9107
8283
  exposesDeviceSettings: true,
9108
8284
  methods: {
9109
8285
  analyze: require_sleep.method(zod.z.object({
@@ -12225,6 +11401,18 @@ var OauthIntegrationDescriptorSchema = zod.z.object({
12225
11401
  * redirect_uri that does not start with one of these. Required —
12226
11402
  * an empty list means the integration can never complete linking. */
12227
11403
  allowedRedirectPrefixes: zod.z.array(zod.z.string()).min(1),
11404
+ /** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
11405
+ * RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
11406
+ * `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
11407
+ * whose address the hub cannot know in advance (a Home Assistant at
11408
+ * `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
11409
+ * exactly; a public host never satisfies this branch, so it is not a
11410
+ * wildcard prefix by another name. */
11411
+ allowedPrivateHostPaths: zod.z.array(zod.z.string()).optional(),
11412
+ /** When true this is a PUBLIC client (source is published, no secret can be
11413
+ * protected) and PKCE is mandatory: `/authorize` refuses without an S256
11414
+ * `code_challenge`, `/token` refuses without the matching `code_verifier`. */
11415
+ requiresPkce: zod.z.boolean().optional(),
12228
11416
  /** Optional public origin (no trailing slash) that this integration's
12229
11417
  * issued codes/tokens should carry as the `hubUrl` claim — typically the
12230
11418
  * operator-selected external-access endpoint resolved by the addon. When
@@ -12383,7 +11571,7 @@ var TrackEnvelopeSchema = zod.z.object({
12383
11571
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
12384
11572
  * keeps every scalar the list surfaces actually render (ids, class(es),
12385
11573
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
12386
- * zonesVisited, bestEventId, envelope) and returns `positions` /
11574
+ * zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
12387
11575
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
12388
11576
  * `getTrack`. Mirrors the event-store `projection` convention
12389
11577
  * (`getObjectEvents` et al.).
@@ -12636,6 +11824,24 @@ var TrackSchema = zod.z.object({
12636
11824
  * Populated from the persisted envelope columns on historical reads;
12637
11825
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
12638
11826
  envelope: TrackEnvelopeSchema.optional(),
11827
+ /**
11828
+ * A face DETECTOR found a face on this track — nothing more. It says the
11829
+ * detail plane produced a `face` detail; it does NOT say the face was
11830
+ * embedded, matched, above `minFacePx`, or that the recognizer was even
11831
+ * enabled. Set once and never cleared.
11832
+ *
11833
+ * **This exists so "face present but not recognised" is expressible.** A
11834
+ * recognised identity lands in `subLabel` (attributed to the face chain via
11835
+ * `subLabelMeta.stepId`), so before this field a track with an unmatched face
11836
+ * and a track with no face at all were byte-identical on the wire and no
11837
+ * surface could tell them apart. The read is `hasFace === true && subLabel
11838
+ * === undefined`.
11839
+ *
11840
+ * **Absent ≠ false.** Every row written before the column existed omits it,
11841
+ * and so does every server that predates the field — a consumer must test
11842
+ * `=== true` and render nothing otherwise, never infer "no face".
11843
+ */
11844
+ hasFace: zod.z.boolean().optional(),
12639
11845
  ...TrackFlagFields,
12640
11846
  ...TrackRetrainFields
12641
11847
  });
@@ -16577,6 +15783,10 @@ var SsoBridgeClaimsSchema = zod.z.object({
16577
15783
  integrationId: zod.z.string().optional(),
16578
15784
  /** JWT ID — unique per issued code; consumed-set enforces single-use. */
16579
15785
  jti: zod.z.string().optional(),
15786
+ /** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
15787
+ * client. Its PRESENCE is what makes the verifier mandatory at exchange,
15788
+ * so the requirement travels with the code and not with mutable config. */
15789
+ codeChallenge: zod.z.string().optional(),
16580
15790
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
16581
15791
  * tokens so the verify path can check the session is not revoked. */
16582
15792
  sessionId: zod.z.string().optional()
@@ -17349,6 +16559,10 @@ var videoclipsCapability = {
17349
16559
  mode: "singleton",
17350
16560
  kind: "wrapper",
17351
16561
  defaultActive: true,
16562
+ /** A clip is a window over a camera's footage — the cap is meaningless on a
16563
+ * sensor, a button or an event emitter, and the `defaultActive` auto-bind
16564
+ * reads this to decide which devices it may claim. */
16565
+ deviceTypes: [require_sleep.DeviceType.Camera],
17352
16566
  methods: {
17353
16567
  listClips: require_sleep.method(zod.z.object({
17354
16568
  deviceId: zod.z.number(),
@@ -19900,7 +19114,29 @@ var FaceInfoSchema = zod.z.object({
19900
19114
  recognizedIdentityId: zod.z.string().optional(),
19901
19115
  identityName: zod.z.string().optional(),
19902
19116
  assigned: zod.z.boolean(),
19117
+ /**
19118
+ * The crop, inline, base64.
19119
+ *
19120
+ * **Prefer {@link cropUrl}.** At the 500 rows the Faces view asks for this
19121
+ * field alone is ~2.87 MiB, re-sent in full on every operator assign and
19122
+ * every 30 s poll, base64-inflated over the msgpack socket and held in the
19123
+ * query heap. It stays for callers that have not migrated; `includeCrops:
19124
+ * false` turns it off once they have.
19125
+ */
19903
19126
  base64: zod.z.string().optional(),
19127
+ /**
19128
+ * Same crop, as a data-plane URL for `<img src>` — the move the admin
19129
+ * snapshot surfaces made on 2026-08-08.
19130
+ *
19131
+ * Served by the `event-media` plane, which resolves a raw MediaStore key and
19132
+ * is `access: 'authenticated'`: a bare `<img>` carries the `camstack_session`
19133
+ * cookie, so no header plumbing is needed. The bytes then ride the browser's
19134
+ * HTTP cache with an ETag and `immutable`, instead of the WebSocket.
19135
+ *
19136
+ * Absent when the face has no stored crop, or when the addon has no data
19137
+ * plane — callers fall back to {@link base64}.
19138
+ */
19139
+ cropUrl: zod.z.string().optional(),
19904
19140
  /** Design B: the face bbox (pixel space) on the key frame — lets a detail
19905
19141
  * view draw the box over the native `keyFrameMediaKey` frame. Absent on
19906
19142
  * legacy rows written before design B. */
@@ -19976,7 +19212,23 @@ var faceGalleryCapability = {
19976
19212
  }),
19977
19213
  listRecentFaces: require_sleep.method(zod.z.object({
19978
19214
  limit: zod.z.number().int().positive().optional(),
19979
- filter: FaceFilterEnum.optional()
19215
+ filter: FaceFilterEnum.optional(),
19216
+ /**
19217
+ * Inline the base64 crop on every row. Default `true` — the existing
19218
+ * behaviour, kept so no caller breaks.
19219
+ *
19220
+ * Set `false` once the caller renders {@link FaceInfo.cropUrl}: that
19221
+ * drops ~2.87 MiB per 500-row page to a few KiB of metadata and lets
19222
+ * the browser cache the images.
19223
+ *
19224
+ * **This is an INPUT field, so it does not reach the addon until the
19225
+ * next train.** The hub router validates cap inputs against its own
19226
+ * compiled Zod, which strips a key it does not know — verified today
19227
+ * on the OUTPUT side, where an additive field DOES arrive immediately
19228
+ * (`Track.hasFace`). Until the train ships, sending `false` is
19229
+ * harmless and simply keeps the crops inline.
19230
+ */
19231
+ includeCrops: zod.z.boolean().optional()
19980
19232
  }).optional(), zod.z.array(FaceInfoSchema).readonly()),
19981
19233
  getFaceByTrack: require_sleep.method(zod.z.object({
19982
19234
  deviceId: zod.z.number().int(),
@@ -25489,14 +24741,19 @@ var userManagementCapability = {
25489
24741
  username: zod.z.string(),
25490
24742
  scopes: zod.z.array(TokenScopeSchema),
25491
24743
  redirectUri: zod.z.string(),
25492
- hubUrl: zod.z.string()
24744
+ hubUrl: zod.z.string(),
24745
+ /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
24746
+ * that carries one can ONLY be exchanged with the matching verifier. */
24747
+ codeChallenge: zod.z.string().optional()
25493
24748
  }), zod.z.object({ code: zod.z.string() }), {
25494
24749
  kind: "mutation",
25495
24750
  access: "create"
25496
24751
  }),
25497
24752
  oauthExchangeCode: require_sleep.method(zod.z.object({
25498
24753
  code: zod.z.string(),
25499
- redirectUri: zod.z.string()
24754
+ redirectUri: zod.z.string(),
24755
+ /** PKCE verifier. REQUIRED when the code carries a challenge. */
24756
+ codeVerifier: zod.z.string().optional()
25500
24757
  }), zod.z.object({
25501
24758
  accessToken: zod.z.string(),
25502
24759
  refreshToken: zod.z.string(),
@@ -27387,6 +26644,218 @@ var BaseDeviceProvider = class extends require_sleep.BaseAddon {
27387
26644
  }
27388
26645
  };
27389
26646
  //#endregion
26647
+ //#region src/device/declared-device.ts
26648
+ /** Marker written to a declared integration's `info`. */
26649
+ var DECLARED_INTEGRATION_FIXED_KEY = "fixed";
26650
+ /**
26651
+ * Strip the `<node>/<addon>` suffix a forked child carries.
26652
+ *
26653
+ * Comparing `ctx.kernel.localNodeId` raw skipped EVERY node — including the one
26654
+ * that was supposed to act — because on the hub it reads `hub/<addon>`.
26655
+ */
26656
+ function declarationOwnerNodeId(localNodeId) {
26657
+ const raw = localNodeId ?? "hub";
26658
+ if (!raw.includes("/")) return raw;
26659
+ return raw.split("/")[0] ?? "hub";
26660
+ }
26661
+ /** Cap on how many rows one withdrawal pass may delete. */
26662
+ var DECLARED_DEVICE_SWEEP_LIMIT = 32;
26663
+ /**
26664
+ * The one way an addon owns a device it declares.
26665
+ *
26666
+ * Construct once with the addon's ports, then call {@link reconcile} on boot and
26667
+ * on every convergence tick. There is no second get-or-create helper — a guard
26668
+ * in `scripts/` enforces that.
26669
+ */
26670
+ var DeclaredDevices = class {
26671
+ ports;
26672
+ constructor(ports) {
26673
+ this.ports = ports;
26674
+ }
26675
+ /**
26676
+ * Converge the declared set. Idempotent, and safe to call repeatedly.
26677
+ *
26678
+ * Throws only what the ports throw on the FIRST index read; every other
26679
+ * failure is per-device and logged, so one bad declaration never takes the
26680
+ * others down.
26681
+ */
26682
+ async reconcile(spec) {
26683
+ if ((spec.placement ?? "hub") === "hub") {
26684
+ const nodeId = declarationOwnerNodeId(this.ports.localNodeId);
26685
+ if (nodeId !== "hub") {
26686
+ this.ports.logger.info("declared devices are hub-owned — skipping on this node", { meta: {
26687
+ nodeId,
26688
+ rawNodeId: this.ports.localNodeId ?? null
26689
+ } });
26690
+ return {
26691
+ integrationId: null,
26692
+ devices: [],
26693
+ removed: [],
26694
+ owned: false
26695
+ };
26696
+ }
26697
+ }
26698
+ const integrationId = await this.ensureIntegration(spec.integrationName);
26699
+ const index = await this.readIndex();
26700
+ const outcomes = [];
26701
+ for (const declaration of spec.devices) {
26702
+ const outcome = await this.applyDeclaration(declaration, integrationId, index);
26703
+ if (outcome !== null) outcomes.push(outcome);
26704
+ }
26705
+ return {
26706
+ integrationId,
26707
+ devices: outcomes,
26708
+ removed: await this.sweepWithdrawn(spec.devices, integrationId, index),
26709
+ owned: true
26710
+ };
26711
+ }
26712
+ /**
26713
+ * Get-or-create the FIXED integration, and RE-ASSERT the flag every pass.
26714
+ *
26715
+ * The re-assertion is the fix for the defect the hand-rolled version shipped
26716
+ * with: writing `info.fixed` only on the create path left every pre-existing
26717
+ * install without it, and the kernel kept offering to delete an integration
26718
+ * the addon owns.
26719
+ */
26720
+ async ensureIntegration(integrationName) {
26721
+ const existing = await this.ports.getIntegration(this.ports.addonId);
26722
+ if (existing === null) {
26723
+ const created = await this.ports.createIntegration({
26724
+ addonId: this.ports.addonId,
26725
+ name: integrationName,
26726
+ info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
26727
+ });
26728
+ this.ports.logger.info("declared a fixed integration", { meta: {
26729
+ integrationId: created.id,
26730
+ name: integrationName
26731
+ } });
26732
+ return created.id;
26733
+ }
26734
+ if (existing.info?.["fixed"] !== true) {
26735
+ await this.ports.updateIntegration({
26736
+ id: existing.id,
26737
+ info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
26738
+ });
26739
+ this.ports.logger.info("re-asserted `fixed` on a declared integration", { meta: { integrationId: existing.id } });
26740
+ }
26741
+ return existing.id;
26742
+ }
26743
+ async readIndex() {
26744
+ const rows = await this.ports.listOwnDevices();
26745
+ return new Map(rows.map((row) => [row.stableId, row]));
26746
+ }
26747
+ /**
26748
+ * One declaration: adopt what exists, create what does not.
26749
+ *
26750
+ * The create branch is the destructive one — it seeds `initialMeta`, and
26751
+ * `initialMeta.name` lands as an unconditional `setName`. A transiently empty
26752
+ * index therefore looks exactly like a first boot and would silently re-stamp
26753
+ * the declared name over the operator's rename. D49: that branch needs a
26754
+ * second read to agree.
26755
+ */
26756
+ async applyDeclaration(declaration, integrationId, index) {
26757
+ try {
26758
+ let existing = index.get(declaration.stableId);
26759
+ if (existing === void 0) {
26760
+ existing = (await this.readIndex()).get(declaration.stableId);
26761
+ if (existing !== void 0) this.ports.logger.warn("device index disagreed with itself — adopting instead of re-creating", {
26762
+ tags: { deviceId: existing.id },
26763
+ meta: {
26764
+ stableId: declaration.stableId,
26765
+ addonId: this.ports.addonId
26766
+ }
26767
+ });
26768
+ }
26769
+ if (existing !== void 0) {
26770
+ const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, {}, null, void 0);
26771
+ this.ports.logger.info("declared device adopted", {
26772
+ tags: { deviceId: device.id },
26773
+ meta: {
26774
+ stableId: declaration.stableId,
26775
+ integrationId
26776
+ }
26777
+ });
26778
+ return {
26779
+ stableId: declaration.stableId,
26780
+ deviceId: device.id,
26781
+ device,
26782
+ created: false
26783
+ };
26784
+ }
26785
+ const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, declaration.config ?? {}, null, {
26786
+ type: declaration.type,
26787
+ name: declaration.name,
26788
+ integrationId,
26789
+ ...declaration.role === void 0 ? {} : { role: declaration.role }
26790
+ });
26791
+ this.ports.logger.info("declared device created", {
26792
+ tags: { deviceId: device.id },
26793
+ meta: {
26794
+ stableId: declaration.stableId,
26795
+ integrationId
26796
+ }
26797
+ });
26798
+ return {
26799
+ stableId: declaration.stableId,
26800
+ deviceId: device.id,
26801
+ device,
26802
+ created: true
26803
+ };
26804
+ } catch (err) {
26805
+ this.ports.logger.warn("a declared device could not be brought up", { meta: {
26806
+ stableId: declaration.stableId,
26807
+ error: err instanceof Error ? err.message : String(err)
26808
+ } });
26809
+ return null;
26810
+ }
26811
+ }
26812
+ /**
26813
+ * Remove rows under the addon's FIXED integration whose declaration is gone.
26814
+ *
26815
+ * Bounded to that integration: a declared integration has no operator
26816
+ * add-flow, so every row under it got there by declaration. Devices this
26817
+ * addon owns OUTSIDE it (a provider's adopted devices) are never candidates.
26818
+ *
26819
+ * Bounded in count, and every deletion is logged with its `deviceId` — a
26820
+ * withdrawal that removes an operator-visible row silently is the failure
26821
+ * mode, not the removal itself.
26822
+ */
26823
+ async sweepWithdrawn(declarations, integrationId, index) {
26824
+ const declared = new Set(declarations.map((d) => d.stableId));
26825
+ const candidates = [...index.values()].filter((row) => row.integrationId === integrationId && !declared.has(row.stableId));
26826
+ if (candidates.length === 0) return [];
26827
+ if (candidates.length > 32) {
26828
+ this.ports.logger.warn("withdrawal sweep exceeded its bound — removing nothing", { meta: {
26829
+ integrationId,
26830
+ candidates: candidates.length,
26831
+ bound: 32
26832
+ } });
26833
+ return [];
26834
+ }
26835
+ const removed = [];
26836
+ for (const row of candidates) try {
26837
+ await this.ports.devices.remove(row.id);
26838
+ removed.push(row.id);
26839
+ this.ports.logger.info("declared device removed — its declaration was withdrawn", {
26840
+ tags: { deviceId: row.id },
26841
+ meta: {
26842
+ stableId: row.stableId,
26843
+ integrationId
26844
+ }
26845
+ });
26846
+ } catch (err) {
26847
+ this.ports.logger.warn("a withdrawn declared device could not be removed", {
26848
+ tags: { deviceId: row.id },
26849
+ meta: {
26850
+ stableId: row.stableId,
26851
+ error: err instanceof Error ? err.message : String(err)
26852
+ }
26853
+ });
26854
+ }
26855
+ return removed;
26856
+ }
26857
+ };
26858
+ //#endregion
27390
26859
  //#region src/device/device-control-resolution.ts
27391
26860
  /**
27392
26861
  * Device-control RESOLUTION LOGIC — the single, presentation-free source of
@@ -27563,30 +27032,6 @@ function resolveMutate(router, method) {
27563
27032
  return typeof mutate === "function" ? mutate : null;
27564
27033
  }
27565
27034
  //#endregion
27566
- //#region src/device/device-link-transform.ts
27567
- /** Apply a link transform to a resolved source value. `identity`/undefined
27568
- * pass through; `enum-map` remaps strings (fallback or null when unknown);
27569
- * `linear` does scale*x+offset with optional clamp (null for non-numbers). */
27570
- function applyTransform(value, transform) {
27571
- if (!transform || transform.kind === "identity") return value;
27572
- if (transform.kind === "enum-map") {
27573
- const key = String(value);
27574
- if (Object.prototype.hasOwnProperty.call(transform.mapping, key)) return transform.mapping[key];
27575
- return transform.fallback ?? null;
27576
- }
27577
- if (transform.kind === "linear") {
27578
- if (typeof value !== "number" || Number.isNaN(value)) return null;
27579
- let out = value * transform.scale + transform.offset;
27580
- if (transform.clamp) {
27581
- const min = transform.clamp[0];
27582
- const max = transform.clamp[1];
27583
- out = Math.min(max, Math.max(min, out));
27584
- }
27585
- return out;
27586
- }
27587
- return value;
27588
- }
27589
- //#endregion
27590
27035
  //#region src/device/device-profile.ts
27591
27036
  /**
27592
27037
  * Heuristic check — `device.features?.includes(profile.when.hasFeature)`.
@@ -28115,501 +27560,1347 @@ var SystemMirror = class {
28115
27560
  const proxy = this.getDeviceById(deviceId);
28116
27561
  if (proxy) out.push(proxy);
28117
27562
  }
28118
- return out;
28119
- }
28120
- /** Map every device's slice for `capName` to a derived value. Devices
28121
- * without the cap or without a slice are skipped. */
28122
- mapState(capName, mapper) {
28123
- const out = [];
28124
- for (const [deviceId, perCap] of this.stateMirror) {
28125
- const slice = perCap.get(capName);
28126
- if (!slice) continue;
28127
- out.push(mapper(slice, deviceId));
27563
+ return out;
27564
+ }
27565
+ /** Map every device's slice for `capName` to a derived value. Devices
27566
+ * without the cap or without a slice are skipped. */
27567
+ mapState(capName, mapper) {
27568
+ const out = [];
27569
+ for (const [deviceId, perCap] of this.stateMirror) {
27570
+ const slice = perCap.get(capName);
27571
+ if (!slice) continue;
27572
+ out.push(mapper(slice, deviceId));
27573
+ }
27574
+ return out;
27575
+ }
27576
+ /** First device whose slice matches `predicate`, or null. */
27577
+ findState(capName, predicate) {
27578
+ for (const [deviceId, perCap] of this.stateMirror) {
27579
+ const slice = perCap.get(capName);
27580
+ if (!slice) continue;
27581
+ if (!predicate(slice, deviceId)) continue;
27582
+ return this.getDeviceById(deviceId);
27583
+ }
27584
+ return null;
27585
+ }
27586
+ /** Count devices that bind a cap. Faster than `filterByCap(...).length`. */
27587
+ countByCap(capName) {
27588
+ let n = 0;
27589
+ for (const binding of this.bindings.values()) if (binding.entries.some((e) => e.capName === capName)) n++;
27590
+ return n;
27591
+ }
27592
+ /** Count devices whose slice for `capName` matches `predicate`. */
27593
+ countByState(capName, predicate) {
27594
+ let n = 0;
27595
+ for (const [deviceId, perCap] of this.stateMirror) {
27596
+ const slice = perCap.get(capName);
27597
+ if (!slice) continue;
27598
+ if (predicate(slice, deviceId)) n++;
27599
+ }
27600
+ return n;
27601
+ }
27602
+ /**
27603
+ * Global listener — fires for every `device.state-changed` event the
27604
+ * mirror absorbs.
27605
+ */
27606
+ listen(cb) {
27607
+ this.globalStateListeners.add(cb);
27608
+ return () => {
27609
+ this.globalStateListeners.delete(cb);
27610
+ };
27611
+ }
27612
+ /**
27613
+ * Per-cap listener — fires only for state changes on `capName`,
27614
+ * across every device. The callback receives the deviceId so the
27615
+ * caller can route.
27616
+ */
27617
+ listenCap(capName, cb) {
27618
+ let set = this.capListeners.get(capName);
27619
+ if (!set) {
27620
+ set = /* @__PURE__ */ new Set();
27621
+ this.capListeners.set(capName, set);
27622
+ }
27623
+ set.add(cb);
27624
+ return () => {
27625
+ set.delete(cb);
27626
+ if (set.size === 0) this.capListeners.delete(capName);
27627
+ };
27628
+ }
27629
+ /**
27630
+ * Per-device listener — fires for every cap change on `deviceId`.
27631
+ */
27632
+ listenDevice(deviceId, cb) {
27633
+ let set = this.deviceListeners.get(deviceId);
27634
+ if (!set) {
27635
+ set = /* @__PURE__ */ new Set();
27636
+ this.deviceListeners.set(deviceId, set);
27637
+ }
27638
+ set.add(cb);
27639
+ return () => {
27640
+ set.delete(cb);
27641
+ if (set.size === 0) this.deviceListeners.delete(deviceId);
27642
+ };
27643
+ }
27644
+ /** Fires when `device.registered` lands. Receives the new metadata. */
27645
+ onDeviceAdded(cb) {
27646
+ this.addedListeners.add(cb);
27647
+ return () => {
27648
+ this.addedListeners.delete(cb);
27649
+ };
27650
+ }
27651
+ /** Fires when `device.unregistered` lands. `info` is the LAST-known
27652
+ * metadata (or null if the device was never seen). */
27653
+ onDeviceRemoved(cb) {
27654
+ this.removedListeners.add(cb);
27655
+ return () => {
27656
+ this.removedListeners.delete(cb);
27657
+ };
27658
+ }
27659
+ /**
27660
+ * Resolve when `predicate` over the runtime-state slice for
27661
+ * `(deviceId, capName)` becomes true. Resolves immediately if the
27662
+ * current slice already matches. Rejects with `Error('timeout')`
27663
+ * after `timeoutMs` (default 30s; pass `Infinity` to wait forever).
27664
+ *
27665
+ * Returns the matching slice — caller can read it directly without
27666
+ * a second mirror lookup.
27667
+ */
27668
+ waitForState(deviceId, capName, predicate, timeoutMs = 3e4) {
27669
+ return new Promise((resolve, reject) => {
27670
+ const check = () => {
27671
+ const slice = this.stateMirror.get(deviceId)?.get(capName);
27672
+ if (slice && predicate(slice)) return slice;
27673
+ return null;
27674
+ };
27675
+ const initial = check();
27676
+ if (initial) {
27677
+ resolve(initial);
27678
+ return;
27679
+ }
27680
+ let timer = null;
27681
+ const off = this.listenDevice(deviceId, (_id, cap, slice) => {
27682
+ if (cap !== capName) return;
27683
+ if (!slice) return;
27684
+ if (predicate(slice)) {
27685
+ if (timer) clearTimeout(timer);
27686
+ off();
27687
+ resolve(slice);
27688
+ }
27689
+ });
27690
+ if (Number.isFinite(timeoutMs)) timer = setTimeout(() => {
27691
+ off();
27692
+ reject(/* @__PURE__ */ new Error(`waitForState timed out after ${timeoutMs}ms (deviceId=${deviceId}, capName=${capName})`));
27693
+ }, timeoutMs);
27694
+ });
27695
+ }
27696
+ /**
27697
+ * Resolve when a device with `deviceId` becomes available (a
27698
+ * binding exists). Resolves immediately if already known. Rejects
27699
+ * with timeout.
27700
+ */
27701
+ waitForDevice(deviceId, timeoutMs = 3e4) {
27702
+ return new Promise((resolve, reject) => {
27703
+ const existing = this.getDeviceById(deviceId);
27704
+ if (existing) {
27705
+ resolve(existing);
27706
+ return;
27707
+ }
27708
+ let timer = null;
27709
+ const off = this.onDeviceAdded((id) => {
27710
+ if (id !== deviceId) return;
27711
+ const proxy = this.getDeviceById(id);
27712
+ if (!proxy) return;
27713
+ if (timer) clearTimeout(timer);
27714
+ off();
27715
+ resolve(proxy);
27716
+ });
27717
+ if (Number.isFinite(timeoutMs)) timer = setTimeout(() => {
27718
+ off();
27719
+ reject(/* @__PURE__ */ new Error(`waitForDevice timed out after ${timeoutMs}ms (deviceId=${deviceId})`));
27720
+ }, timeoutMs);
27721
+ });
27722
+ }
27723
+ /**
27724
+ * Iterate every device that binds `capName`. Awaits each callback
27725
+ * sequentially — for parallel use `invokeCap` with explicit
27726
+ * parallelism.
27727
+ */
27728
+ async forEachCap(capName, cb) {
27729
+ for (const proxy of this.filterByCap(capName)) await cb(proxy);
27730
+ }
27731
+ /**
27732
+ * Invoke a cap method on every device that binds the cap. Returns
27733
+ * one result per device, success or failure isolated. Optional
27734
+ * parallelism cap — useful for "snapshot all cameras but only 4
27735
+ * at a time so battery cams don't all wake at once".
27736
+ *
27737
+ * Example:
27738
+ *
27739
+ * const results = await sm.invokeCap('snapshot', 'getSnapshot', {}, { parallelism: 4 })
27740
+ * const failed = results.filter(r => !r.ok)
27741
+ */
27742
+ async invokeCap(capName, methodName, args, opts = {}) {
27743
+ const targets = this.filterByCap(capName);
27744
+ const parallelism = Math.max(1, opts.parallelism ?? targets.length);
27745
+ const out = [];
27746
+ for (let i = 0; i < targets.length; i += parallelism) {
27747
+ const chunk = targets.slice(i, i + parallelism);
27748
+ const settled = await Promise.allSettled(chunk.map(async (proxy) => {
27749
+ const cap = proxy[capName];
27750
+ if (!cap || typeof cap[methodName] !== "function") throw new Error(`device '${proxy.deviceId}' does not expose '${capName}.${methodName}'`);
27751
+ return await cap[methodName](args);
27752
+ }));
27753
+ for (let j = 0; j < settled.length; j++) {
27754
+ const proxy = chunk[j];
27755
+ const r = settled[j];
27756
+ if (r.status === "fulfilled") out.push({
27757
+ deviceId: proxy.deviceId,
27758
+ ok: true,
27759
+ result: r.value
27760
+ });
27761
+ else out.push({
27762
+ deviceId: proxy.deviceId,
27763
+ ok: false,
27764
+ error: r.reason
27765
+ });
27766
+ }
27767
+ }
27768
+ return out;
27769
+ }
27770
+ /**
27771
+ * One-shot summary — fleet size, breakdown by cap / addon / type.
27772
+ * Designed for REPL inspection (`sm.summary()`).
27773
+ */
27774
+ summary() {
27775
+ const byCap = {};
27776
+ const byAddon = {};
27777
+ const byType = {};
27778
+ let online = 0;
27779
+ let offline = 0;
27780
+ for (const binding of this.bindings.values()) for (const entry of binding.entries) byCap[entry.capName] = (byCap[entry.capName] ?? 0) + 1;
27781
+ for (const info of this.devices.values()) {
27782
+ byAddon[info.addonId] = (byAddon[info.addonId] ?? 0) + 1;
27783
+ byType[info.type] = (byType[info.type] ?? 0) + 1;
27784
+ if (info.online) online++;
27785
+ else offline++;
27786
+ }
27787
+ return {
27788
+ totalDevices: this.bindings.size,
27789
+ online,
27790
+ offline,
27791
+ byCap,
27792
+ byAddon,
27793
+ byType,
27794
+ statedDevices: this.stateMirror.size
27795
+ };
27796
+ }
27797
+ /**
27798
+ * Debug-friendly dump — full state + binding + metadata for one
27799
+ * device or all devices. Cheap deep clone so caller mutations don't
27800
+ * leak into the mirror.
27801
+ */
27802
+ dump(deviceId) {
27803
+ const dumpOne = (id) => {
27804
+ const info = this.devices.get(id) ?? null;
27805
+ const binding = this.bindings.get(id) ?? null;
27806
+ const state = {};
27807
+ const perCap = this.stateMirror.get(id);
27808
+ if (perCap) for (const [cap, slice] of perCap) state[cap] = { ...slice };
27809
+ return {
27810
+ deviceId: id,
27811
+ info,
27812
+ binding: binding ? {
27813
+ ...binding,
27814
+ entries: binding.entries.map((e) => ({ ...e }))
27815
+ } : null,
27816
+ state
27817
+ };
27818
+ };
27819
+ if (deviceId !== void 0) return dumpOne(deviceId);
27820
+ const out = [];
27821
+ for (const id of this.bindings.keys()) out.push(dumpOne(id));
27822
+ return out;
27823
+ }
27824
+ /**
27825
+ * Direct read-only access to the underlying state mirror. Use
27826
+ * sparingly — `getSystemState` returns a deep copy that's safer for
27827
+ * exploratory work; this avoids the clone cost when iterating
27828
+ * thousands of slices.
27829
+ */
27830
+ getRawMirror() {
27831
+ return this.stateMirror;
27832
+ }
27833
+ /**
27834
+ * Snapshot of the full state mirror — same shape as the warm-boot
27835
+ * payload. Deep-cloned; safe to mutate.
27836
+ */
27837
+ getSystemState() {
27838
+ const copy = /* @__PURE__ */ new Map();
27839
+ for (const [id, perCap] of this.stateMirror) {
27840
+ const dup = /* @__PURE__ */ new Map();
27841
+ for (const [k, v] of perCap) dup.set(k, { ...v });
27842
+ copy.set(id, dup);
27843
+ }
27844
+ return copy;
27845
+ }
27846
+ subscribeBus() {
27847
+ if (!this.api.live?.onEvent) return;
27848
+ const sub = this.api.live.onEvent;
27849
+ this.bridges.push(sub.subscribe({ category: STATE_CHANGED_CATEGORY }, { onData: (evt) => {
27850
+ const data = evt.data;
27851
+ if (!data || typeof data.deviceId !== "number" || typeof data.capName !== "string") return;
27852
+ this.applyStateUpdate(data.deviceId, data.capName, data.slice);
27853
+ } }));
27854
+ this.bridges.push(sub.subscribe({ category: BINDING_CHANGED_CATEGORY }, { onData: (evt) => {
27855
+ const data = evt.data;
27856
+ const deviceId = typeof data?.deviceId === "number" ? data.deviceId : data?.source?.type === "device" && typeof data.source.id === "number" ? data.source.id : null;
27857
+ if (deviceId === null) return;
27858
+ this.refreshBinding(deviceId);
27859
+ } }));
27860
+ this.bridges.push(sub.subscribe({ category: DEVICE_REGISTERED_CATEGORY }, { onData: (evt) => {
27861
+ const data = evt.data;
27862
+ if (typeof data?.deviceId !== "number") return;
27863
+ this.refreshDeviceMetadata(data.deviceId, "added");
27864
+ } }));
27865
+ this.bridges.push(sub.subscribe({ category: DEVICE_UNREGISTERED_CATEGORY }, { onData: (evt) => {
27866
+ const data = evt.data;
27867
+ if (typeof data?.deviceId !== "number") return;
27868
+ this.applyDeviceRemoval(data.deviceId);
27869
+ } }));
27870
+ this.bridges.push(sub.subscribe({ category: DEVICE_UPDATED_CATEGORY }, { onData: (evt) => {
27871
+ const data = evt.data;
27872
+ if (typeof data?.deviceId !== "number") return;
27873
+ this.refreshDeviceMetadata(data.deviceId, "updated");
27874
+ } }));
27875
+ }
27876
+ applyStateUpdate(deviceId, capName, slice) {
27877
+ let perCap = this.stateMirror.get(deviceId);
27878
+ if (!perCap) {
27879
+ perCap = /* @__PURE__ */ new Map();
27880
+ this.stateMirror.set(deviceId, perCap);
27881
+ }
27882
+ if (slice === void 0) perCap.delete(capName);
27883
+ else perCap.set(capName, slice);
27884
+ const handleKey = `${deviceId}:${capName}`;
27885
+ const handleSet = this.handleListeners.get(handleKey);
27886
+ if (handleSet) for (const cb of handleSet) try {
27887
+ cb(slice);
27888
+ } catch {}
27889
+ for (const cb of this.globalStateListeners) try {
27890
+ cb(deviceId, capName, slice);
27891
+ } catch {}
27892
+ const capSet = this.capListeners.get(capName);
27893
+ if (capSet) for (const cb of capSet) try {
27894
+ cb(deviceId, slice);
27895
+ } catch {}
27896
+ const devSet = this.deviceListeners.get(deviceId);
27897
+ if (devSet) for (const cb of devSet) try {
27898
+ cb(deviceId, capName, slice);
27899
+ } catch {}
27900
+ }
27901
+ applyDeviceRemoval(deviceId) {
27902
+ const lastInfo = this.devices.get(deviceId) ?? null;
27903
+ this.bindings.delete(deviceId);
27904
+ this.devices.delete(deviceId);
27905
+ this.stateMirror.delete(deviceId);
27906
+ for (const cb of this.removedListeners) try {
27907
+ cb(deviceId, lastInfo);
27908
+ } catch {}
27909
+ }
27910
+ async refreshBinding(deviceId) {
27911
+ try {
27912
+ const fresh = (await this.api.deviceManager.getAllBindings.query({})).find((b) => b.deviceId === deviceId);
27913
+ if (fresh) this.bindings.set(deviceId, fresh);
27914
+ else this.applyDeviceRemoval(deviceId);
27915
+ } catch {}
27916
+ }
27917
+ async refreshDeviceMetadata(deviceId, kind) {
27918
+ try {
27919
+ const info = await this.api.deviceManager.getDevice.query({ deviceId });
27920
+ if (!info) return;
27921
+ const wasNew = !this.devices.has(deviceId);
27922
+ this.devices.set(deviceId, info);
27923
+ if (kind === "added" && wasNew) {
27924
+ await this.refreshBinding(deviceId);
27925
+ for (const cb of this.addedListeners) try {
27926
+ cb(deviceId, info);
27927
+ } catch {}
27928
+ }
27929
+ } catch {}
27930
+ }
27931
+ };
27932
+ function inSet(value, set) {
27933
+ if (Array.isArray(set)) return set.includes(value);
27934
+ return value === set;
27935
+ }
27936
+ function toArray(value) {
27937
+ return Array.isArray(value) ? value : [value];
27938
+ }
27939
+ function matchesString(haystack, match) {
27940
+ if (typeof match === "string") return haystack === match;
27941
+ if (match instanceof RegExp) return match.test(haystack);
27942
+ if ("exact" in match) return haystack === match.exact;
27943
+ if ("contains" in match) return haystack.toLowerCase().includes(match.contains.toLowerCase());
27944
+ return false;
27945
+ }
27946
+ //#endregion
27947
+ //#region src/device/zod-to-config-ui.ts
27948
+ /** Access Zod v4 internal .def — not in public typings but stable at runtime */
27949
+ function zodDef(schema) {
27950
+ return schema.def;
27951
+ }
27952
+ /** Access internal properties on a Zod schema instance */
27953
+ function zodInternals(schema) {
27954
+ return schema;
27955
+ }
27956
+ /**
27957
+ * Convert DeviceConfig.entries() output to ConfigUISchema for the admin UI FormBuilder.
27958
+ *
27959
+ * Each entry's Zod type is inspected to determine the correct ConfigField type:
27960
+ * - ZodString → 'text' (or 'password' when key contains "password"/"secret"/"token"/"apikey")
27961
+ * - ZodNumber → 'number' (extracts min/max/step from Zod v4 checks)
27962
+ * - ZodBoolean → 'boolean'
27963
+ * - ZodEnum → 'select' (options built from enum values)
27964
+ * - Anything else → 'text' fallback
27965
+ *
27966
+ * Wrapper types ZodDefault, ZodOptional, and ZodNullable are unwrapped transparently.
27967
+ * Default values are extracted from ZodDefault wrappers.
27968
+ */
27969
+ function zodEntriesToConfigUI(entries, sectionTitle = "Configuration", sectionId = "main") {
27970
+ return { sections: [{
27971
+ id: sectionId,
27972
+ title: sectionTitle,
27973
+ fields: entries.map((entry) => zodToConfigField(entry.key, entry.schema, entry.description))
27974
+ }] };
27975
+ }
27976
+ function zodToConfigField(key, schema, description) {
27977
+ const inner = unwrapZod(schema);
27978
+ const defaultValue = getZodDefault(schema);
27979
+ const base = {
27980
+ key,
27981
+ label: description ?? humanizeKey(key),
27982
+ description,
27983
+ default: defaultValue
27984
+ };
27985
+ if (inner instanceof zod.z.ZodString) return buildStringField(key, base);
27986
+ if (inner instanceof zod.z.ZodNumber) return buildNumberField(inner, base);
27987
+ if (inner instanceof zod.z.ZodBoolean) return {
27988
+ ...base,
27989
+ type: "boolean"
27990
+ };
27991
+ if (inner instanceof zod.z.ZodEnum) return buildEnumField(inner, base);
27992
+ if (inner instanceof zod.z.ZodArray || inner instanceof zod.z.ZodObject) return {
27993
+ ...base,
27994
+ type: "textarea",
27995
+ rows: 6,
27996
+ isJson: true
27997
+ };
27998
+ return {
27999
+ ...base,
28000
+ type: "text"
28001
+ };
28002
+ }
28003
+ function buildStringField(key, base) {
28004
+ const lowerKey = key.toLowerCase();
28005
+ if (lowerKey.includes("password") || lowerKey.includes("secret") || lowerKey.includes("token") || lowerKey.includes("apikey") || lowerKey.includes("api_key")) return {
28006
+ ...base,
28007
+ type: "password",
28008
+ showToggle: true
28009
+ };
28010
+ return {
28011
+ ...base,
28012
+ type: "text"
28013
+ };
28014
+ }
28015
+ function buildNumberField(inner, base) {
28016
+ const anyInner = zodInternals(inner);
28017
+ const rawMin = anyInner.minValue;
28018
+ const rawMax = anyInner.maxValue;
28019
+ const min = rawMin != null && isFinite(rawMin) ? rawMin : void 0;
28020
+ const max = rawMax != null && isFinite(rawMax) ? rawMax : void 0;
28021
+ const step = getMultipleOfStep(inner);
28022
+ return {
28023
+ ...base,
28024
+ type: "number",
28025
+ ...min !== void 0 ? { min } : {},
28026
+ ...max !== void 0 ? { max } : {},
28027
+ ...step !== void 0 ? { step } : {}
28028
+ };
28029
+ }
28030
+ function getMultipleOfStep(inner) {
28031
+ const checks = zodDef(inner).checks ?? [];
28032
+ for (const check of checks) if (check._zod?.def?.check === "multiple_of" && check._zod.def.value !== void 0) return check._zod.def.value;
28033
+ }
28034
+ function buildEnumField(inner, base) {
28035
+ const values = inner.options.map((v) => String(v));
28036
+ return {
28037
+ ...base,
28038
+ type: "select",
28039
+ options: values.map((v) => ({
28040
+ label: humanizeKey(v),
28041
+ value: v
28042
+ }))
28043
+ };
28044
+ }
28045
+ function unwrapZod(schema) {
28046
+ if (schema instanceof zod.z.ZodDefault) return unwrapZod(zodDef(schema).innerType);
28047
+ if (schema instanceof zod.z.ZodOptional) return unwrapZod(zodDef(schema).innerType);
28048
+ if (schema instanceof zod.z.ZodNullable) return unwrapZod(zodDef(schema).innerType);
28049
+ return schema;
28050
+ }
28051
+ function getZodDefault(schema) {
28052
+ if (schema instanceof zod.z.ZodDefault) return zodDef(schema).defaultValue;
28053
+ }
28054
+ function humanizeKey(key) {
28055
+ return key.replace(/([A-Z])/g, " $1").replace(/[_-]/g, " ").replace(/^\w/, (c) => c.toUpperCase()).trim();
28056
+ }
28057
+ //#endregion
28058
+ //#region src/expression/errors.ts
28059
+ /**
28060
+ * Error types for the safe expression engine. Two distinct classes so callers
28061
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
28062
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
28063
+ */
28064
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
28065
+ * the failure is anchored to a character (author-facing inline feedback). */
28066
+ var ExpressionParseError = class extends Error {
28067
+ position;
28068
+ constructor(message, position) {
28069
+ super(message);
28070
+ this.name = "ExpressionParseError";
28071
+ this.position = position;
28072
+ }
28073
+ };
28074
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
28075
+ * result, unknown builtin, step-budget exceeded). */
28076
+ var ExpressionEvalError = class extends Error {
28077
+ constructor(message) {
28078
+ super(message);
28079
+ this.name = "ExpressionEvalError";
28080
+ }
28081
+ };
28082
+ //#endregion
28083
+ //#region src/expression/builtins.ts
28084
+ /**
28085
+ * Frozen, null-prototype builtin function table for the expression engine
28086
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
28087
+ * parser rejects any callee not in it, and the evaluator gates each call on an
28088
+ * own-property check against it.
28089
+ *
28090
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
28091
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
28092
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
28093
+ * (there is no `Object.prototype` in the chain), so those names are not
28094
+ * callable — they are simply "unknown function" at parse time.
28095
+ *
28096
+ * Every numeric argument is validated as a finite number and every numeric
28097
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
28098
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
28099
+ * closed rather than emitting a garbage value.
28100
+ */
28101
+ function asFiniteNumber(value, name, index) {
28102
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
28103
+ return value;
28104
+ }
28105
+ function asString$1(value, name, index) {
28106
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
28107
+ return value;
28108
+ }
28109
+ function finiteResult(value, name) {
28110
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
28111
+ return value;
28112
+ }
28113
+ function allFiniteNumbers(args, name) {
28114
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
28115
+ }
28116
+ var INF = Number.POSITIVE_INFINITY;
28117
+ var table = {
28118
+ min: {
28119
+ minArgs: 1,
28120
+ maxArgs: INF,
28121
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
28122
+ },
28123
+ max: {
28124
+ minArgs: 1,
28125
+ maxArgs: INF,
28126
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
28127
+ },
28128
+ abs: {
28129
+ minArgs: 1,
28130
+ maxArgs: 1,
28131
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
28132
+ },
28133
+ floor: {
28134
+ minArgs: 1,
28135
+ maxArgs: 1,
28136
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
28137
+ },
28138
+ ceil: {
28139
+ minArgs: 1,
28140
+ maxArgs: 1,
28141
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
28142
+ },
28143
+ sqrt: {
28144
+ minArgs: 1,
28145
+ maxArgs: 1,
28146
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
28147
+ },
28148
+ round: {
28149
+ minArgs: 1,
28150
+ maxArgs: 2,
28151
+ apply: (args) => {
28152
+ const x = asFiniteNumber(args[0], "round", 0);
28153
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
28154
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
28155
+ const factor = 10 ** digits;
28156
+ return finiteResult(Math.round(x * factor) / factor, "round");
28157
+ }
28158
+ },
28159
+ pow: {
28160
+ minArgs: 2,
28161
+ maxArgs: 2,
28162
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
28163
+ },
28164
+ clamp: {
28165
+ minArgs: 3,
28166
+ maxArgs: 3,
28167
+ apply: (args) => {
28168
+ const x = asFiniteNumber(args[0], "clamp", 0);
28169
+ const lo = asFiniteNumber(args[1], "clamp", 1);
28170
+ const hi = asFiniteNumber(args[2], "clamp", 2);
28171
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
28172
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
28173
+ }
28174
+ },
28175
+ avg: {
28176
+ minArgs: 1,
28177
+ maxArgs: INF,
28178
+ apply: (args) => {
28179
+ const nums = allFiniteNumbers(args, "avg");
28180
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
28181
+ }
28182
+ },
28183
+ sum: {
28184
+ minArgs: 1,
28185
+ maxArgs: INF,
28186
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
28187
+ },
28188
+ coalesce: {
28189
+ minArgs: 1,
28190
+ maxArgs: INF,
28191
+ apply: (args) => {
28192
+ for (const a of args) if (a !== null) return a;
28193
+ return null;
28194
+ }
28195
+ },
28196
+ age: {
28197
+ minArgs: 2,
28198
+ maxArgs: 2,
28199
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
28200
+ },
28201
+ convert: {
28202
+ minArgs: 3,
28203
+ maxArgs: 3,
28204
+ apply: (args, hooks) => {
28205
+ const x = asFiniteNumber(args[0], "convert", 0);
28206
+ const from = asString$1(args[1], "convert", 1).trim();
28207
+ const to = asString$1(args[2], "convert", 2).trim();
28208
+ if (hooks.convert) {
28209
+ const out = hooks.convert(x, from, to);
28210
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
28211
+ return finiteResult(out, "convert");
28212
+ }
28213
+ if (from === to) return x;
28214
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
28215
+ }
28216
+ }
28217
+ };
28218
+ /** Frozen, null-prototype builtin table. */
28219
+ var EXPRESSION_BUILTINS = Object.freeze(Object.assign(Object.create(null), table));
28220
+ /** The set of valid builtin names — used by the parser to reject unknown
28221
+ * callees at parse time (immediate author feedback). */
28222
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
28223
+ //#endregion
28224
+ //#region src/expression/limits.ts
28225
+ /**
28226
+ * Resource-bound constants for the safe expression engine.
28227
+ *
28228
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
28229
+ * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
28230
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
28231
+ * work a single author-supplied expression can request, so a hostile or
28232
+ * accidental pathological string can never spend unbounded CPU/memory.
28233
+ */
28234
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
28235
+ * rejected without allocation. */
28236
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
28237
+ /** Max AST nodes — checked during parse; a deeply nested grouping that exceeds
28238
+ * this is rejected as "expression too complex". */
28239
+ var MAX_EXPRESSION_AST_NODES = 256;
28240
+ /** Defense-in-depth walker step budget — one increment per node visit during
28241
+ * evaluation. The grammar guarantees O(nodeCount) walks, so this can only trip
28242
+ * on a crafted maximum-size AST. */
28243
+ var MAX_EXPRESSION_EVAL_STEPS = 4096;
28244
+ /** Max named bindings on one {@link ExpressionSource}. */
28245
+ var MAX_EXPRESSION_BINDINGS = 32;
28246
+ /** Max positional arguments to any builtin call. */
28247
+ var MAX_EXPRESSION_CALL_ARGS = 16;
28248
+ /** LRU compile-cache capacity (parsed ASTs keyed by raw source string). */
28249
+ var EXPRESSION_COMPILE_CACHE_CAPACITY = 256;
28250
+ /** A legal binding / identifier name. */
28251
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
28252
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
28253
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
28254
+ var RESERVED_BINDING_NAMES = new Set([
28255
+ "now",
28256
+ "true",
28257
+ "false",
28258
+ "null"
28259
+ ]);
28260
+ //#endregion
28261
+ //#region src/expression/tokenizer.ts
28262
+ /**
28263
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
28264
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
28265
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
28266
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
28267
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
28268
+ * is a parse error with a source position, so member access / assignment /
28269
+ * template literals are lexically impossible.
28270
+ */
28271
+ var KEYWORDS = new Set([
28272
+ "true",
28273
+ "false",
28274
+ "null"
28275
+ ]);
28276
+ function isDigit(ch) {
28277
+ return ch >= "0" && ch <= "9";
28278
+ }
28279
+ function isIdentStart(ch) {
28280
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
28281
+ }
28282
+ function isIdentPart(ch) {
28283
+ return isIdentStart(ch) || isDigit(ch);
28284
+ }
28285
+ function isWhitespace(ch) {
28286
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
28287
+ }
28288
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
28289
+ * Throws `ExpressionParseError` on any illegal character or unterminated
28290
+ * string. */
28291
+ function tokenize(source) {
28292
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
28293
+ const tokens = [];
28294
+ let i = 0;
28295
+ const n = source.length;
28296
+ while (i < n) {
28297
+ const ch = source[i];
28298
+ if (isWhitespace(ch)) {
28299
+ i += 1;
28300
+ continue;
28301
+ }
28302
+ if (isDigit(ch)) {
28303
+ const start = i;
28304
+ while (i < n && isDigit(source[i])) i += 1;
28305
+ if (i < n && source[i] === ".") {
28306
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
28307
+ i += 1;
28308
+ while (i < n && isDigit(source[i])) i += 1;
28309
+ }
28310
+ const text = source.slice(start, i);
28311
+ const value = Number(text);
28312
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
28313
+ tokens.push({
28314
+ type: "number",
28315
+ value,
28316
+ pos: start
28317
+ });
28318
+ continue;
28128
28319
  }
28129
- return out;
28130
- }
28131
- /** First device whose slice matches `predicate`, or null. */
28132
- findState(capName, predicate) {
28133
- for (const [deviceId, perCap] of this.stateMirror) {
28134
- const slice = perCap.get(capName);
28135
- if (!slice) continue;
28136
- if (!predicate(slice, deviceId)) continue;
28137
- return this.getDeviceById(deviceId);
28320
+ if (ch === "'" || ch === "\"") {
28321
+ const quote = ch;
28322
+ const start = i;
28323
+ i += 1;
28324
+ let out = "";
28325
+ let closed = false;
28326
+ while (i < n) {
28327
+ const c = source[i];
28328
+ if (c === "\\") {
28329
+ const next = i + 1 < n ? source[i + 1] : "";
28330
+ if (next === "\\" || next === "'" || next === "\"") {
28331
+ out += next;
28332
+ i += 2;
28333
+ continue;
28334
+ }
28335
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
28336
+ }
28337
+ if (c === quote) {
28338
+ closed = true;
28339
+ i += 1;
28340
+ break;
28341
+ }
28342
+ out += c;
28343
+ i += 1;
28344
+ }
28345
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
28346
+ tokens.push({
28347
+ type: "string",
28348
+ value: out,
28349
+ pos: start
28350
+ });
28351
+ continue;
28138
28352
  }
28139
- return null;
28140
- }
28141
- /** Count devices that bind a cap. Faster than `filterByCap(...).length`. */
28142
- countByCap(capName) {
28143
- let n = 0;
28144
- for (const binding of this.bindings.values()) if (binding.entries.some((e) => e.capName === capName)) n++;
28145
- return n;
28146
- }
28147
- /** Count devices whose slice for `capName` matches `predicate`. */
28148
- countByState(capName, predicate) {
28149
- let n = 0;
28150
- for (const [deviceId, perCap] of this.stateMirror) {
28151
- const slice = perCap.get(capName);
28152
- if (!slice) continue;
28153
- if (predicate(slice, deviceId)) n++;
28353
+ if (isIdentStart(ch)) {
28354
+ const start = i;
28355
+ while (i < n && isIdentPart(source[i])) i += 1;
28356
+ const text = source.slice(start, i);
28357
+ if (KEYWORDS.has(text)) tokens.push({
28358
+ type: "keyword",
28359
+ keyword: keywordOf(text),
28360
+ pos: start
28361
+ });
28362
+ else tokens.push({
28363
+ type: "identifier",
28364
+ name: text,
28365
+ pos: start
28366
+ });
28367
+ continue;
28154
28368
  }
28155
- return n;
28156
- }
28157
- /**
28158
- * Global listener — fires for every `device.state-changed` event the
28159
- * mirror absorbs.
28160
- */
28161
- listen(cb) {
28162
- this.globalStateListeners.add(cb);
28163
- return () => {
28164
- this.globalStateListeners.delete(cb);
28165
- };
28166
- }
28167
- /**
28168
- * Per-cap listener — fires only for state changes on `capName`,
28169
- * across every device. The callback receives the deviceId so the
28170
- * caller can route.
28171
- */
28172
- listenCap(capName, cb) {
28173
- let set = this.capListeners.get(capName);
28174
- if (!set) {
28175
- set = /* @__PURE__ */ new Set();
28176
- this.capListeners.set(capName, set);
28369
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
28370
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
28371
+ tokens.push({
28372
+ type: "punct",
28373
+ punct: two,
28374
+ pos: i
28375
+ });
28376
+ i += 2;
28377
+ continue;
28177
28378
  }
28178
- set.add(cb);
28179
- return () => {
28180
- set.delete(cb);
28181
- if (set.size === 0) this.capListeners.delete(capName);
28182
- };
28183
- }
28184
- /**
28185
- * Per-device listener — fires for every cap change on `deviceId`.
28186
- */
28187
- listenDevice(deviceId, cb) {
28188
- let set = this.deviceListeners.get(deviceId);
28189
- if (!set) {
28190
- set = /* @__PURE__ */ new Set();
28191
- this.deviceListeners.set(deviceId, set);
28379
+ if (isSinglePunct(ch)) {
28380
+ tokens.push({
28381
+ type: "punct",
28382
+ punct: ch,
28383
+ pos: i
28384
+ });
28385
+ i += 1;
28386
+ continue;
28192
28387
  }
28193
- set.add(cb);
28194
- return () => {
28195
- set.delete(cb);
28196
- if (set.size === 0) this.deviceListeners.delete(deviceId);
28197
- };
28388
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
28198
28389
  }
28199
- /** Fires when `device.registered` lands. Receives the new metadata. */
28200
- onDeviceAdded(cb) {
28201
- this.addedListeners.add(cb);
28202
- return () => {
28203
- this.addedListeners.delete(cb);
28204
- };
28390
+ tokens.push({
28391
+ type: "eof",
28392
+ pos: n
28393
+ });
28394
+ return tokens;
28395
+ }
28396
+ function keywordOf(text) {
28397
+ if (text === "true") return "true";
28398
+ if (text === "false") return "false";
28399
+ return "null";
28400
+ }
28401
+ function isSinglePunct(ch) {
28402
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
28403
+ }
28404
+ //#endregion
28405
+ //#region src/expression/parser.ts
28406
+ /**
28407
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
28408
+ *
28409
+ * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
28410
+ * → relational → additive → multiplicative → unary `! -` → call / primary.
28411
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
28412
+ * string validated against the builtin table at parse time, so an unknown
28413
+ * function is rejected immediately (author feedback) and a persisted expression
28414
+ * that references a since-removed builtin degrades at read.
28415
+ *
28416
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
28417
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
28418
+ */
28419
+ /** Binary/logical operator precedence (higher binds tighter). */
28420
+ var BINARY_PRECEDENCE = {
28421
+ "||": 1,
28422
+ "&&": 2,
28423
+ "==": 3,
28424
+ "!=": 3,
28425
+ "<": 4,
28426
+ "<=": 4,
28427
+ ">": 4,
28428
+ ">=": 4,
28429
+ "+": 5,
28430
+ "-": 5,
28431
+ "*": 6,
28432
+ "/": 6,
28433
+ "%": 6
28434
+ };
28435
+ function isLogicalOp(op) {
28436
+ return op === "&&" || op === "||";
28437
+ }
28438
+ function isBinaryOp(op) {
28439
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
28440
+ }
28441
+ var Parser = class {
28442
+ tokens;
28443
+ pos = 0;
28444
+ nodeCount = 0;
28445
+ identifiers = /* @__PURE__ */ new Set();
28446
+ callees = /* @__PURE__ */ new Set();
28447
+ constructor(tokens) {
28448
+ this.tokens = tokens;
28205
28449
  }
28206
- /** Fires when `device.unregistered` lands. `info` is the LAST-known
28207
- * metadata (or null if the device was never seen). */
28208
- onDeviceRemoved(cb) {
28209
- this.removedListeners.add(cb);
28210
- return () => {
28211
- this.removedListeners.delete(cb);
28450
+ parse() {
28451
+ const ast = this.parseTernary();
28452
+ const tok = this.peek();
28453
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
28454
+ return {
28455
+ ast,
28456
+ identifiers: this.identifiers,
28457
+ callees: this.callees,
28458
+ nodeCount: this.nodeCount
28212
28459
  };
28213
28460
  }
28214
- /**
28215
- * Resolve when `predicate` over the runtime-state slice for
28216
- * `(deviceId, capName)` becomes true. Resolves immediately if the
28217
- * current slice already matches. Rejects with `Error('timeout')`
28218
- * after `timeoutMs` (default 30s; pass `Infinity` to wait forever).
28219
- *
28220
- * Returns the matching slice — caller can read it directly without
28221
- * a second mirror lookup.
28222
- */
28223
- waitForState(deviceId, capName, predicate, timeoutMs = 3e4) {
28224
- return new Promise((resolve, reject) => {
28225
- const check = () => {
28226
- const slice = this.stateMirror.get(deviceId)?.get(capName);
28227
- if (slice && predicate(slice)) return slice;
28228
- return null;
28229
- };
28230
- const initial = check();
28231
- if (initial) {
28232
- resolve(initial);
28233
- return;
28234
- }
28235
- let timer = null;
28236
- const off = this.listenDevice(deviceId, (_id, cap, slice) => {
28237
- if (cap !== capName) return;
28238
- if (!slice) return;
28239
- if (predicate(slice)) {
28240
- if (timer) clearTimeout(timer);
28241
- off();
28242
- resolve(slice);
28243
- }
28244
- });
28245
- if (Number.isFinite(timeoutMs)) timer = setTimeout(() => {
28246
- off();
28247
- reject(/* @__PURE__ */ new Error(`waitForState timed out after ${timeoutMs}ms (deviceId=${deviceId}, capName=${capName})`));
28248
- }, timeoutMs);
28249
- });
28461
+ peek() {
28462
+ return this.tokens[this.pos];
28250
28463
  }
28251
- /**
28252
- * Resolve when a device with `deviceId` becomes available (a
28253
- * binding exists). Resolves immediately if already known. Rejects
28254
- * with timeout.
28255
- */
28256
- waitForDevice(deviceId, timeoutMs = 3e4) {
28257
- return new Promise((resolve, reject) => {
28258
- const existing = this.getDeviceById(deviceId);
28259
- if (existing) {
28260
- resolve(existing);
28261
- return;
28262
- }
28263
- let timer = null;
28264
- const off = this.onDeviceAdded((id) => {
28265
- if (id !== deviceId) return;
28266
- const proxy = this.getDeviceById(id);
28267
- if (!proxy) return;
28268
- if (timer) clearTimeout(timer);
28269
- off();
28270
- resolve(proxy);
28271
- });
28272
- if (Number.isFinite(timeoutMs)) timer = setTimeout(() => {
28273
- off();
28274
- reject(/* @__PURE__ */ new Error(`waitForDevice timed out after ${timeoutMs}ms (deviceId=${deviceId})`));
28275
- }, timeoutMs);
28276
- });
28464
+ next() {
28465
+ return this.tokens[this.pos++];
28277
28466
  }
28278
- /**
28279
- * Iterate every device that binds `capName`. Awaits each callback
28280
- * sequentially for parallel use `invokeCap` with explicit
28281
- * parallelism.
28282
- */
28283
- async forEachCap(capName, cb) {
28284
- for (const proxy of this.filterByCap(capName)) await cb(proxy);
28467
+ /** Consume a punctuator token, erroring if the next token isn't it. */
28468
+ expectPunct(punct) {
28469
+ const tok = this.peek();
28470
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
28471
+ this.pos += 1;
28285
28472
  }
28286
- /**
28287
- * Invoke a cap method on every device that binds the cap. Returns
28288
- * one result per device, success or failure isolated. Optional
28289
- * parallelism cap — useful for "snapshot all cameras but only 4
28290
- * at a time so battery cams don't all wake at once".
28291
- *
28292
- * Example:
28293
- *
28294
- * const results = await sm.invokeCap('snapshot', 'getSnapshot', {}, { parallelism: 4 })
28295
- * const failed = results.filter(r => !r.ok)
28296
- */
28297
- async invokeCap(capName, methodName, args, opts = {}) {
28298
- const targets = this.filterByCap(capName);
28299
- const parallelism = Math.max(1, opts.parallelism ?? targets.length);
28300
- const out = [];
28301
- for (let i = 0; i < targets.length; i += parallelism) {
28302
- const chunk = targets.slice(i, i + parallelism);
28303
- const settled = await Promise.allSettled(chunk.map(async (proxy) => {
28304
- const cap = proxy[capName];
28305
- if (!cap || typeof cap[methodName] !== "function") throw new Error(`device '${proxy.deviceId}' does not expose '${capName}.${methodName}'`);
28306
- return await cap[methodName](args);
28307
- }));
28308
- for (let j = 0; j < settled.length; j++) {
28309
- const proxy = chunk[j];
28310
- const r = settled[j];
28311
- if (r.status === "fulfilled") out.push({
28312
- deviceId: proxy.deviceId,
28313
- ok: true,
28314
- result: r.value
28315
- });
28316
- else out.push({
28317
- deviceId: proxy.deviceId,
28318
- ok: false,
28319
- error: r.reason
28320
- });
28321
- }
28473
+ matchPunct(punct) {
28474
+ const tok = this.peek();
28475
+ if (tok.type === "punct" && tok.punct === punct) {
28476
+ this.pos += 1;
28477
+ return true;
28322
28478
  }
28323
- return out;
28479
+ return false;
28324
28480
  }
28325
- /**
28326
- * One-shot summary — fleet size, breakdown by cap / addon / type.
28327
- * Designed for REPL inspection (`sm.summary()`).
28328
- */
28329
- summary() {
28330
- const byCap = {};
28331
- const byAddon = {};
28332
- const byType = {};
28333
- let online = 0;
28334
- let offline = 0;
28335
- for (const binding of this.bindings.values()) for (const entry of binding.entries) byCap[entry.capName] = (byCap[entry.capName] ?? 0) + 1;
28336
- for (const info of this.devices.values()) {
28337
- byAddon[info.addonId] = (byAddon[info.addonId] ?? 0) + 1;
28338
- byType[info.type] = (byType[info.type] ?? 0) + 1;
28339
- if (info.online) online++;
28340
- else offline++;
28341
- }
28342
- return {
28343
- totalDevices: this.bindings.size,
28344
- online,
28345
- offline,
28346
- byCap,
28347
- byAddon,
28348
- byType,
28349
- statedDevices: this.stateMirror.size
28350
- };
28481
+ countNode() {
28482
+ this.nodeCount += 1;
28483
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
28351
28484
  }
28352
- /**
28353
- * Debug-friendly dump — full state + binding + metadata for one
28354
- * device or all devices. Cheap deep clone so caller mutations don't
28355
- * leak into the mirror.
28356
- */
28357
- dump(deviceId) {
28358
- const dumpOne = (id) => {
28359
- const info = this.devices.get(id) ?? null;
28360
- const binding = this.bindings.get(id) ?? null;
28361
- const state = {};
28362
- const perCap = this.stateMirror.get(id);
28363
- if (perCap) for (const [cap, slice] of perCap) state[cap] = { ...slice };
28485
+ parseTernary() {
28486
+ const test = this.parseBinary(1);
28487
+ if (this.matchPunct("?")) {
28488
+ const consequent = this.parseTernary();
28489
+ this.expectPunct(":");
28490
+ const alternate = this.parseTernary();
28491
+ this.countNode();
28364
28492
  return {
28365
- deviceId: id,
28366
- info,
28367
- binding: binding ? {
28368
- ...binding,
28369
- entries: binding.entries.map((e) => ({ ...e }))
28370
- } : null,
28371
- state
28493
+ kind: "conditional",
28494
+ test,
28495
+ consequent,
28496
+ alternate
28372
28497
  };
28373
- };
28374
- if (deviceId !== void 0) return dumpOne(deviceId);
28375
- const out = [];
28376
- for (const id of this.bindings.keys()) out.push(dumpOne(id));
28377
- return out;
28378
- }
28379
- /**
28380
- * Direct read-only access to the underlying state mirror. Use
28381
- * sparingly — `getSystemState` returns a deep copy that's safer for
28382
- * exploratory work; this avoids the clone cost when iterating
28383
- * thousands of slices.
28384
- */
28385
- getRawMirror() {
28386
- return this.stateMirror;
28498
+ }
28499
+ return test;
28387
28500
  }
28388
- /**
28389
- * Snapshot of the full state mirror — same shape as the warm-boot
28390
- * payload. Deep-cloned; safe to mutate.
28391
- */
28392
- getSystemState() {
28393
- const copy = /* @__PURE__ */ new Map();
28394
- for (const [id, perCap] of this.stateMirror) {
28395
- const dup = /* @__PURE__ */ new Map();
28396
- for (const [k, v] of perCap) dup.set(k, { ...v });
28397
- copy.set(id, dup);
28501
+ parseBinary(minPrec) {
28502
+ let left = this.parseUnary();
28503
+ for (;;) {
28504
+ const tok = this.peek();
28505
+ if (tok.type !== "punct") break;
28506
+ const prec = BINARY_PRECEDENCE[tok.punct];
28507
+ if (prec === void 0 || prec < minPrec) break;
28508
+ const op = tok.punct;
28509
+ this.pos += 1;
28510
+ const right = this.parseBinary(prec + 1);
28511
+ this.countNode();
28512
+ if (isLogicalOp(op)) left = {
28513
+ kind: "logical",
28514
+ op,
28515
+ left,
28516
+ right
28517
+ };
28518
+ else if (isBinaryOp(op)) left = {
28519
+ kind: "binary",
28520
+ op,
28521
+ left,
28522
+ right
28523
+ };
28524
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
28525
+ }
28526
+ return left;
28527
+ }
28528
+ parseUnary() {
28529
+ const tok = this.peek();
28530
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
28531
+ const op = tok.punct;
28532
+ this.pos += 1;
28533
+ const operand = this.parseUnary();
28534
+ this.countNode();
28535
+ return {
28536
+ kind: "unary",
28537
+ op,
28538
+ operand
28539
+ };
28398
28540
  }
28399
- return copy;
28541
+ return this.parsePrimary();
28400
28542
  }
28401
- subscribeBus() {
28402
- if (!this.api.live?.onEvent) return;
28403
- const sub = this.api.live.onEvent;
28404
- this.bridges.push(sub.subscribe({ category: STATE_CHANGED_CATEGORY }, { onData: (evt) => {
28405
- const data = evt.data;
28406
- if (!data || typeof data.deviceId !== "number" || typeof data.capName !== "string") return;
28407
- this.applyStateUpdate(data.deviceId, data.capName, data.slice);
28408
- } }));
28409
- this.bridges.push(sub.subscribe({ category: BINDING_CHANGED_CATEGORY }, { onData: (evt) => {
28410
- const data = evt.data;
28411
- const deviceId = typeof data?.deviceId === "number" ? data.deviceId : data?.source?.type === "device" && typeof data.source.id === "number" ? data.source.id : null;
28412
- if (deviceId === null) return;
28413
- this.refreshBinding(deviceId);
28414
- } }));
28415
- this.bridges.push(sub.subscribe({ category: DEVICE_REGISTERED_CATEGORY }, { onData: (evt) => {
28416
- const data = evt.data;
28417
- if (typeof data?.deviceId !== "number") return;
28418
- this.refreshDeviceMetadata(data.deviceId, "added");
28419
- } }));
28420
- this.bridges.push(sub.subscribe({ category: DEVICE_UNREGISTERED_CATEGORY }, { onData: (evt) => {
28421
- const data = evt.data;
28422
- if (typeof data?.deviceId !== "number") return;
28423
- this.applyDeviceRemoval(data.deviceId);
28424
- } }));
28425
- this.bridges.push(sub.subscribe({ category: DEVICE_UPDATED_CATEGORY }, { onData: (evt) => {
28426
- const data = evt.data;
28427
- if (typeof data?.deviceId !== "number") return;
28428
- this.refreshDeviceMetadata(data.deviceId, "updated");
28429
- } }));
28543
+ parsePrimary() {
28544
+ const tok = this.next();
28545
+ switch (tok.type) {
28546
+ case "number":
28547
+ this.countNode();
28548
+ return {
28549
+ kind: "literal",
28550
+ value: tok.value
28551
+ };
28552
+ case "string":
28553
+ this.countNode();
28554
+ return {
28555
+ kind: "literal",
28556
+ value: tok.value
28557
+ };
28558
+ case "keyword":
28559
+ this.countNode();
28560
+ return {
28561
+ kind: "literal",
28562
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
28563
+ };
28564
+ case "identifier": {
28565
+ const nextTok = this.peek();
28566
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
28567
+ this.identifiers.add(tok.name);
28568
+ this.countNode();
28569
+ return {
28570
+ kind: "identifier",
28571
+ name: tok.name
28572
+ };
28573
+ }
28574
+ case "punct":
28575
+ if (tok.punct === "(") {
28576
+ const inner = this.parseTernary();
28577
+ this.expectPunct(")");
28578
+ return inner;
28579
+ }
28580
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
28581
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
28582
+ }
28430
28583
  }
28431
- applyStateUpdate(deviceId, capName, slice) {
28432
- let perCap = this.stateMirror.get(deviceId);
28433
- if (!perCap) {
28434
- perCap = /* @__PURE__ */ new Map();
28435
- this.stateMirror.set(deviceId, perCap);
28584
+ parseCall(callee, pos) {
28585
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
28586
+ this.expectPunct("(");
28587
+ const args = [];
28588
+ if (!this.matchPunct(")")) for (;;) {
28589
+ args.push(this.parseTernary());
28590
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
28591
+ if (this.matchPunct(",")) continue;
28592
+ this.expectPunct(")");
28593
+ break;
28436
28594
  }
28437
- if (slice === void 0) perCap.delete(capName);
28438
- else perCap.set(capName, slice);
28439
- const handleKey = `${deviceId}:${capName}`;
28440
- const handleSet = this.handleListeners.get(handleKey);
28441
- if (handleSet) for (const cb of handleSet) try {
28442
- cb(slice);
28443
- } catch {}
28444
- for (const cb of this.globalStateListeners) try {
28445
- cb(deviceId, capName, slice);
28446
- } catch {}
28447
- const capSet = this.capListeners.get(capName);
28448
- if (capSet) for (const cb of capSet) try {
28449
- cb(deviceId, slice);
28450
- } catch {}
28451
- const devSet = this.deviceListeners.get(deviceId);
28452
- if (devSet) for (const cb of devSet) try {
28453
- cb(deviceId, capName, slice);
28454
- } catch {}
28595
+ this.callees.add(callee);
28596
+ this.countNode();
28597
+ return {
28598
+ kind: "call",
28599
+ callee,
28600
+ args
28601
+ };
28455
28602
  }
28456
- applyDeviceRemoval(deviceId) {
28457
- const lastInfo = this.devices.get(deviceId) ?? null;
28458
- this.bindings.delete(deviceId);
28459
- this.devices.delete(deviceId);
28460
- this.stateMirror.delete(deviceId);
28461
- for (const cb of this.removedListeners) try {
28462
- cb(deviceId, lastInfo);
28463
- } catch {}
28603
+ };
28604
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
28605
+ * `ExpressionParseError` on any lexical or grammatical failure. */
28606
+ function parseExpression(source) {
28607
+ return new Parser(tokenize(source)).parse();
28608
+ }
28609
+ //#endregion
28610
+ //#region src/expression/compile.ts
28611
+ /**
28612
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
28613
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
28614
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
28615
+ * one per read on a hot resolve path.
28616
+ *
28617
+ * The cache is a module-level singleton: entries are pure, content-addressed
28618
+ * ASTs keyed by the raw source string, so sharing one instance across all
28619
+ * callers is safe and maximises hit rate.
28620
+ */
28621
+ var cache = /* @__PURE__ */ new Map();
28622
+ function getCached(source) {
28623
+ const hit = cache.get(source);
28624
+ if (hit !== void 0) {
28625
+ cache.delete(source);
28626
+ cache.set(source, hit);
28627
+ return hit;
28464
28628
  }
28465
- async refreshBinding(deviceId) {
28466
- try {
28467
- const fresh = (await this.api.deviceManager.getAllBindings.query({})).find((b) => b.deviceId === deviceId);
28468
- if (fresh) this.bindings.set(deviceId, fresh);
28469
- else this.applyDeviceRemoval(deviceId);
28470
- } catch {}
28629
+ let result;
28630
+ try {
28631
+ result = {
28632
+ ok: true,
28633
+ parsed: parseExpression(source)
28634
+ };
28635
+ } catch (err) {
28636
+ result = {
28637
+ ok: false,
28638
+ error: err instanceof ExpressionParseError ? err.message : String(err)
28639
+ };
28471
28640
  }
28472
- async refreshDeviceMetadata(deviceId, kind) {
28473
- try {
28474
- const info = await this.api.deviceManager.getDevice.query({ deviceId });
28475
- if (!info) return;
28476
- const wasNew = !this.devices.has(deviceId);
28477
- this.devices.set(deviceId, info);
28478
- if (kind === "added" && wasNew) {
28479
- await this.refreshBinding(deviceId);
28480
- for (const cb of this.addedListeners) try {
28481
- cb(deviceId, info);
28482
- } catch {}
28483
- }
28484
- } catch {}
28641
+ cache.set(source, result);
28642
+ if (cache.size > 256) {
28643
+ const oldest = cache.keys().next().value;
28644
+ if (oldest !== void 0) cache.delete(oldest);
28485
28645
  }
28486
- };
28487
- function inSet(value, set) {
28488
- if (Array.isArray(set)) return set.includes(value);
28489
- return value === set;
28646
+ return result;
28490
28647
  }
28491
- function toArray(value) {
28492
- return Array.isArray(value) ? value : [value];
28648
+ /** Compile `source` to a `ParsedExpression`, throwing `ExpressionParseError`
28649
+ * on failure. LRU/negative-cached. */
28650
+ function compileExpression(source) {
28651
+ const result = getCached(source);
28652
+ if (result.ok) return result.parsed;
28653
+ throw new ExpressionParseError(result.error);
28493
28654
  }
28494
- function matchesString(haystack, match) {
28495
- if (typeof match === "string") return haystack === match;
28496
- if (match instanceof RegExp) return match.test(haystack);
28497
- if ("exact" in match) return haystack === match.exact;
28498
- if ("contains" in match) return haystack.toLowerCase().includes(match.contains.toLowerCase());
28499
- return false;
28655
+ /** Compile `source`, returning a discriminated result instead of throwing.
28656
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
28657
+ function compileExpressionSafe(source) {
28658
+ return getCached(source);
28500
28659
  }
28501
28660
  //#endregion
28502
- //#region src/device/zod-to-config-ui.ts
28503
- /** Access Zod v4 internal .def — not in public typings but stable at runtime */
28504
- function zodDef(schema) {
28505
- return schema.def;
28506
- }
28507
- /** Access internal properties on a Zod schema instance */
28508
- function zodInternals(schema) {
28509
- return schema;
28510
- }
28661
+ //#region src/expression/evaluator.ts
28511
28662
  /**
28512
- * Convert DeviceConfig.entries() output to ConfigUISchema for the admin UI FormBuilder.
28513
- *
28514
- * Each entry's Zod type is inspected to determine the correct ConfigField type:
28515
- * - ZodString → 'text' (or 'password' when key contains "password"/"secret"/"token"/"apikey")
28516
- * - ZodNumber → 'number' (extracts min/max/step from Zod v4 checks)
28517
- * - ZodBoolean → 'boolean'
28518
- * - ZodEnum → 'select' (options built from enum values)
28519
- * - Anything else → 'text' fallback
28663
+ * Tree-walking evaluator for the safe expression mini-language.
28520
28664
  *
28521
- * Wrapper types ZodDefault, ZodOptional, and ZodNullable are unwrapped transparently.
28522
- * Default values are extracted from ZodDefault wrappers.
28665
+ * SECURITY (spec §4 rule 2/5):
28666
+ * - The scope is an `Object.create(null)` copy of ONLY the caller's own
28667
+ * enumerable binding entries, so `name in scope` is a pure own-key check and
28668
+ * `constructor` / `__proto__` / `toString` are plain unknown identifiers.
28669
+ * - Performs ZERO I/O and never touches `globalThis` / `Date` / `Math`
28670
+ * directly — the only external calls are into the frozen builtin table.
28671
+ * - The grammar has no loops/recursion/lambdas, so a walk is O(nodeCount) by
28672
+ * construction; the step counter is defense-in-depth for a crafted max-size
28673
+ * AST. Nothing blocks: there are no timers, awaits or unbounded loops.
28523
28674
  */
28524
- function zodEntriesToConfigUI(entries, sectionTitle = "Configuration", sectionId = "main") {
28525
- return { sections: [{
28526
- id: sectionId,
28527
- title: sectionTitle,
28528
- fields: entries.map((entry) => zodToConfigField(entry.key, entry.schema, entry.description))
28529
- }] };
28675
+ var EMPTY_HOOKS = Object.freeze({});
28676
+ /** Build a null-prototype scope from own-enumerable binding entries. Inherited
28677
+ * keys of the input (e.g. from a `{__proto__: {...}}` payload) are NOT copied,
28678
+ * so nothing smuggles in via the prototype chain. */
28679
+ function createExpressionScope(bindings) {
28680
+ const scope = Object.create(null);
28681
+ for (const key of Object.keys(bindings)) if (Object.prototype.hasOwnProperty.call(bindings, key)) scope[key] = bindings[key];
28682
+ return scope;
28530
28683
  }
28531
- function zodToConfigField(key, schema, description) {
28532
- const inner = unwrapZod(schema);
28533
- const defaultValue = getZodDefault(schema);
28534
- const base = {
28535
- key,
28536
- label: description ?? humanizeKey(key),
28537
- description,
28538
- default: defaultValue
28539
- };
28540
- if (inner instanceof zod.z.ZodString) return buildStringField(key, base);
28541
- if (inner instanceof zod.z.ZodNumber) return buildNumberField(inner, base);
28542
- if (inner instanceof zod.z.ZodBoolean) return {
28543
- ...base,
28544
- type: "boolean"
28545
- };
28546
- if (inner instanceof zod.z.ZodEnum) return buildEnumField(inner, base);
28547
- if (inner instanceof zod.z.ZodArray || inner instanceof zod.z.ZodObject) return {
28548
- ...base,
28549
- type: "textarea",
28550
- rows: 6,
28551
- isJson: true
28552
- };
28553
- return {
28554
- ...base,
28555
- type: "text"
28556
- };
28684
+ function isFiniteNumber(value) {
28685
+ return typeof value === "number" && Number.isFinite(value);
28557
28686
  }
28558
- function buildStringField(key, base) {
28559
- const lowerKey = key.toLowerCase();
28560
- if (lowerKey.includes("password") || lowerKey.includes("secret") || lowerKey.includes("token") || lowerKey.includes("apikey") || lowerKey.includes("api_key")) return {
28561
- ...base,
28562
- type: "password",
28563
- showToggle: true
28564
- };
28565
- return {
28566
- ...base,
28567
- type: "text"
28568
- };
28687
+ /** JS truthiness of a primitive value. */
28688
+ function truthy(value) {
28689
+ return Boolean(value);
28569
28690
  }
28570
- function buildNumberField(inner, base) {
28571
- const anyInner = zodInternals(inner);
28572
- const rawMin = anyInner.minValue;
28573
- const rawMax = anyInner.maxValue;
28574
- const min = rawMin != null && isFinite(rawMin) ? rawMin : void 0;
28575
- const max = rawMax != null && isFinite(rawMax) ? rawMax : void 0;
28576
- const step = getMultipleOfStep(inner);
28577
- return {
28578
- ...base,
28579
- type: "number",
28580
- ...min !== void 0 ? { min } : {},
28581
- ...max !== void 0 ? { max } : {},
28582
- ...step !== void 0 ? { step } : {}
28583
- };
28691
+ function requireFinite(value, context) {
28692
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${context} produced a non-finite result`);
28693
+ return value;
28584
28694
  }
28585
- function getMultipleOfStep(inner) {
28586
- const checks = zodDef(inner).checks ?? [];
28587
- for (const check of checks) if (check._zod?.def?.check === "multiple_of" && check._zod.def.value !== void 0) return check._zod.def.value;
28695
+ function step(ctx) {
28696
+ ctx.steps += 1;
28697
+ if (ctx.steps > ctx.maxSteps) throw new ExpressionEvalError("expression evaluation step budget exceeded");
28588
28698
  }
28589
- function buildEnumField(inner, base) {
28590
- const values = inner.options.map((v) => String(v));
28591
- return {
28592
- ...base,
28593
- type: "select",
28594
- options: values.map((v) => ({
28595
- label: humanizeKey(v),
28596
- value: v
28597
- }))
28598
- };
28699
+ function evalNode(node, ctx) {
28700
+ step(ctx);
28701
+ switch (node.kind) {
28702
+ case "literal": return node.value;
28703
+ case "identifier":
28704
+ if (!(node.name in ctx.scope)) throw new ExpressionEvalError(`unknown identifier: ${node.name}`);
28705
+ return ctx.scope[node.name];
28706
+ case "unary": return evalUnary(node.op, evalNode(node.operand, ctx));
28707
+ case "binary": return evalBinary(node.op, evalNode(node.left, ctx), evalNode(node.right, ctx));
28708
+ case "logical": {
28709
+ const left = evalNode(node.left, ctx);
28710
+ if (node.op === "&&") return truthy(left) ? evalNode(node.right, ctx) : left;
28711
+ return truthy(left) ? left : evalNode(node.right, ctx);
28712
+ }
28713
+ case "conditional": return truthy(evalNode(node.test, ctx)) ? evalNode(node.consequent, ctx) : evalNode(node.alternate, ctx);
28714
+ case "call": return evalCall(node.callee, node.args.map((a) => evalNode(a, ctx)), ctx.hooks);
28715
+ }
28599
28716
  }
28600
- function unwrapZod(schema) {
28601
- if (schema instanceof zod.z.ZodDefault) return unwrapZod(zodDef(schema).innerType);
28602
- if (schema instanceof zod.z.ZodOptional) return unwrapZod(zodDef(schema).innerType);
28603
- if (schema instanceof zod.z.ZodNullable) return unwrapZod(zodDef(schema).innerType);
28604
- return schema;
28717
+ function evalUnary(op, operand) {
28718
+ if (op === "!") return !truthy(operand);
28719
+ if (!isFiniteNumber(operand)) throw new ExpressionEvalError("unary \"-\" requires a finite number");
28720
+ return requireFinite(-operand, "unary \"-\"");
28605
28721
  }
28606
- function getZodDefault(schema) {
28607
- if (schema instanceof zod.z.ZodDefault) return zodDef(schema).defaultValue;
28722
+ function evalBinary(op, left, right) {
28723
+ switch (op) {
28724
+ case "==": return left === right;
28725
+ case "!=": return left !== right;
28726
+ case "+":
28727
+ if (typeof left === "string" && typeof right === "string") return left + right;
28728
+ if (isFiniteNumber(left) && isFiniteNumber(right)) return requireFinite(left + right, "\"+\"");
28729
+ throw new ExpressionEvalError("\"+\" requires two numbers or two strings");
28730
+ case "-":
28731
+ case "*":
28732
+ case "/":
28733
+ case "%":
28734
+ if (!isFiniteNumber(left) || !isFiniteNumber(right)) throw new ExpressionEvalError(`"${op}" requires two finite numbers`);
28735
+ return requireFinite(op === "-" ? left - right : op === "*" ? left * right : op === "/" ? left / right : left % right, `"${op}"`);
28736
+ case "<":
28737
+ case "<=":
28738
+ case ">":
28739
+ case ">=":
28740
+ if (isFiniteNumber(left) && isFiniteNumber(right)) return op === "<" ? left < right : op === "<=" ? left <= right : op === ">" ? left > right : left >= right;
28741
+ if (typeof left === "string" && typeof right === "string") return op === "<" ? left < right : op === "<=" ? left <= right : op === ">" ? left > right : left >= right;
28742
+ throw new ExpressionEvalError(`"${op}" requires two numbers or two strings`);
28743
+ }
28608
28744
  }
28609
- function humanizeKey(key) {
28610
- return key.replace(/([A-Z])/g, " $1").replace(/[_-]/g, " ").replace(/^\w/, (c) => c.toUpperCase()).trim();
28745
+ function evalCall(callee, args, hooks) {
28746
+ if (!Object.prototype.hasOwnProperty.call(EXPRESSION_BUILTINS, callee)) throw new ExpressionEvalError(`unknown function: ${callee}`);
28747
+ const builtin = EXPRESSION_BUILTINS[callee];
28748
+ if (args.length < builtin.minArgs || args.length > builtin.maxArgs) throw new ExpressionEvalError(`${callee}: wrong number of arguments (${args.length})`);
28749
+ return builtin.apply(args, hooks);
28750
+ }
28751
+ /** Evaluate an AST node against a scope. Throws `ExpressionEvalError` on any
28752
+ * runtime failure (unknown identifier, type mismatch, non-finite result,
28753
+ * step-budget exhaustion). */
28754
+ function evaluateAst(node, scope, opts) {
28755
+ return evalNode(node, {
28756
+ scope,
28757
+ hooks: opts?.hooks ?? EMPTY_HOOKS,
28758
+ maxSteps: opts?.maxSteps ?? 4096,
28759
+ steps: 0
28760
+ });
28761
+ }
28762
+ //#endregion
28763
+ //#region src/expression/expression-source.ts
28764
+ /** The `now` epoch-ms binding is auto-injected into every evaluation and is a
28765
+ * reserved binding name (authors may not rebind it). */
28766
+ var EXPRESSION_INJECTED_NOW = "now";
28767
+ /**
28768
+ * Coerce an untrusted `getByPath` / mirror read to an `ExpressionValue`.
28769
+ * Non-primitive values (objects, arrays, `undefined`, functions, bigint,
28770
+ * symbol) and non-finite numbers become `undefined` so the caller can apply
28771
+ * its binding-miss policy (→ `null`). `null` itself is a valid value.
28772
+ */
28773
+ function toExpressionValue(raw) {
28774
+ if (raw === null) return null;
28775
+ if (typeof raw === "string") return raw;
28776
+ if (typeof raw === "boolean") return raw;
28777
+ if (typeof raw === "number") return Number.isFinite(raw) ? raw : void 0;
28778
+ }
28779
+ /**
28780
+ * Author-time validation. Returns `null` when the source is valid, else a
28781
+ * human-readable error message. Checks: the expression compiles; binding count
28782
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
28783
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
28784
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
28785
+ */
28786
+ function validateExpressionSource(src) {
28787
+ const names = Object.keys(src.bindings);
28788
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
28789
+ for (const name of names) {
28790
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
28791
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
28792
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
28793
+ }
28794
+ const compiled = compileExpressionSafe(src.expr);
28795
+ if (!compiled.ok) return compiled.error;
28796
+ const bound = new Set(names);
28797
+ for (const id of compiled.parsed.identifiers) {
28798
+ if (id === "now") continue;
28799
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
28800
+ }
28801
+ return null;
28802
+ }
28803
+ /**
28804
+ * Shared read-path evaluation. Builds a null-proto scope from `bindingValues`
28805
+ * plus the injected `now` (supplied by the caller for determinism and
28806
+ * testability), compiles via the LRU, and evaluates. Any failure (parse or
28807
+ * eval) returns `{ ok: false }` — the caller treats that as "skip this
28808
+ * derivation", never as a throw that takes the pass down.
28809
+ */
28810
+ function evaluateExpressionSource(expr, bindingValues, now, opts) {
28811
+ const compiled = compileExpressionSafe(expr);
28812
+ if (!compiled.ok) return {
28813
+ ok: false,
28814
+ error: compiled.error
28815
+ };
28816
+ const scope = createExpressionScope({
28817
+ ...bindingValues,
28818
+ ["now"]: now
28819
+ });
28820
+ try {
28821
+ return {
28822
+ ok: true,
28823
+ value: evaluateAst(compiled.parsed.ast, scope, opts)
28824
+ };
28825
+ } catch (err) {
28826
+ return {
28827
+ ok: false,
28828
+ error: err instanceof ExpressionEvalError ? err.message : String(err)
28829
+ };
28830
+ }
28611
28831
  }
28612
28832
  //#endregion
28833
+ //#region src/expression/binding-source.ts
28834
+ /**
28835
+ * What an expression's named bindings READ from.
28836
+ *
28837
+ * Salvaged verbatim from the deleted device-link mechanism. Wiring's source
28838
+ * kinds were the one part of it worth keeping: addressing a device field by
28839
+ * re-sync-stable `stableId`, a per-device constant, and a sibling-accessory
28840
+ * read are the vocabulary any cross-device derivation needs, and they were
28841
+ * already correct. What wiring got wrong was the DESTINATION — a field on
28842
+ * somebody else's device, with no identity — not the source.
28843
+ *
28844
+ * These shapes are therefore kept, re-homed next to the engine that consumes
28845
+ * them, and are the binding type of a composition recipe (the source picker
28846
+ * stays `deviceManager.getWireableFields`). They deliberately do NOT nest: a
28847
+ * binding is a read, never another expression.
28848
+ *
28849
+ * Schemas are authoritative; every type is `z.infer` of one, so a wire shape and
28850
+ * a TypeScript shape cannot drift apart (`scripts/check-schema-type-twins.ts`).
28851
+ */
28852
+ /** Read a sibling accessory's status field, addressed by the sibling's key.
28853
+ * `kind` is optional for wire compatibility — absent means `'field'`. */
28854
+ var ExpressionFieldBindingSchema = zod.z.object({
28855
+ kind: zod.z.literal("field").optional(),
28856
+ sourceKey: zod.z.string(),
28857
+ cap: zod.z.string(),
28858
+ fieldPath: zod.z.string()
28859
+ });
28860
+ /** A constant. No device is read. */
28861
+ var ExpressionLiteralBindingSchema = zod.z.object({
28862
+ kind: zod.z.literal("literal"),
28863
+ value: zod.z.union([
28864
+ zod.z.string(),
28865
+ zod.z.number(),
28866
+ zod.z.boolean(),
28867
+ zod.z.null()
28868
+ ])
28869
+ });
28870
+ /** Read ANY device's status field, addressed by its re-sync-stable `stableId` —
28871
+ * never by numeric id, which a re-adoption reissues. */
28872
+ var ExpressionGlobalBindingSchema = zod.z.object({
28873
+ kind: zod.z.literal("global"),
28874
+ sourceStableId: zod.z.string(),
28875
+ cap: zod.z.string(),
28876
+ fieldPath: zod.z.string()
28877
+ });
28878
+ var ExpressionBindingSourceSchema = zod.z.union([
28879
+ ExpressionFieldBindingSchema,
28880
+ ExpressionLiteralBindingSchema,
28881
+ ExpressionGlobalBindingSchema
28882
+ ]);
28883
+ /**
28884
+ * An expression plus the bindings its free identifiers resolve against.
28885
+ *
28886
+ * The `superRefine` runs the SAME author-time validation as
28887
+ * `validateExpressionSource` — compiles the expression, checks binding names,
28888
+ * checks identifier coverage — so every boundary that parses one
28889
+ * validates-at-write rather than discovering the problem at read time.
28890
+ * Compiles are LRU-cached, so repeated validation of the same string is a hit.
28891
+ */
28892
+ var ExpressionSourceSchema = zod.z.object({
28893
+ expr: zod.z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
28894
+ bindings: zod.z.record(zod.z.string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
28895
+ }).superRefine((src, ctx) => {
28896
+ const err = validateExpressionSource(src);
28897
+ if (err !== null) ctx.addIssue({
28898
+ code: "custom",
28899
+ message: err,
28900
+ path: ["expr"]
28901
+ });
28902
+ });
28903
+ //#endregion
28613
28904
  //#region src/generated/cap-status-types.ts
28614
28905
  /**
28615
28906
  * Runtime list of cap names with status. Used by the settings
@@ -30411,6 +30702,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
30411
30702
  addonId: null,
30412
30703
  access: "view"
30413
30704
  },
30705
+ "coreBlocks.restart": {
30706
+ capName: "core-blocks",
30707
+ capScope: "system",
30708
+ addonId: null,
30709
+ access: "create"
30710
+ },
30414
30711
  "coreBlocks.setEnabled": {
30415
30712
  capName: "core-blocks",
30416
30713
  capScope: "system",
@@ -31047,12 +31344,6 @@ var METHOD_ACCESS_MAP = Object.freeze({
31047
31344
  addonId: null,
31048
31345
  access: "create"
31049
31346
  },
31050
- "deviceManager.setDeviceLinks": {
31051
- capName: "device-manager",
31052
- capScope: "system",
31053
- addonId: null,
31054
- access: "create"
31055
- },
31056
31347
  "deviceManager.setDisabled": {
31057
31348
  capName: "device-manager",
31058
31349
  capScope: "system",
@@ -35465,6 +35756,7 @@ function createSystemProxy(api) {
35465
35756
  update: (input) => dispatch("coreBlocks", "update", "mutation", input),
35466
35757
  delete: (input) => dispatch("coreBlocks", "delete", "mutation", input),
35467
35758
  setEnabled: (input) => dispatch("coreBlocks", "setEnabled", "mutation", input),
35759
+ restart: (input) => dispatch("coreBlocks", "restart", "mutation", input),
35468
35760
  compile: (input) => dispatch("coreBlocks", "compile", "mutation", input),
35469
35761
  getTypeDefs: (input) => dispatch("coreBlocks", "getTypeDefs", "query", input)
35470
35762
  },
@@ -36388,6 +36680,441 @@ var TimelapseRuleSchema = TimelapseRuleInputSchema.extend({
36388
36680
  updatedAt: zod.z.number()
36389
36681
  });
36390
36682
  //#endregion
36683
+ //#region src/pipeline/detail-crop.ts
36684
+ /**
36685
+ * THE detail-crop convention — the single derivation of the rectangle a
36686
+ * detail/enrichment step (clip-embedding, face-detection, plate-detection…)
36687
+ * is fed.
36688
+ *
36689
+ * ## Why this is one module and not two constants
36690
+ *
36691
+ * `object-clip` is ONE vector index, and cosine similarity is only meaningful
36692
+ * between vectors produced from the same crop convention. Two encode paths
36693
+ * write into it — the live detail plane and the embedding rebuild — and they
36694
+ * used to derive their crops independently: `DETAIL_CROP_PADDING_RATIO = 0.15`
36695
+ * with no squaring on one side, `DEFAULT_CROP_MARGIN = 0.2` with squaring on
36696
+ * by default on the other. Every rebuild therefore poured a second, silently
36697
+ * incomparable feature space into the index it exists to keep consistent.
36698
+ *
36699
+ * So the rectangle is derived HERE, once, from ONE convention value. Both
36700
+ * paths now reach this function through `pipelineRunner.runDetailSubtree` —
36701
+ * the runner is the only process that cuts (see `detail-subtree.ts`), and the
36702
+ * convention is a cluster-global `pipeline-orchestrator` setting. There is
36703
+ * deliberately no per-node or per-device scope: a per-accelerator crop margin
36704
+ * would reintroduce the same split, merely relocated.
36705
+ *
36706
+ * ## The default IS the live convention
36707
+ *
36708
+ * {@link DEFAULT_DETAIL_CROP_CONVENTION} reproduces what the live path has
36709
+ * been storing (0.15, no squaring). Anything else would invalidate every
36710
+ * vector already in the index on the day it shipped. Changing the convention
36711
+ * is legitimate — that is what the operator knob is for — but it must be
36712
+ * followed by a rebuild, which is now guaranteed to produce crops from this
36713
+ * same function.
36714
+ */
36715
+ /**
36716
+ * Store identity of the convention in `pipeline-orchestrator`'s GLOBAL
36717
+ * (cluster-wide) settings.
36718
+ *
36719
+ * These live here rather than in the orchestrator because the reader is a
36720
+ * different addon — the pipeline runner, over the hub-routed `addon-settings`
36721
+ * cap. Addons never import each other, so a key owned by the writer would have
36722
+ * to be hand-copied by the reader, and a hand-copied key is how a setting
36723
+ * silently stops arriving while both sides still look correct.
36724
+ */
36725
+ var DETAIL_CROP_SECTION_ID = "detail-crop";
36726
+ var DETAIL_CROP_PADDING_KEY = "detailCropPaddingRatio";
36727
+ var DETAIL_CROP_SQUARE_KEY = "detailCropSquare";
36728
+ /**
36729
+ * Operator-tunable crop convention. Single-valued and cluster-wide — see the
36730
+ * module docblock for why it cannot be scoped per node or per device.
36731
+ */
36732
+ var DetailCropConventionSchema = zod.z.object({
36733
+ /**
36734
+ * Fraction of the box's own size added on EACH side before cutting.
36735
+ *
36736
+ * CLIP is trained on natural images WITH surroundings; a pixel-tight crop
36737
+ * removes exactly the context it is strongest on (a dog cut to its outline
36738
+ * is a dark blob). The right value is an empirical question, which is why it
36739
+ * is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
36740
+ */
36741
+ paddingRatio: zod.z.number().min(0).max(4),
36742
+ /**
36743
+ * Square the window (in PIXELS) before cutting.
36744
+ *
36745
+ * CLIP's input is square, so a tall bbox resized straight to NxN is squashed
36746
+ * — a standing person becomes a shape the model never saw. Squaring costs
36747
+ * extra background, which is context the model wants anyway. Off by default
36748
+ * because the live path has never squared and the stored index reflects that.
36749
+ */
36750
+ square: zod.z.boolean()
36751
+ });
36752
+ /**
36753
+ * The convention in force when nobody has configured one — byte-for-byte the
36754
+ * behaviour of the pre-unification LIVE path (`DETAIL_CROP_PADDING_RATIO`).
36755
+ */
36756
+ var DEFAULT_DETAIL_CROP_CONVENTION = {
36757
+ paddingRatio: .15,
36758
+ square: false
36759
+ };
36760
+ /**
36761
+ * Narrow a FLAT settings record to the convention.
36762
+ *
36763
+ * Per-FIELD fallback, deliberately: a junk padding must not also discard a
36764
+ * valid squaring choice. An absent or invalid value resolves to
36765
+ * {@link DEFAULT_DETAIL_CROP_CONVENTION} — the historical live behaviour —
36766
+ * rather than to a clamped number nobody chose, so a bad read can never
36767
+ * quietly change what the stored vectors mean.
36768
+ */
36769
+ function readDetailCropConvention(config) {
36770
+ const paddingRatio = DetailCropConventionSchema.shape.paddingRatio.safeParse(config[DETAIL_CROP_PADDING_KEY]);
36771
+ const square = DetailCropConventionSchema.shape.square.safeParse(config[DETAIL_CROP_SQUARE_KEY]);
36772
+ return {
36773
+ paddingRatio: paddingRatio.success ? paddingRatio.data : DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio,
36774
+ square: square.success ? square.data : DEFAULT_DETAIL_CROP_CONVENTION.square
36775
+ };
36776
+ }
36777
+ function isHydratedField$1(entry) {
36778
+ return typeof entry === "object" && entry !== null && "key" in entry;
36779
+ }
36780
+ /**
36781
+ * Extract the convention from an `addon-settings.getGlobalSettings` payload.
36782
+ *
36783
+ * Walks EVERY section rather than looking inside {@link DETAIL_CROP_SECTION_ID}
36784
+ * alone: the keys are unique across the addon's schema, and a section rename
36785
+ * must not silently revert the whole cluster to the default. A `null` payload
36786
+ * (addon mid-boot) is the default convention.
36787
+ */
36788
+ function pickDetailCropConvention(view) {
36789
+ if (view === null) return DEFAULT_DETAIL_CROP_CONVENTION;
36790
+ const flat = {};
36791
+ for (const section of view.sections) for (const entry of section.fields) {
36792
+ if (!isHydratedField$1(entry) || typeof entry.key !== "string") continue;
36793
+ if (entry.key === "detailCropPaddingRatio" || entry.key === "detailCropSquare") flat[entry.key] = entry.value;
36794
+ }
36795
+ return readDetailCropConvention(flat);
36796
+ }
36797
+ /** Slider bounds for the operator-facing padding knob (orchestrator settings UI). */
36798
+ var DETAIL_CROP_PADDING_FIELD = {
36799
+ min: 0,
36800
+ max: 1,
36801
+ step: .05,
36802
+ default: DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio
36803
+ };
36804
+ /**
36805
+ * Derive the crop rectangle for one parent detection.
36806
+ *
36807
+ * Order: pad by `paddingRatio` of the box's own size → optionally square in
36808
+ * pixel space around the padded centre → keep it inside the frame. Pure:
36809
+ * always returns a new rect and never mutates `bbox`.
36810
+ *
36811
+ * Edge handling differs by mode, on purpose:
36812
+ *
36813
+ * - **unsquared** — TRUNCATED at the frame border, byte-for-byte what the live
36814
+ * path has always done (`padAndClampFrameBbox`). A subject against the edge
36815
+ * gets a slightly smaller window. Changing this would silently reinterpret
36816
+ * every edge-touching vector already in the index.
36817
+ * - **squared** — SLID inward instead, because a truncated square is not
36818
+ * square and squaring exists precisely to preserve the aspect the model
36819
+ * sees. It only shrinks when the square is larger than the frame itself.
36820
+ */
36821
+ function deriveDetailCropRect(bbox, frameWidth, frameHeight, convention) {
36822
+ const padX = convention.paddingRatio * bbox.w;
36823
+ const padY = convention.paddingRatio * bbox.h;
36824
+ const padded = {
36825
+ x: bbox.x - padX,
36826
+ y: bbox.y - padY,
36827
+ w: bbox.w + 2 * padX,
36828
+ h: bbox.h + 2 * padY
36829
+ };
36830
+ return convention.square ? slideInsideFrame(squareInPixels(padded, frameWidth, frameHeight), frameWidth, frameHeight) : truncateToFrame(padded, frameWidth, frameHeight);
36831
+ }
36832
+ /**
36833
+ * Grow the shorter side to the longer one around the window's centre, bounded
36834
+ * by the frame's shorter side — a square larger than the frame cannot exist,
36835
+ * and collapsing to the frame's short side is the most that does.
36836
+ */
36837
+ function squareInPixels(rect, frameWidth, frameHeight) {
36838
+ const side = Math.min(Math.max(rect.w, rect.h), Math.min(frameWidth, frameHeight));
36839
+ const cx = rect.x + rect.w / 2;
36840
+ const cy = rect.y + rect.h / 2;
36841
+ return {
36842
+ x: cx - side / 2,
36843
+ y: cy - side / 2,
36844
+ w: side,
36845
+ h: side
36846
+ };
36847
+ }
36848
+ /**
36849
+ * Cut the window at the frame border — the pre-unification live behaviour,
36850
+ * preserved exactly so unsquared crops keep matching the stored index.
36851
+ */
36852
+ function truncateToFrame(rect, frameWidth, frameHeight) {
36853
+ const x1 = Math.max(0, rect.x);
36854
+ const y1 = Math.max(0, rect.y);
36855
+ const x2 = Math.min(frameWidth, rect.x + rect.w);
36856
+ const y2 = Math.min(frameHeight, rect.y + rect.h);
36857
+ return {
36858
+ x: x1,
36859
+ y: y1,
36860
+ w: Math.max(0, x2 - x1),
36861
+ h: Math.max(0, y2 - y1)
36862
+ };
36863
+ }
36864
+ /**
36865
+ * Move the window inside the frame keeping its extent — used only for squared
36866
+ * windows, where truncating would destroy the squareness that is the point.
36867
+ */
36868
+ function slideInsideFrame(rect, frameWidth, frameHeight) {
36869
+ const w = Math.max(0, Math.min(rect.w, frameWidth));
36870
+ const h = Math.max(0, Math.min(rect.h, frameHeight));
36871
+ return {
36872
+ x: Math.min(Math.max(0, rect.x), Math.max(0, frameWidth - w)),
36873
+ y: Math.min(Math.max(0, rect.y), Math.max(0, frameHeight - h)),
36874
+ w,
36875
+ h
36876
+ };
36877
+ }
36878
+ //#endregion
36879
+ //#region src/pipeline/native-lease.ts
36880
+ /**
36881
+ * THE native-frame **lease** knobs — TTL, RAM budget and demand window for the
36882
+ * decode worker's native-resolution frame retention.
36883
+ *
36884
+ * ## Why they live here and not in the addon that reads them
36885
+ *
36886
+ * The WRITER is `pipeline-orchestrator` (the cluster-wide settings authority);
36887
+ * the READER is a private child process of `addon-pipeline`'s pipeline-runner.
36888
+ * Addons never import each other, so a key owned by either side would have to
36889
+ * be hand-copied by the other — and a hand-copied key is how a setting silently
36890
+ * stops arriving while both sides still look correct. Same reasoning, same
36891
+ * placement as `detail-crop.ts` (D52's "one cluster-wide orchestrator setting").
36892
+ *
36893
+ * ## Why cluster-wide and not per-node
36894
+ *
36895
+ * The lease is a per-decode-worker RAM window. Its purpose — the late
36896
+ * cross-process native crop landing on a full-resolution frame rather than the
36897
+ * ≤640 detection fallback — is a property of the PIPELINE, not of a node's
36898
+ * hardware: a per-node TTL would mean the same camera produces different crop
36899
+ * quality depending on which node the balancer placed it on, and nobody could
36900
+ * tell that from the stored media. Node-level RAM pressure is already handled
36901
+ * by the per-session budget ceiling, which is itself one of these knobs.
36902
+ *
36903
+ * ## What each knob costs
36904
+ *
36905
+ * A retained frame is a full NATIVE-resolution copy in system RAM. With the
36906
+ * default pinned-RGB24 lease path (`CAMSTACK_SESSION_PINNED_RGB_CROP`, on):
36907
+ * 4K ≈ 24.9 MB/frame, 1080p ≈ 6.2 MB/frame. On the YUV420P path (flag off, and
36908
+ * for software-decoded sessions): 4K ≈ 12.4 MB, 1080p ≈ 3.1 MB. Worst-case
36909
+ * resident RAM for ONE busy camera ≈ frameBytes × deliveredFps × ttlSeconds,
36910
+ * clamped by the budget ceiling. See `docs/design/decode-path.md` → "Lease
36911
+ * admission" for what actually gets admitted.
36912
+ */
36913
+ /**
36914
+ * Store identity of the lease knobs in `pipeline-orchestrator`'s GLOBAL
36915
+ * (cluster-wide) settings. Keys are unique across that addon's whole schema, so
36916
+ * the reader can walk every section instead of trusting the section id.
36917
+ */
36918
+ var NATIVE_LEASE_SECTION_ID = "native-lease";
36919
+ var NATIVE_LEASE_TTL_KEY = "nativeLeaseTtlMs";
36920
+ var NATIVE_LEASE_BUDGET_KEY = "nativeLeaseBudgetMb";
36921
+ var NATIVE_LEASE_ACTIVITY_KEY = "nativeLeaseActivityMs";
36922
+ var NATIVE_LEASE_ADMISSION_KEY = "nativeLeaseAdmission";
36923
+ /**
36924
+ * WHICH delivered frames the decode worker retains a native copy of.
36925
+ *
36926
+ * - `all` — every frame the worker delivered to the runner. The shipped
36927
+ * behaviour, and the only correct one if something can ask for a crop of a
36928
+ * frame the runner never sent to inference.
36929
+ * - `inferred` — only the frames the runner ADMITTED to its detection queue.
36930
+ * A native-crop request always names a `frameId` that rode an inference
36931
+ * result, so that is the only set a request can name. How much it drops is
36932
+ * the two-plane governor's admit ratio and nothing else: measured at ~50% on
36933
+ * this cluster, not the ~80% the design sketch assumed, because the governor
36934
+ * was not throttling as hard as the sketch supposed. Read
36935
+ * `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
36936
+ * of you rather than quoting a number from here. The newest delivered frame is
36937
+ * croppable regardless — it is still the worker's reserved slot, not a lease —
36938
+ * which covers the one-frame race between a mark and the supersede that
36939
+ * consumes it.
36940
+ */
36941
+ var NativeLeaseAdmissionSchema = zod.z.enum(["all", "inferred"]);
36942
+ /**
36943
+ * Operator-tunable native-lease settings. Bounds are enforced HERE (not only in
36944
+ * the slider) because the value also travels to a forked child process, where a
36945
+ * junk number would silently become a 0-length or unbounded retention window.
36946
+ */
36947
+ var NativeLeaseSettingsSchema = zod.z.object({
36948
+ /**
36949
+ * How long a retained native frame is served before it counts as a miss.
36950
+ *
36951
+ * Must cover the FULL late-crop horizon: detection inference + the
36952
+ * cross-process inference-result hop to hub post-analysis + tracking + the
36953
+ * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
36954
+ * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
36955
+ * RAM per busy camera grows linearly with no measured hit-rate gain.
36956
+ */
36957
+ ttlMs: zod.z.number().int().min(250).max(1e4),
36958
+ /**
36959
+ * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
36960
+ *
36961
+ * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
36962
+ * which one is actually binding before reasoning from that. At the shipped
36963
+ * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
36964
+ * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
36965
+ * `leaseFrames` on the metrics line say which. When the ceiling binds, a
36966
+ * change that admits fewer frames buys retention WINDOW at constant RAM
36967
+ * rather than giving RAM back — lower this knob if RAM is what you wanted.
36968
+ * `0` DISABLES the lease entirely and falls the worker back to the tiny
36969
+ * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
36970
+ * to replace).
36971
+ */
36972
+ budgetMb: zod.z.number().int().min(0).max(4096),
36973
+ /**
36974
+ * Demand window: eager per-frame native retention runs only within this many
36975
+ * ms of the last native-crop request (or of the dial starting).
36976
+ *
36977
+ * `0` means ALWAYS ON — it disables the gate, it does not disable retention.
36978
+ * That is the legacy behaviour that saturated an N100 (24 native-4K downloads
36979
+ * per second on a camera with zero crop demand), so leave it non-zero unless
36980
+ * you are reproducing that.
36981
+ */
36982
+ activityMs: zod.z.number().int().min(0).max(12e4),
36983
+ /**
36984
+ * Which delivered frames are retained at all — see
36985
+ * {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
36986
+ * changes WHAT is kept rather than for how long, so it is also the only one
36987
+ * that can turn a crop that used to hit into a miss. The worker counts every
36988
+ * crop request naming a frame it did NOT see marked
36989
+ * (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
36990
+ * there is the signal that some caller names frames outside the inference set
36991
+ * and that this must go back to `all`.
36992
+ */
36993
+ admission: NativeLeaseAdmissionSchema
36994
+ });
36995
+ /**
36996
+ * The values in force when the operator has set nothing — byte-for-byte the
36997
+ * constants the decode worker shipped with as env-var defaults, so making these
36998
+ * settings changed no behaviour on the day it landed.
36999
+ */
37000
+ var DEFAULT_NATIVE_LEASE_SETTINGS = {
37001
+ ttlMs: 1200,
37002
+ budgetMb: 1024,
37003
+ activityMs: 15e3,
37004
+ admission: "inferred"
37005
+ };
37006
+ /** Slider bounds for the operator-facing knobs (orchestrator settings UI). */
37007
+ var NATIVE_LEASE_TTL_FIELD = {
37008
+ min: 250,
37009
+ max: 1e4,
37010
+ step: 50,
37011
+ default: DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs
37012
+ };
37013
+ var NATIVE_LEASE_BUDGET_FIELD = {
37014
+ min: 0,
37015
+ max: 4096,
37016
+ step: 64,
37017
+ default: DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb
37018
+ };
37019
+ var NATIVE_LEASE_ACTIVITY_FIELD = {
37020
+ min: 0,
37021
+ max: 12e4,
37022
+ step: 1e3,
37023
+ default: DEFAULT_NATIVE_LEASE_SETTINGS.activityMs
37024
+ };
37025
+ /** Select options for the admission knob (orchestrator settings UI). */
37026
+ var NATIVE_LEASE_ADMISSION_FIELD = {
37027
+ options: [{
37028
+ value: "all",
37029
+ label: "Every delivered frame"
37030
+ }, {
37031
+ value: "inferred",
37032
+ label: "Only frames sent to inference"
37033
+ }],
37034
+ default: DEFAULT_NATIVE_LEASE_SETTINGS.admission
37035
+ };
37036
+ /**
37037
+ * Parse one knob, reporting `null` for absent, junk, out-of-bounds — AND for a
37038
+ * value equal to the shipped default.
37039
+ *
37040
+ * That last rule is not tidiness, it is the difference between the documented
37041
+ * precedence being true and being a lie. `addon-settings.getGlobalSettings`
37042
+ * returns a HYDRATED payload, and `hydrateField` fills an unstored field with
37043
+ * the schema's own `default` (verified live on the hub: a cluster that has never
37044
+ * opened the form still reports `nativeLeaseTtlMs = 1200`). A reader that took
37045
+ * that at face value would report all three knobs as "set" on every cluster on
37046
+ * the day this shipped, permanently retiring the `CAMSTACK_SESSION_NATIVE_LEASE_*`
37047
+ * emergency override that the precedence promises. There is no raw-store read on
37048
+ * this cap to distinguish the two, so the default value itself is treated as
37049
+ * "the operator has expressed no preference" — which is also what leaving a
37050
+ * slider untouched means.
37051
+ *
37052
+ * The cost is one honest edge: an operator who deliberately selects the default
37053
+ * value in order to overrule an env var does not get it. Clear the env var
37054
+ * instead; the worker's spawn line names the source, so this is visible rather
37055
+ * than mysterious.
37056
+ */
37057
+ function readKnob(knob, raw) {
37058
+ const parsed = NativeLeaseSettingsSchema.shape[knob].safeParse(raw);
37059
+ if (!parsed.success) return null;
37060
+ return parsed.data === DEFAULT_NATIVE_LEASE_SETTINGS[knob] ? null : parsed.data;
37061
+ }
37062
+ /** {@link readKnob} for the one non-numeric knob. Same default-means-unset rule. */
37063
+ function readAdmissionKnob(raw) {
37064
+ const parsed = NativeLeaseAdmissionSchema.safeParse(raw);
37065
+ if (!parsed.success) return null;
37066
+ return parsed.data === DEFAULT_NATIVE_LEASE_SETTINGS.admission ? null : parsed.data;
37067
+ }
37068
+ /**
37069
+ * Narrow a FLAT settings record to the knobs the operator set.
37070
+ *
37071
+ * Per-FIELD parse, deliberately: a junk TTL must not also discard a valid
37072
+ * budget. An absent, out-of-bounds or default-valued knob is OMITTED (not
37073
+ * clamped, not defaulted) so the caller can still fall through to the env
37074
+ * override — clamping here would turn a typo into a value nobody chose. See
37075
+ * {@link readKnob} for why the default counts as unset.
37076
+ */
37077
+ function readNativeLeaseOverride(config) {
37078
+ const ttlMs = readKnob("ttlMs", config[NATIVE_LEASE_TTL_KEY]);
37079
+ const budgetMb = readKnob("budgetMb", config[NATIVE_LEASE_BUDGET_KEY]);
37080
+ const activityMs = readKnob("activityMs", config[NATIVE_LEASE_ACTIVITY_KEY]);
37081
+ const admission = readAdmissionKnob(config[NATIVE_LEASE_ADMISSION_KEY]);
37082
+ return {
37083
+ ...ttlMs === null ? {} : { ttlMs },
37084
+ ...budgetMb === null ? {} : { budgetMb },
37085
+ ...activityMs === null ? {} : { activityMs },
37086
+ ...admission === null ? {} : { admission }
37087
+ };
37088
+ }
37089
+ function isHydratedField(entry) {
37090
+ return typeof entry === "object" && entry !== null && "key" in entry;
37091
+ }
37092
+ var LEASE_KEYS = [
37093
+ NATIVE_LEASE_TTL_KEY,
37094
+ NATIVE_LEASE_BUDGET_KEY,
37095
+ NATIVE_LEASE_ACTIVITY_KEY,
37096
+ NATIVE_LEASE_ADMISSION_KEY
37097
+ ];
37098
+ /**
37099
+ * Extract the operator's lease overrides from an
37100
+ * `addon-settings.getGlobalSettings` payload.
37101
+ *
37102
+ * Walks EVERY section rather than looking inside {@link NATIVE_LEASE_SECTION_ID}
37103
+ * alone: the keys are unique across the addon's schema, and a section rename
37104
+ * must not silently revert the whole cluster to the defaults. A `null` payload
37105
+ * (addon mid-boot) means "operator set nothing" — the env/default fallback then
37106
+ * applies, which is the correct read of "I could not ask".
37107
+ */
37108
+ function pickNativeLeaseOverride(view) {
37109
+ if (view === null) return {};
37110
+ const flat = {};
37111
+ for (const section of view.sections) for (const entry of section.fields) {
37112
+ if (!isHydratedField(entry) || typeof entry.key !== "string") continue;
37113
+ if (LEASE_KEYS.includes(entry.key)) flat[entry.key] = entry.value;
37114
+ }
37115
+ return readNativeLeaseOverride(flat);
37116
+ }
37117
+ //#endregion
36391
37118
  //#region src/types/device-type.ts
36392
37119
  var DEVICE_TYPE_INFO = { ["camera"]: {
36393
37120
  type: "camera",
@@ -37447,441 +38174,6 @@ function pointInPolygon(point, polygon) {
37447
38174
  return inside;
37448
38175
  }
37449
38176
  //#endregion
37450
- //#region src/pipeline/detail-crop.ts
37451
- /**
37452
- * THE detail-crop convention — the single derivation of the rectangle a
37453
- * detail/enrichment step (clip-embedding, face-detection, plate-detection…)
37454
- * is fed.
37455
- *
37456
- * ## Why this is one module and not two constants
37457
- *
37458
- * `object-clip` is ONE vector index, and cosine similarity is only meaningful
37459
- * between vectors produced from the same crop convention. Two encode paths
37460
- * write into it — the live detail plane and the embedding rebuild — and they
37461
- * used to derive their crops independently: `DETAIL_CROP_PADDING_RATIO = 0.15`
37462
- * with no squaring on one side, `DEFAULT_CROP_MARGIN = 0.2` with squaring on
37463
- * by default on the other. Every rebuild therefore poured a second, silently
37464
- * incomparable feature space into the index it exists to keep consistent.
37465
- *
37466
- * So the rectangle is derived HERE, once, from ONE convention value. Both
37467
- * paths now reach this function through `pipelineRunner.runDetailSubtree` —
37468
- * the runner is the only process that cuts (see `detail-subtree.ts`), and the
37469
- * convention is a cluster-global `pipeline-orchestrator` setting. There is
37470
- * deliberately no per-node or per-device scope: a per-accelerator crop margin
37471
- * would reintroduce the same split, merely relocated.
37472
- *
37473
- * ## The default IS the live convention
37474
- *
37475
- * {@link DEFAULT_DETAIL_CROP_CONVENTION} reproduces what the live path has
37476
- * been storing (0.15, no squaring). Anything else would invalidate every
37477
- * vector already in the index on the day it shipped. Changing the convention
37478
- * is legitimate — that is what the operator knob is for — but it must be
37479
- * followed by a rebuild, which is now guaranteed to produce crops from this
37480
- * same function.
37481
- */
37482
- /**
37483
- * Store identity of the convention in `pipeline-orchestrator`'s GLOBAL
37484
- * (cluster-wide) settings.
37485
- *
37486
- * These live here rather than in the orchestrator because the reader is a
37487
- * different addon — the pipeline runner, over the hub-routed `addon-settings`
37488
- * cap. Addons never import each other, so a key owned by the writer would have
37489
- * to be hand-copied by the reader, and a hand-copied key is how a setting
37490
- * silently stops arriving while both sides still look correct.
37491
- */
37492
- var DETAIL_CROP_SECTION_ID = "detail-crop";
37493
- var DETAIL_CROP_PADDING_KEY = "detailCropPaddingRatio";
37494
- var DETAIL_CROP_SQUARE_KEY = "detailCropSquare";
37495
- /**
37496
- * Operator-tunable crop convention. Single-valued and cluster-wide — see the
37497
- * module docblock for why it cannot be scoped per node or per device.
37498
- */
37499
- var DetailCropConventionSchema = zod.z.object({
37500
- /**
37501
- * Fraction of the box's own size added on EACH side before cutting.
37502
- *
37503
- * CLIP is trained on natural images WITH surroundings; a pixel-tight crop
37504
- * removes exactly the context it is strongest on (a dog cut to its outline
37505
- * is a dark blob). The right value is an empirical question, which is why it
37506
- * is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
37507
- */
37508
- paddingRatio: zod.z.number().min(0).max(4),
37509
- /**
37510
- * Square the window (in PIXELS) before cutting.
37511
- *
37512
- * CLIP's input is square, so a tall bbox resized straight to NxN is squashed
37513
- * — a standing person becomes a shape the model never saw. Squaring costs
37514
- * extra background, which is context the model wants anyway. Off by default
37515
- * because the live path has never squared and the stored index reflects that.
37516
- */
37517
- square: zod.z.boolean()
37518
- });
37519
- /**
37520
- * The convention in force when nobody has configured one — byte-for-byte the
37521
- * behaviour of the pre-unification LIVE path (`DETAIL_CROP_PADDING_RATIO`).
37522
- */
37523
- var DEFAULT_DETAIL_CROP_CONVENTION = {
37524
- paddingRatio: .15,
37525
- square: false
37526
- };
37527
- /**
37528
- * Narrow a FLAT settings record to the convention.
37529
- *
37530
- * Per-FIELD fallback, deliberately: a junk padding must not also discard a
37531
- * valid squaring choice. An absent or invalid value resolves to
37532
- * {@link DEFAULT_DETAIL_CROP_CONVENTION} — the historical live behaviour —
37533
- * rather than to a clamped number nobody chose, so a bad read can never
37534
- * quietly change what the stored vectors mean.
37535
- */
37536
- function readDetailCropConvention(config) {
37537
- const paddingRatio = DetailCropConventionSchema.shape.paddingRatio.safeParse(config[DETAIL_CROP_PADDING_KEY]);
37538
- const square = DetailCropConventionSchema.shape.square.safeParse(config[DETAIL_CROP_SQUARE_KEY]);
37539
- return {
37540
- paddingRatio: paddingRatio.success ? paddingRatio.data : DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio,
37541
- square: square.success ? square.data : DEFAULT_DETAIL_CROP_CONVENTION.square
37542
- };
37543
- }
37544
- function isHydratedField$1(entry) {
37545
- return typeof entry === "object" && entry !== null && "key" in entry;
37546
- }
37547
- /**
37548
- * Extract the convention from an `addon-settings.getGlobalSettings` payload.
37549
- *
37550
- * Walks EVERY section rather than looking inside {@link DETAIL_CROP_SECTION_ID}
37551
- * alone: the keys are unique across the addon's schema, and a section rename
37552
- * must not silently revert the whole cluster to the default. A `null` payload
37553
- * (addon mid-boot) is the default convention.
37554
- */
37555
- function pickDetailCropConvention(view) {
37556
- if (view === null) return DEFAULT_DETAIL_CROP_CONVENTION;
37557
- const flat = {};
37558
- for (const section of view.sections) for (const entry of section.fields) {
37559
- if (!isHydratedField$1(entry) || typeof entry.key !== "string") continue;
37560
- if (entry.key === "detailCropPaddingRatio" || entry.key === "detailCropSquare") flat[entry.key] = entry.value;
37561
- }
37562
- return readDetailCropConvention(flat);
37563
- }
37564
- /** Slider bounds for the operator-facing padding knob (orchestrator settings UI). */
37565
- var DETAIL_CROP_PADDING_FIELD = {
37566
- min: 0,
37567
- max: 1,
37568
- step: .05,
37569
- default: DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio
37570
- };
37571
- /**
37572
- * Derive the crop rectangle for one parent detection.
37573
- *
37574
- * Order: pad by `paddingRatio` of the box's own size → optionally square in
37575
- * pixel space around the padded centre → keep it inside the frame. Pure:
37576
- * always returns a new rect and never mutates `bbox`.
37577
- *
37578
- * Edge handling differs by mode, on purpose:
37579
- *
37580
- * - **unsquared** — TRUNCATED at the frame border, byte-for-byte what the live
37581
- * path has always done (`padAndClampFrameBbox`). A subject against the edge
37582
- * gets a slightly smaller window. Changing this would silently reinterpret
37583
- * every edge-touching vector already in the index.
37584
- * - **squared** — SLID inward instead, because a truncated square is not
37585
- * square and squaring exists precisely to preserve the aspect the model
37586
- * sees. It only shrinks when the square is larger than the frame itself.
37587
- */
37588
- function deriveDetailCropRect(bbox, frameWidth, frameHeight, convention) {
37589
- const padX = convention.paddingRatio * bbox.w;
37590
- const padY = convention.paddingRatio * bbox.h;
37591
- const padded = {
37592
- x: bbox.x - padX,
37593
- y: bbox.y - padY,
37594
- w: bbox.w + 2 * padX,
37595
- h: bbox.h + 2 * padY
37596
- };
37597
- return convention.square ? slideInsideFrame(squareInPixels(padded, frameWidth, frameHeight), frameWidth, frameHeight) : truncateToFrame(padded, frameWidth, frameHeight);
37598
- }
37599
- /**
37600
- * Grow the shorter side to the longer one around the window's centre, bounded
37601
- * by the frame's shorter side — a square larger than the frame cannot exist,
37602
- * and collapsing to the frame's short side is the most that does.
37603
- */
37604
- function squareInPixels(rect, frameWidth, frameHeight) {
37605
- const side = Math.min(Math.max(rect.w, rect.h), Math.min(frameWidth, frameHeight));
37606
- const cx = rect.x + rect.w / 2;
37607
- const cy = rect.y + rect.h / 2;
37608
- return {
37609
- x: cx - side / 2,
37610
- y: cy - side / 2,
37611
- w: side,
37612
- h: side
37613
- };
37614
- }
37615
- /**
37616
- * Cut the window at the frame border — the pre-unification live behaviour,
37617
- * preserved exactly so unsquared crops keep matching the stored index.
37618
- */
37619
- function truncateToFrame(rect, frameWidth, frameHeight) {
37620
- const x1 = Math.max(0, rect.x);
37621
- const y1 = Math.max(0, rect.y);
37622
- const x2 = Math.min(frameWidth, rect.x + rect.w);
37623
- const y2 = Math.min(frameHeight, rect.y + rect.h);
37624
- return {
37625
- x: x1,
37626
- y: y1,
37627
- w: Math.max(0, x2 - x1),
37628
- h: Math.max(0, y2 - y1)
37629
- };
37630
- }
37631
- /**
37632
- * Move the window inside the frame keeping its extent — used only for squared
37633
- * windows, where truncating would destroy the squareness that is the point.
37634
- */
37635
- function slideInsideFrame(rect, frameWidth, frameHeight) {
37636
- const w = Math.max(0, Math.min(rect.w, frameWidth));
37637
- const h = Math.max(0, Math.min(rect.h, frameHeight));
37638
- return {
37639
- x: Math.min(Math.max(0, rect.x), Math.max(0, frameWidth - w)),
37640
- y: Math.min(Math.max(0, rect.y), Math.max(0, frameHeight - h)),
37641
- w,
37642
- h
37643
- };
37644
- }
37645
- //#endregion
37646
- //#region src/pipeline/native-lease.ts
37647
- /**
37648
- * THE native-frame **lease** knobs — TTL, RAM budget and demand window for the
37649
- * decode worker's native-resolution frame retention.
37650
- *
37651
- * ## Why they live here and not in the addon that reads them
37652
- *
37653
- * The WRITER is `pipeline-orchestrator` (the cluster-wide settings authority);
37654
- * the READER is a private child process of `addon-pipeline`'s pipeline-runner.
37655
- * Addons never import each other, so a key owned by either side would have to
37656
- * be hand-copied by the other — and a hand-copied key is how a setting silently
37657
- * stops arriving while both sides still look correct. Same reasoning, same
37658
- * placement as `detail-crop.ts` (D52's "one cluster-wide orchestrator setting").
37659
- *
37660
- * ## Why cluster-wide and not per-node
37661
- *
37662
- * The lease is a per-decode-worker RAM window. Its purpose — the late
37663
- * cross-process native crop landing on a full-resolution frame rather than the
37664
- * ≤640 detection fallback — is a property of the PIPELINE, not of a node's
37665
- * hardware: a per-node TTL would mean the same camera produces different crop
37666
- * quality depending on which node the balancer placed it on, and nobody could
37667
- * tell that from the stored media. Node-level RAM pressure is already handled
37668
- * by the per-session budget ceiling, which is itself one of these knobs.
37669
- *
37670
- * ## What each knob costs
37671
- *
37672
- * A retained frame is a full NATIVE-resolution copy in system RAM. With the
37673
- * default pinned-RGB24 lease path (`CAMSTACK_SESSION_PINNED_RGB_CROP`, on):
37674
- * 4K ≈ 24.9 MB/frame, 1080p ≈ 6.2 MB/frame. On the YUV420P path (flag off, and
37675
- * for software-decoded sessions): 4K ≈ 12.4 MB, 1080p ≈ 3.1 MB. Worst-case
37676
- * resident RAM for ONE busy camera ≈ frameBytes × deliveredFps × ttlSeconds,
37677
- * clamped by the budget ceiling. See `docs/design/decode-path.md` → "Lease
37678
- * admission" for what actually gets admitted.
37679
- */
37680
- /**
37681
- * Store identity of the lease knobs in `pipeline-orchestrator`'s GLOBAL
37682
- * (cluster-wide) settings. Keys are unique across that addon's whole schema, so
37683
- * the reader can walk every section instead of trusting the section id.
37684
- */
37685
- var NATIVE_LEASE_SECTION_ID = "native-lease";
37686
- var NATIVE_LEASE_TTL_KEY = "nativeLeaseTtlMs";
37687
- var NATIVE_LEASE_BUDGET_KEY = "nativeLeaseBudgetMb";
37688
- var NATIVE_LEASE_ACTIVITY_KEY = "nativeLeaseActivityMs";
37689
- var NATIVE_LEASE_ADMISSION_KEY = "nativeLeaseAdmission";
37690
- /**
37691
- * WHICH delivered frames the decode worker retains a native copy of.
37692
- *
37693
- * - `all` — every frame the worker delivered to the runner. The shipped
37694
- * behaviour, and the only correct one if something can ask for a crop of a
37695
- * frame the runner never sent to inference.
37696
- * - `inferred` — only the frames the runner ADMITTED to its detection queue.
37697
- * A native-crop request always names a `frameId` that rode an inference
37698
- * result, so that is the only set a request can name. How much it drops is
37699
- * the two-plane governor's admit ratio and nothing else: measured at ~50% on
37700
- * this cluster, not the ~80% the design sketch assumed, because the governor
37701
- * was not throttling as hard as the sketch supposed. Read
37702
- * `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
37703
- * of you rather than quoting a number from here. The newest delivered frame is
37704
- * croppable regardless — it is still the worker's reserved slot, not a lease —
37705
- * which covers the one-frame race between a mark and the supersede that
37706
- * consumes it.
37707
- */
37708
- var NativeLeaseAdmissionSchema = zod.z.enum(["all", "inferred"]);
37709
- /**
37710
- * Operator-tunable native-lease settings. Bounds are enforced HERE (not only in
37711
- * the slider) because the value also travels to a forked child process, where a
37712
- * junk number would silently become a 0-length or unbounded retention window.
37713
- */
37714
- var NativeLeaseSettingsSchema = zod.z.object({
37715
- /**
37716
- * How long a retained native frame is served before it counts as a miss.
37717
- *
37718
- * Must cover the FULL late-crop horizon: detection inference + the
37719
- * cross-process inference-result hop to hub post-analysis + tracking + the
37720
- * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
37721
- * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
37722
- * RAM per busy camera grows linearly with no measured hit-rate gain.
37723
- */
37724
- ttlMs: zod.z.number().int().min(250).max(1e4),
37725
- /**
37726
- * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
37727
- *
37728
- * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
37729
- * which one is actually binding before reasoning from that. At the shipped
37730
- * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
37731
- * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
37732
- * `leaseFrames` on the metrics line say which. When the ceiling binds, a
37733
- * change that admits fewer frames buys retention WINDOW at constant RAM
37734
- * rather than giving RAM back — lower this knob if RAM is what you wanted.
37735
- * `0` DISABLES the lease entirely and falls the worker back to the tiny
37736
- * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
37737
- * to replace).
37738
- */
37739
- budgetMb: zod.z.number().int().min(0).max(4096),
37740
- /**
37741
- * Demand window: eager per-frame native retention runs only within this many
37742
- * ms of the last native-crop request (or of the dial starting).
37743
- *
37744
- * `0` means ALWAYS ON — it disables the gate, it does not disable retention.
37745
- * That is the legacy behaviour that saturated an N100 (24 native-4K downloads
37746
- * per second on a camera with zero crop demand), so leave it non-zero unless
37747
- * you are reproducing that.
37748
- */
37749
- activityMs: zod.z.number().int().min(0).max(12e4),
37750
- /**
37751
- * Which delivered frames are retained at all — see
37752
- * {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
37753
- * changes WHAT is kept rather than for how long, so it is also the only one
37754
- * that can turn a crop that used to hit into a miss. The worker counts every
37755
- * crop request naming a frame it did NOT see marked
37756
- * (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
37757
- * there is the signal that some caller names frames outside the inference set
37758
- * and that this must go back to `all`.
37759
- */
37760
- admission: NativeLeaseAdmissionSchema
37761
- });
37762
- /**
37763
- * The values in force when the operator has set nothing — byte-for-byte the
37764
- * constants the decode worker shipped with as env-var defaults, so making these
37765
- * settings changed no behaviour on the day it landed.
37766
- */
37767
- var DEFAULT_NATIVE_LEASE_SETTINGS = {
37768
- ttlMs: 1200,
37769
- budgetMb: 1024,
37770
- activityMs: 15e3,
37771
- admission: "inferred"
37772
- };
37773
- /** Slider bounds for the operator-facing knobs (orchestrator settings UI). */
37774
- var NATIVE_LEASE_TTL_FIELD = {
37775
- min: 250,
37776
- max: 1e4,
37777
- step: 50,
37778
- default: DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs
37779
- };
37780
- var NATIVE_LEASE_BUDGET_FIELD = {
37781
- min: 0,
37782
- max: 4096,
37783
- step: 64,
37784
- default: DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb
37785
- };
37786
- var NATIVE_LEASE_ACTIVITY_FIELD = {
37787
- min: 0,
37788
- max: 12e4,
37789
- step: 1e3,
37790
- default: DEFAULT_NATIVE_LEASE_SETTINGS.activityMs
37791
- };
37792
- /** Select options for the admission knob (orchestrator settings UI). */
37793
- var NATIVE_LEASE_ADMISSION_FIELD = {
37794
- options: [{
37795
- value: "all",
37796
- label: "Every delivered frame"
37797
- }, {
37798
- value: "inferred",
37799
- label: "Only frames sent to inference"
37800
- }],
37801
- default: DEFAULT_NATIVE_LEASE_SETTINGS.admission
37802
- };
37803
- /**
37804
- * Parse one knob, reporting `null` for absent, junk, out-of-bounds — AND for a
37805
- * value equal to the shipped default.
37806
- *
37807
- * That last rule is not tidiness, it is the difference between the documented
37808
- * precedence being true and being a lie. `addon-settings.getGlobalSettings`
37809
- * returns a HYDRATED payload, and `hydrateField` fills an unstored field with
37810
- * the schema's own `default` (verified live on the hub: a cluster that has never
37811
- * opened the form still reports `nativeLeaseTtlMs = 1200`). A reader that took
37812
- * that at face value would report all three knobs as "set" on every cluster on
37813
- * the day this shipped, permanently retiring the `CAMSTACK_SESSION_NATIVE_LEASE_*`
37814
- * emergency override that the precedence promises. There is no raw-store read on
37815
- * this cap to distinguish the two, so the default value itself is treated as
37816
- * "the operator has expressed no preference" — which is also what leaving a
37817
- * slider untouched means.
37818
- *
37819
- * The cost is one honest edge: an operator who deliberately selects the default
37820
- * value in order to overrule an env var does not get it. Clear the env var
37821
- * instead; the worker's spawn line names the source, so this is visible rather
37822
- * than mysterious.
37823
- */
37824
- function readKnob(knob, raw) {
37825
- const parsed = NativeLeaseSettingsSchema.shape[knob].safeParse(raw);
37826
- if (!parsed.success) return null;
37827
- return parsed.data === DEFAULT_NATIVE_LEASE_SETTINGS[knob] ? null : parsed.data;
37828
- }
37829
- /** {@link readKnob} for the one non-numeric knob. Same default-means-unset rule. */
37830
- function readAdmissionKnob(raw) {
37831
- const parsed = NativeLeaseAdmissionSchema.safeParse(raw);
37832
- if (!parsed.success) return null;
37833
- return parsed.data === DEFAULT_NATIVE_LEASE_SETTINGS.admission ? null : parsed.data;
37834
- }
37835
- /**
37836
- * Narrow a FLAT settings record to the knobs the operator set.
37837
- *
37838
- * Per-FIELD parse, deliberately: a junk TTL must not also discard a valid
37839
- * budget. An absent, out-of-bounds or default-valued knob is OMITTED (not
37840
- * clamped, not defaulted) so the caller can still fall through to the env
37841
- * override — clamping here would turn a typo into a value nobody chose. See
37842
- * {@link readKnob} for why the default counts as unset.
37843
- */
37844
- function readNativeLeaseOverride(config) {
37845
- const ttlMs = readKnob("ttlMs", config[NATIVE_LEASE_TTL_KEY]);
37846
- const budgetMb = readKnob("budgetMb", config[NATIVE_LEASE_BUDGET_KEY]);
37847
- const activityMs = readKnob("activityMs", config[NATIVE_LEASE_ACTIVITY_KEY]);
37848
- const admission = readAdmissionKnob(config[NATIVE_LEASE_ADMISSION_KEY]);
37849
- return {
37850
- ...ttlMs === null ? {} : { ttlMs },
37851
- ...budgetMb === null ? {} : { budgetMb },
37852
- ...activityMs === null ? {} : { activityMs },
37853
- ...admission === null ? {} : { admission }
37854
- };
37855
- }
37856
- function isHydratedField(entry) {
37857
- return typeof entry === "object" && entry !== null && "key" in entry;
37858
- }
37859
- var LEASE_KEYS = [
37860
- NATIVE_LEASE_TTL_KEY,
37861
- NATIVE_LEASE_BUDGET_KEY,
37862
- NATIVE_LEASE_ACTIVITY_KEY,
37863
- NATIVE_LEASE_ADMISSION_KEY
37864
- ];
37865
- /**
37866
- * Extract the operator's lease overrides from an
37867
- * `addon-settings.getGlobalSettings` payload.
37868
- *
37869
- * Walks EVERY section rather than looking inside {@link NATIVE_LEASE_SECTION_ID}
37870
- * alone: the keys are unique across the addon's schema, and a section rename
37871
- * must not silently revert the whole cluster to the defaults. A `null` payload
37872
- * (addon mid-boot) means "operator set nothing" — the env/default fallback then
37873
- * applies, which is the correct read of "I could not ask".
37874
- */
37875
- function pickNativeLeaseOverride(view) {
37876
- if (view === null) return {};
37877
- const flat = {};
37878
- for (const section of view.sections) for (const entry of section.fields) {
37879
- if (!isHydratedField(entry) || typeof entry.key !== "string") continue;
37880
- if (LEASE_KEYS.includes(entry.key)) flat[entry.key] = entry.value;
37881
- }
37882
- return readNativeLeaseOverride(flat);
37883
- }
37884
- //#endregion
37885
38177
  //#region src/helpers/bind-addon-actions.ts
37886
38178
  /**
37887
38179
  * Bind an addon's custom-action catalog to its tRPC surface, returning a
@@ -38140,7 +38432,7 @@ exports.APPLE_SA_TO_MACRO = APPLE_SA_TO_MACRO;
38140
38432
  exports.AUDIO_ANALYSIS_CAP_NAME = AUDIO_ANALYSIS_CAP_NAME;
38141
38433
  exports.AUDIO_BACKEND_CHOICES = AUDIO_BACKEND_CHOICES;
38142
38434
  exports.AUDIO_MACRO_LABELS = AUDIO_MACRO_LABELS;
38143
- exports.AUDIO_PRESETS = require_fmp4_box_splitter.AUDIO_PRESETS;
38435
+ exports.AUDIO_PRESETS = require_canonical_hash.AUDIO_PRESETS;
38144
38436
  exports.AccessoriesStatusSchema = AccessoriesStatusSchema;
38145
38437
  exports.AccessoryKind = AccessoryKind;
38146
38438
  exports.AddBrokerInputSchema = AddBrokerInputSchema;
@@ -38237,6 +38529,7 @@ exports.CAP_NODE_PIN_CONTEXT_KEY = require_sleep.CAP_NODE_PIN_CONTEXT_KEY;
38237
38529
  exports.CAP_PROVIDER_KIND_MAP = CAP_PROVIDER_KIND_MAP;
38238
38530
  exports.COCO_80_LABELS = COCO_80_LABELS;
38239
38531
  exports.COCO_TO_MACRO = COCO_TO_MACRO;
38532
+ exports.CORE_BLOCK_ADDON_PREFIX = CORE_BLOCK_ADDON_PREFIX;
38240
38533
  exports.CamProfileSchema = require_sleep.CamProfileSchema;
38241
38534
  exports.CamStreamDescriptorSchema = CamStreamDescriptorSchema;
38242
38535
  exports.CamStreamKindSchema = require_sleep.CamStreamKindSchema;
@@ -38309,6 +38602,8 @@ exports.CreateUserInputSchema = CreateUserInputSchema;
38309
38602
  exports.CustomActionInputSchema = CustomActionInputSchema;
38310
38603
  exports.CustomModelDescriptorSchema = CustomModelDescriptorSchema;
38311
38604
  exports.DATAPLANE_SECRET_HEADER = require_sleep.DATAPLANE_SECRET_HEADER;
38605
+ exports.DECLARED_DEVICE_SWEEP_LIMIT = DECLARED_DEVICE_SWEEP_LIMIT;
38606
+ exports.DECLARED_INTEGRATION_FIXED_KEY = DECLARED_INTEGRATION_FIXED_KEY;
38312
38607
  exports.DEFAULT_ADDON_PLACEMENT = DEFAULT_ADDON_PLACEMENT;
38313
38608
  exports.DEFAULT_AUDIO_ANALYZER_CONFIG = DEFAULT_AUDIO_ANALYZER_CONFIG;
38314
38609
  exports.DEFAULT_DECODER_HWACCEL_CONFIG = DEFAULT_DECODER_HWACCEL_CONFIG;
@@ -38337,6 +38632,7 @@ exports.DayNightModeSchema = DayNightModeSchema;
38337
38632
  exports.DayNightOptionsSchema = DayNightOptionsSchema;
38338
38633
  exports.DayNightSettingsPatchSchema = DayNightSettingsPatchSchema;
38339
38634
  exports.DayNightStatusSchema = DayNightStatusSchema;
38635
+ exports.DeclaredDevices = DeclaredDevices;
38340
38636
  exports.DecodedAudioChunkSchema = require_sleep.DecodedAudioChunkSchema;
38341
38637
  exports.DecodedFrameSchema = require_sleep.DecodedFrameSchema;
38342
38638
  exports.DecoderSessionConfigSchema = DecoderSessionConfigSchema;
@@ -38352,7 +38648,6 @@ exports.DeviceExportStatusSchema = DeviceExportStatusSchema;
38352
38648
  exports.DeviceExportUnexposeInputSchema = UnexposeInputSchema;
38353
38649
  exports.DeviceFeature = require_sleep.DeviceFeature;
38354
38650
  exports.DeviceInfoSchema = DeviceInfoSchema;
38355
- exports.DeviceLinkModeSchema = DeviceLinkModeSchema;
38356
38651
  exports.DeviceNetworkStatsSchema = DeviceNetworkStatsSchema;
38357
38652
  exports.DeviceRole = require_sleep.DeviceRole;
38358
38653
  exports.DeviceRuntimeState = DeviceRuntimeState;
@@ -38410,14 +38705,19 @@ exports.ExportStateSchema = ExportStateSchema;
38410
38705
  exports.ExportTimelapseSchema = ExportTimelapseSchema;
38411
38706
  exports.ExposedDeviceSchema = ExposedDeviceSchema;
38412
38707
  exports.ExposureModeSchema = ExposureModeSchema;
38708
+ exports.ExpressionBindingSourceSchema = ExpressionBindingSourceSchema;
38413
38709
  exports.ExpressionEvalError = ExpressionEvalError;
38710
+ exports.ExpressionFieldBindingSchema = ExpressionFieldBindingSchema;
38711
+ exports.ExpressionGlobalBindingSchema = ExpressionGlobalBindingSchema;
38712
+ exports.ExpressionLiteralBindingSchema = ExpressionLiteralBindingSchema;
38414
38713
  exports.ExpressionParseError = ExpressionParseError;
38714
+ exports.ExpressionSourceSchema = ExpressionSourceSchema;
38415
38715
  exports.FanControlStatusSchema = FanControlStatusSchema;
38416
38716
  exports.FanDirectionSchema = FanDirectionSchema;
38417
38717
  exports.FeatureManifestSchema = FeatureManifestSchema;
38418
38718
  exports.FeatureProbeStatusSchema = FeatureProbeStatusSchema;
38419
38719
  exports.FloodStatusSchema = FloodStatusSchema;
38420
- exports.Fmp4BoxSplitter = require_fmp4_box_splitter.Fmp4BoxSplitter;
38720
+ exports.Fmp4BoxSplitter = require_canonical_hash.Fmp4BoxSplitter;
38421
38721
  exports.FrameHandleFormatSchema = require_sleep.FrameHandleFormatSchema;
38422
38722
  exports.FrameHandleSchema = require_sleep.FrameHandleSchema;
38423
38723
  exports.FrameInputSchema = FrameInputSchema;
@@ -38459,6 +38759,7 @@ exports.LabelTierSchema = LabelTierSchema;
38459
38759
  exports.LawnMowerActivitySchema = LawnMowerActivitySchema;
38460
38760
  exports.LawnMowerControlStatusSchema = LawnMowerControlStatusSchema;
38461
38761
  exports.LinkedDeviceSchema = LinkedDeviceSchema;
38762
+ exports.LinkedDevicesModeSchema = LinkedDevicesModeSchema;
38462
38763
  exports.LlmDefaultSchema = LlmDefaultSchema;
38463
38764
  exports.LlmDefaultSelectorSchema = LlmDefaultSelectorSchema;
38464
38765
  exports.LlmErrorCodeSchema = LlmErrorCodeSchema;
@@ -38929,7 +39230,6 @@ exports.airQualitySensorCapability = airQualitySensorCapability;
38929
39230
  exports.alarmPanelCapability = alarmPanelCapability;
38930
39231
  exports.alertsCapability = alertsCapability;
38931
39232
  exports.ambientLightSensorCapability = ambientLightSensorCapability;
38932
- exports.applyTransform = applyTransform;
38933
39233
  exports.asBoolean = require_sleep.asBoolean;
38934
39234
  exports.asJsonArray = require_sleep.asJsonArray;
38935
39235
  exports.asJsonObject = require_sleep.asJsonObject;
@@ -38939,7 +39239,7 @@ exports.audioAnalysisCapability = audioAnalysisCapability;
38939
39239
  exports.audioAnalyzerCapability = audioAnalyzerCapability;
38940
39240
  exports.audioCodecCapability = audioCodecCapability;
38941
39241
  exports.audioMetricsCapability = audioMetricsCapability;
38942
- exports.audioPlanFromEncodeProfile = require_fmp4_box_splitter.audioPlanFromEncodeProfile;
39242
+ exports.audioPlanFromEncodeProfile = require_canonical_hash.audioPlanFromEncodeProfile;
38943
39243
  exports.authProviderCapability = authProviderCapability;
38944
39244
  exports.autoAssignProfiles = autoAssignProfiles;
38945
39245
  exports.automationControlCapability = automationControlCapability;
@@ -38952,14 +39252,14 @@ exports.bindAddonActions = bindAddonActions;
38952
39252
  exports.brightnessCapability = brightnessCapability;
38953
39253
  exports.brokerCapability = brokerCapability;
38954
39254
  exports.buildAddonRouteProvider = buildAddonRouteProvider;
38955
- exports.buildAudioArgs = require_fmp4_box_splitter.buildAudioArgs;
39255
+ exports.buildAudioArgs = require_canonical_hash.buildAudioArgs;
38956
39256
  exports.buildEventKindDescriptor = buildEventKindDescriptor;
38957
- exports.buildFfmpegArgs = require_fmp4_box_splitter.buildFfmpegArgs;
38958
- exports.buildInputArgs = require_fmp4_box_splitter.buildInputArgs;
39257
+ exports.buildFfmpegArgs = require_canonical_hash.buildFfmpegArgs;
39258
+ exports.buildInputArgs = require_canonical_hash.buildInputArgs;
38959
39259
  exports.buildModelVariantGroups = buildModelVariantGroups;
38960
39260
  exports.buildNcTaxonomy = buildNcTaxonomy;
38961
39261
  exports.buildStreamParamsConfigSchema = buildStreamParamsConfigSchema;
38962
- exports.buildVideoArgs = require_fmp4_box_splitter.buildVideoArgs;
39262
+ exports.buildVideoArgs = require_canonical_hash.buildVideoArgs;
38963
39263
  exports.buttonCapability = buttonCapability;
38964
39264
  exports.cameraCredentialsCapability = cameraCredentialsCapability;
38965
39265
  exports.cameraPipelineConfigCapability = cameraPipelineConfigCapability;
@@ -38982,6 +39282,8 @@ exports.consumablesCapability = consumablesCapability;
38982
39282
  exports.contactCapability = contactCapability;
38983
39283
  exports.controlCapability = controlCapability;
38984
39284
  exports.convertUnit = convertUnit;
39285
+ exports.coreBlockAddonId = coreBlockAddonId;
39286
+ exports.coreBlockIdFromAddonId = coreBlockIdFromAddonId;
38985
39287
  exports.coreBlocksCapability = coreBlocksCapability;
38986
39288
  exports.cosineSimilarity = cosineSimilarity;
38987
39289
  exports.coverCapability = coverCapability;
@@ -38999,6 +39301,7 @@ exports.customAction = customAction;
38999
39301
  exports.customModelRegistryCapability = customModelRegistryCapability;
39000
39302
  exports.dataStoreProviderCapability = dataStoreProviderCapability;
39001
39303
  exports.dayNightCapability = dayNightCapability;
39304
+ exports.declarationOwnerNodeId = declarationOwnerNodeId;
39002
39305
  exports.decodeVectorBase64 = decodeVectorBase64;
39003
39306
  exports.decoderCapability = decoderCapability;
39004
39307
  exports.defaultDeviceFor = defaultDeviceFor;
@@ -39033,7 +39336,7 @@ exports.enumerateItemArrayFields = enumerateItemArrayFields;
39033
39336
  exports.enumerateSchemaFields = enumerateSchemaFields;
39034
39337
  exports.errMsg = require_err_msg.errMsg;
39035
39338
  exports.evaluateAst = evaluateAst;
39036
- exports.evaluateLinkExpression = evaluateLinkExpression;
39339
+ exports.evaluateExpressionSource = evaluateExpressionSource;
39037
39340
  exports.evaluateZoneRules = evaluateZoneRules;
39038
39341
  exports.event = require_sleep.event;
39039
39342
  exports.eventEmitterCapability = eventEmitterCapability;
@@ -39064,7 +39367,7 @@ exports.imageCapability = imageCapability;
39064
39367
  exports.imageSettingsCapability = imageSettingsCapability;
39065
39368
  exports.integrationsCapability = integrationsCapability;
39066
39369
  exports.intercomCapability = intercomCapability;
39067
- exports.invocationFromEncodeProfile = require_fmp4_box_splitter.invocationFromEncodeProfile;
39370
+ exports.invocationFromEncodeProfile = require_canonical_hash.invocationFromEncodeProfile;
39068
39371
  exports.isAgentOnlyPlacement = isAgentOnlyPlacement;
39069
39372
  exports.isArrayOutputSchema = isArrayOutputSchema;
39070
39373
  exports.isBaseConditionKey = isBaseConditionKey;
@@ -39077,7 +39380,7 @@ exports.isNode = isNode;
39077
39380
  exports.isObjectInput = isObjectInput;
39078
39381
  exports.isSameAddonId = isSameAddonId;
39079
39382
  exports.isScheduleActive = isScheduleActive;
39080
- exports.isSoftwareDecode = require_fmp4_box_splitter.isSoftwareDecode;
39383
+ exports.isSoftwareDecode = require_canonical_hash.isSoftwareDecode;
39081
39384
  exports.isVoidInput = isVoidInput;
39082
39385
  exports.jobKindSchema = jobKindSchema;
39083
39386
  exports.kebabToCamel = kebabToCamel;
@@ -39092,7 +39395,7 @@ exports.llmRuntimeCapability = llmRuntimeCapability;
39092
39395
  exports.localNetworkCapability = localNetworkCapability;
39093
39396
  exports.locationSimilarity = locationSimilarity;
39094
39397
  exports.lockControlCapability = lockControlCapability;
39095
- exports.logBannerArgs = require_fmp4_box_splitter.logBannerArgs;
39398
+ exports.logBannerArgs = require_canonical_hash.logBannerArgs;
39096
39399
  exports.logDestinationCapability = logDestinationCapability;
39097
39400
  exports.logLevelAtMost = logLevelAtMost;
39098
39401
  exports.loginMethodCapability = loginMethodCapability;
@@ -39143,7 +39446,7 @@ exports.pickAccessoryControl = pickAccessoryControl;
39143
39446
  exports.pickDetailCropConvention = pickDetailCropConvention;
39144
39447
  exports.pickNativeLeaseOverride = pickNativeLeaseOverride;
39145
39448
  exports.pickPreferredRtspEntry = pickPreferredRtspEntry;
39146
- exports.pickVideoEncoder = require_fmp4_box_splitter.pickVideoEncoder;
39449
+ exports.pickVideoEncoder = require_canonical_hash.pickVideoEncoder;
39147
39450
  exports.pickerForCondition = pickerForCondition;
39148
39451
  exports.pipelineAnalyticsCapability = pipelineAnalyticsCapability;
39149
39452
  exports.pipelineExecutorCapability = pipelineExecutorCapability;