@camstack/types 1.2.46 → 1.2.47

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 (36) 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/index.d.ts +2 -2
  7. package/dist/capabilities/motion-detection.cap.d.ts +6 -1
  8. package/dist/capabilities/notification-rules.cap.d.ts +24 -24
  9. package/dist/capabilities/oauth-integration.cap.d.ts +4 -0
  10. package/dist/capabilities/osd-manager.cap.d.ts +12 -12
  11. package/dist/capabilities/pipeline-analytics.cap.d.ts +7 -1
  12. package/dist/capabilities/sso-bridge.cap.d.ts +3 -0
  13. package/dist/capabilities/stream-broker.cap.d.ts +1 -0
  14. package/dist/capabilities/user-management.cap.d.ts +3 -1
  15. package/dist/capabilities/videoclips.cap.d.ts +5 -0
  16. package/dist/device/declared-device.d.ts +197 -0
  17. package/dist/device/device-binding.d.ts +15 -9
  18. package/dist/device/device-management.d.ts +1 -83
  19. package/dist/device/index.d.ts +20 -19
  20. package/dist/expression/binding-source.d.ts +85 -0
  21. package/dist/expression/{link-expression.d.ts → expression-source.d.ts} +22 -13
  22. package/dist/expression/index.d.ts +17 -14
  23. package/dist/expression/limits.d.ts +1 -1
  24. package/dist/generated/addon-api.d.ts +7 -7
  25. package/dist/generated/device-proxy.d.ts +1 -1
  26. package/dist/generated/system-proxy.d.ts +1 -1
  27. package/dist/index.d.ts +9 -8
  28. package/dist/index.js +2322 -2057
  29. package/dist/index.mjs +2297 -2043
  30. package/dist/node.js +7 -7
  31. package/dist/node.mjs +1 -1
  32. package/dist/{sleep-BbYwFLG6.mjs → sleep-7WqNZVcL.mjs} +0 -1
  33. package/dist/{sleep-CyN9nHr_.js → sleep-ocMLM2o5.js} +0 -1
  34. package/package.json +1 -1
  35. package/dist/device/device-link-transform.d.ts +0 -5
  36. 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(),
@@ -25489,14 +24703,19 @@ var userManagementCapability = {
25489
24703
  username: zod.z.string(),
25490
24704
  scopes: zod.z.array(TokenScopeSchema),
25491
24705
  redirectUri: zod.z.string(),
25492
- hubUrl: zod.z.string()
24706
+ hubUrl: zod.z.string(),
24707
+ /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
24708
+ * that carries one can ONLY be exchanged with the matching verifier. */
24709
+ codeChallenge: zod.z.string().optional()
25493
24710
  }), zod.z.object({ code: zod.z.string() }), {
25494
24711
  kind: "mutation",
25495
24712
  access: "create"
25496
24713
  }),
25497
24714
  oauthExchangeCode: require_sleep.method(zod.z.object({
25498
24715
  code: zod.z.string(),
25499
- redirectUri: zod.z.string()
24716
+ redirectUri: zod.z.string(),
24717
+ /** PKCE verifier. REQUIRED when the code carries a challenge. */
24718
+ codeVerifier: zod.z.string().optional()
25500
24719
  }), zod.z.object({
25501
24720
  accessToken: zod.z.string(),
25502
24721
  refreshToken: zod.z.string(),
@@ -27387,6 +26606,218 @@ var BaseDeviceProvider = class extends require_sleep.BaseAddon {
27387
26606
  }
27388
26607
  };
27389
26608
  //#endregion
26609
+ //#region src/device/declared-device.ts
26610
+ /** Marker written to a declared integration's `info`. */
26611
+ var DECLARED_INTEGRATION_FIXED_KEY = "fixed";
26612
+ /**
26613
+ * Strip the `<node>/<addon>` suffix a forked child carries.
26614
+ *
26615
+ * Comparing `ctx.kernel.localNodeId` raw skipped EVERY node — including the one
26616
+ * that was supposed to act — because on the hub it reads `hub/<addon>`.
26617
+ */
26618
+ function declarationOwnerNodeId(localNodeId) {
26619
+ const raw = localNodeId ?? "hub";
26620
+ if (!raw.includes("/")) return raw;
26621
+ return raw.split("/")[0] ?? "hub";
26622
+ }
26623
+ /** Cap on how many rows one withdrawal pass may delete. */
26624
+ var DECLARED_DEVICE_SWEEP_LIMIT = 32;
26625
+ /**
26626
+ * The one way an addon owns a device it declares.
26627
+ *
26628
+ * Construct once with the addon's ports, then call {@link reconcile} on boot and
26629
+ * on every convergence tick. There is no second get-or-create helper — a guard
26630
+ * in `scripts/` enforces that.
26631
+ */
26632
+ var DeclaredDevices = class {
26633
+ ports;
26634
+ constructor(ports) {
26635
+ this.ports = ports;
26636
+ }
26637
+ /**
26638
+ * Converge the declared set. Idempotent, and safe to call repeatedly.
26639
+ *
26640
+ * Throws only what the ports throw on the FIRST index read; every other
26641
+ * failure is per-device and logged, so one bad declaration never takes the
26642
+ * others down.
26643
+ */
26644
+ async reconcile(spec) {
26645
+ if ((spec.placement ?? "hub") === "hub") {
26646
+ const nodeId = declarationOwnerNodeId(this.ports.localNodeId);
26647
+ if (nodeId !== "hub") {
26648
+ this.ports.logger.info("declared devices are hub-owned — skipping on this node", { meta: {
26649
+ nodeId,
26650
+ rawNodeId: this.ports.localNodeId ?? null
26651
+ } });
26652
+ return {
26653
+ integrationId: null,
26654
+ devices: [],
26655
+ removed: [],
26656
+ owned: false
26657
+ };
26658
+ }
26659
+ }
26660
+ const integrationId = await this.ensureIntegration(spec.integrationName);
26661
+ const index = await this.readIndex();
26662
+ const outcomes = [];
26663
+ for (const declaration of spec.devices) {
26664
+ const outcome = await this.applyDeclaration(declaration, integrationId, index);
26665
+ if (outcome !== null) outcomes.push(outcome);
26666
+ }
26667
+ return {
26668
+ integrationId,
26669
+ devices: outcomes,
26670
+ removed: await this.sweepWithdrawn(spec.devices, integrationId, index),
26671
+ owned: true
26672
+ };
26673
+ }
26674
+ /**
26675
+ * Get-or-create the FIXED integration, and RE-ASSERT the flag every pass.
26676
+ *
26677
+ * The re-assertion is the fix for the defect the hand-rolled version shipped
26678
+ * with: writing `info.fixed` only on the create path left every pre-existing
26679
+ * install without it, and the kernel kept offering to delete an integration
26680
+ * the addon owns.
26681
+ */
26682
+ async ensureIntegration(integrationName) {
26683
+ const existing = await this.ports.getIntegration(this.ports.addonId);
26684
+ if (existing === null) {
26685
+ const created = await this.ports.createIntegration({
26686
+ addonId: this.ports.addonId,
26687
+ name: integrationName,
26688
+ info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
26689
+ });
26690
+ this.ports.logger.info("declared a fixed integration", { meta: {
26691
+ integrationId: created.id,
26692
+ name: integrationName
26693
+ } });
26694
+ return created.id;
26695
+ }
26696
+ if (existing.info?.["fixed"] !== true) {
26697
+ await this.ports.updateIntegration({
26698
+ id: existing.id,
26699
+ info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
26700
+ });
26701
+ this.ports.logger.info("re-asserted `fixed` on a declared integration", { meta: { integrationId: existing.id } });
26702
+ }
26703
+ return existing.id;
26704
+ }
26705
+ async readIndex() {
26706
+ const rows = await this.ports.listOwnDevices();
26707
+ return new Map(rows.map((row) => [row.stableId, row]));
26708
+ }
26709
+ /**
26710
+ * One declaration: adopt what exists, create what does not.
26711
+ *
26712
+ * The create branch is the destructive one — it seeds `initialMeta`, and
26713
+ * `initialMeta.name` lands as an unconditional `setName`. A transiently empty
26714
+ * index therefore looks exactly like a first boot and would silently re-stamp
26715
+ * the declared name over the operator's rename. D49: that branch needs a
26716
+ * second read to agree.
26717
+ */
26718
+ async applyDeclaration(declaration, integrationId, index) {
26719
+ try {
26720
+ let existing = index.get(declaration.stableId);
26721
+ if (existing === void 0) {
26722
+ existing = (await this.readIndex()).get(declaration.stableId);
26723
+ if (existing !== void 0) this.ports.logger.warn("device index disagreed with itself — adopting instead of re-creating", {
26724
+ tags: { deviceId: existing.id },
26725
+ meta: {
26726
+ stableId: declaration.stableId,
26727
+ addonId: this.ports.addonId
26728
+ }
26729
+ });
26730
+ }
26731
+ if (existing !== void 0) {
26732
+ const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, {}, null, void 0);
26733
+ this.ports.logger.info("declared device adopted", {
26734
+ tags: { deviceId: device.id },
26735
+ meta: {
26736
+ stableId: declaration.stableId,
26737
+ integrationId
26738
+ }
26739
+ });
26740
+ return {
26741
+ stableId: declaration.stableId,
26742
+ deviceId: device.id,
26743
+ device,
26744
+ created: false
26745
+ };
26746
+ }
26747
+ const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, declaration.config ?? {}, null, {
26748
+ type: declaration.type,
26749
+ name: declaration.name,
26750
+ integrationId,
26751
+ ...declaration.role === void 0 ? {} : { role: declaration.role }
26752
+ });
26753
+ this.ports.logger.info("declared device created", {
26754
+ tags: { deviceId: device.id },
26755
+ meta: {
26756
+ stableId: declaration.stableId,
26757
+ integrationId
26758
+ }
26759
+ });
26760
+ return {
26761
+ stableId: declaration.stableId,
26762
+ deviceId: device.id,
26763
+ device,
26764
+ created: true
26765
+ };
26766
+ } catch (err) {
26767
+ this.ports.logger.warn("a declared device could not be brought up", { meta: {
26768
+ stableId: declaration.stableId,
26769
+ error: err instanceof Error ? err.message : String(err)
26770
+ } });
26771
+ return null;
26772
+ }
26773
+ }
26774
+ /**
26775
+ * Remove rows under the addon's FIXED integration whose declaration is gone.
26776
+ *
26777
+ * Bounded to that integration: a declared integration has no operator
26778
+ * add-flow, so every row under it got there by declaration. Devices this
26779
+ * addon owns OUTSIDE it (a provider's adopted devices) are never candidates.
26780
+ *
26781
+ * Bounded in count, and every deletion is logged with its `deviceId` — a
26782
+ * withdrawal that removes an operator-visible row silently is the failure
26783
+ * mode, not the removal itself.
26784
+ */
26785
+ async sweepWithdrawn(declarations, integrationId, index) {
26786
+ const declared = new Set(declarations.map((d) => d.stableId));
26787
+ const candidates = [...index.values()].filter((row) => row.integrationId === integrationId && !declared.has(row.stableId));
26788
+ if (candidates.length === 0) return [];
26789
+ if (candidates.length > 32) {
26790
+ this.ports.logger.warn("withdrawal sweep exceeded its bound — removing nothing", { meta: {
26791
+ integrationId,
26792
+ candidates: candidates.length,
26793
+ bound: 32
26794
+ } });
26795
+ return [];
26796
+ }
26797
+ const removed = [];
26798
+ for (const row of candidates) try {
26799
+ await this.ports.devices.remove(row.id);
26800
+ removed.push(row.id);
26801
+ this.ports.logger.info("declared device removed — its declaration was withdrawn", {
26802
+ tags: { deviceId: row.id },
26803
+ meta: {
26804
+ stableId: row.stableId,
26805
+ integrationId
26806
+ }
26807
+ });
26808
+ } catch (err) {
26809
+ this.ports.logger.warn("a withdrawn declared device could not be removed", {
26810
+ tags: { deviceId: row.id },
26811
+ meta: {
26812
+ stableId: row.stableId,
26813
+ error: err instanceof Error ? err.message : String(err)
26814
+ }
26815
+ });
26816
+ }
26817
+ return removed;
26818
+ }
26819
+ };
26820
+ //#endregion
27390
26821
  //#region src/device/device-control-resolution.ts
27391
26822
  /**
27392
26823
  * Device-control RESOLUTION LOGIC — the single, presentation-free source of
@@ -27563,30 +26994,6 @@ function resolveMutate(router, method) {
27563
26994
  return typeof mutate === "function" ? mutate : null;
27564
26995
  }
27565
26996
  //#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
26997
  //#region src/device/device-profile.ts
27591
26998
  /**
27592
26999
  * Heuristic check — `device.features?.includes(profile.when.hasFeature)`.
@@ -28115,501 +27522,1347 @@ var SystemMirror = class {
28115
27522
  const proxy = this.getDeviceById(deviceId);
28116
27523
  if (proxy) out.push(proxy);
28117
27524
  }
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));
27525
+ return out;
27526
+ }
27527
+ /** Map every device's slice for `capName` to a derived value. Devices
27528
+ * without the cap or without a slice are skipped. */
27529
+ mapState(capName, mapper) {
27530
+ const out = [];
27531
+ for (const [deviceId, perCap] of this.stateMirror) {
27532
+ const slice = perCap.get(capName);
27533
+ if (!slice) continue;
27534
+ out.push(mapper(slice, deviceId));
27535
+ }
27536
+ return out;
27537
+ }
27538
+ /** First device whose slice matches `predicate`, or null. */
27539
+ findState(capName, predicate) {
27540
+ for (const [deviceId, perCap] of this.stateMirror) {
27541
+ const slice = perCap.get(capName);
27542
+ if (!slice) continue;
27543
+ if (!predicate(slice, deviceId)) continue;
27544
+ return this.getDeviceById(deviceId);
27545
+ }
27546
+ return null;
27547
+ }
27548
+ /** Count devices that bind a cap. Faster than `filterByCap(...).length`. */
27549
+ countByCap(capName) {
27550
+ let n = 0;
27551
+ for (const binding of this.bindings.values()) if (binding.entries.some((e) => e.capName === capName)) n++;
27552
+ return n;
27553
+ }
27554
+ /** Count devices whose slice for `capName` matches `predicate`. */
27555
+ countByState(capName, predicate) {
27556
+ let n = 0;
27557
+ for (const [deviceId, perCap] of this.stateMirror) {
27558
+ const slice = perCap.get(capName);
27559
+ if (!slice) continue;
27560
+ if (predicate(slice, deviceId)) n++;
27561
+ }
27562
+ return n;
27563
+ }
27564
+ /**
27565
+ * Global listener — fires for every `device.state-changed` event the
27566
+ * mirror absorbs.
27567
+ */
27568
+ listen(cb) {
27569
+ this.globalStateListeners.add(cb);
27570
+ return () => {
27571
+ this.globalStateListeners.delete(cb);
27572
+ };
27573
+ }
27574
+ /**
27575
+ * Per-cap listener — fires only for state changes on `capName`,
27576
+ * across every device. The callback receives the deviceId so the
27577
+ * caller can route.
27578
+ */
27579
+ listenCap(capName, cb) {
27580
+ let set = this.capListeners.get(capName);
27581
+ if (!set) {
27582
+ set = /* @__PURE__ */ new Set();
27583
+ this.capListeners.set(capName, set);
27584
+ }
27585
+ set.add(cb);
27586
+ return () => {
27587
+ set.delete(cb);
27588
+ if (set.size === 0) this.capListeners.delete(capName);
27589
+ };
27590
+ }
27591
+ /**
27592
+ * Per-device listener — fires for every cap change on `deviceId`.
27593
+ */
27594
+ listenDevice(deviceId, cb) {
27595
+ let set = this.deviceListeners.get(deviceId);
27596
+ if (!set) {
27597
+ set = /* @__PURE__ */ new Set();
27598
+ this.deviceListeners.set(deviceId, set);
27599
+ }
27600
+ set.add(cb);
27601
+ return () => {
27602
+ set.delete(cb);
27603
+ if (set.size === 0) this.deviceListeners.delete(deviceId);
27604
+ };
27605
+ }
27606
+ /** Fires when `device.registered` lands. Receives the new metadata. */
27607
+ onDeviceAdded(cb) {
27608
+ this.addedListeners.add(cb);
27609
+ return () => {
27610
+ this.addedListeners.delete(cb);
27611
+ };
27612
+ }
27613
+ /** Fires when `device.unregistered` lands. `info` is the LAST-known
27614
+ * metadata (or null if the device was never seen). */
27615
+ onDeviceRemoved(cb) {
27616
+ this.removedListeners.add(cb);
27617
+ return () => {
27618
+ this.removedListeners.delete(cb);
27619
+ };
27620
+ }
27621
+ /**
27622
+ * Resolve when `predicate` over the runtime-state slice for
27623
+ * `(deviceId, capName)` becomes true. Resolves immediately if the
27624
+ * current slice already matches. Rejects with `Error('timeout')`
27625
+ * after `timeoutMs` (default 30s; pass `Infinity` to wait forever).
27626
+ *
27627
+ * Returns the matching slice — caller can read it directly without
27628
+ * a second mirror lookup.
27629
+ */
27630
+ waitForState(deviceId, capName, predicate, timeoutMs = 3e4) {
27631
+ return new Promise((resolve, reject) => {
27632
+ const check = () => {
27633
+ const slice = this.stateMirror.get(deviceId)?.get(capName);
27634
+ if (slice && predicate(slice)) return slice;
27635
+ return null;
27636
+ };
27637
+ const initial = check();
27638
+ if (initial) {
27639
+ resolve(initial);
27640
+ return;
27641
+ }
27642
+ let timer = null;
27643
+ const off = this.listenDevice(deviceId, (_id, cap, slice) => {
27644
+ if (cap !== capName) return;
27645
+ if (!slice) return;
27646
+ if (predicate(slice)) {
27647
+ if (timer) clearTimeout(timer);
27648
+ off();
27649
+ resolve(slice);
27650
+ }
27651
+ });
27652
+ if (Number.isFinite(timeoutMs)) timer = setTimeout(() => {
27653
+ off();
27654
+ reject(/* @__PURE__ */ new Error(`waitForState timed out after ${timeoutMs}ms (deviceId=${deviceId}, capName=${capName})`));
27655
+ }, timeoutMs);
27656
+ });
27657
+ }
27658
+ /**
27659
+ * Resolve when a device with `deviceId` becomes available (a
27660
+ * binding exists). Resolves immediately if already known. Rejects
27661
+ * with timeout.
27662
+ */
27663
+ waitForDevice(deviceId, timeoutMs = 3e4) {
27664
+ return new Promise((resolve, reject) => {
27665
+ const existing = this.getDeviceById(deviceId);
27666
+ if (existing) {
27667
+ resolve(existing);
27668
+ return;
27669
+ }
27670
+ let timer = null;
27671
+ const off = this.onDeviceAdded((id) => {
27672
+ if (id !== deviceId) return;
27673
+ const proxy = this.getDeviceById(id);
27674
+ if (!proxy) return;
27675
+ if (timer) clearTimeout(timer);
27676
+ off();
27677
+ resolve(proxy);
27678
+ });
27679
+ if (Number.isFinite(timeoutMs)) timer = setTimeout(() => {
27680
+ off();
27681
+ reject(/* @__PURE__ */ new Error(`waitForDevice timed out after ${timeoutMs}ms (deviceId=${deviceId})`));
27682
+ }, timeoutMs);
27683
+ });
27684
+ }
27685
+ /**
27686
+ * Iterate every device that binds `capName`. Awaits each callback
27687
+ * sequentially — for parallel use `invokeCap` with explicit
27688
+ * parallelism.
27689
+ */
27690
+ async forEachCap(capName, cb) {
27691
+ for (const proxy of this.filterByCap(capName)) await cb(proxy);
27692
+ }
27693
+ /**
27694
+ * Invoke a cap method on every device that binds the cap. Returns
27695
+ * one result per device, success or failure isolated. Optional
27696
+ * parallelism cap — useful for "snapshot all cameras but only 4
27697
+ * at a time so battery cams don't all wake at once".
27698
+ *
27699
+ * Example:
27700
+ *
27701
+ * const results = await sm.invokeCap('snapshot', 'getSnapshot', {}, { parallelism: 4 })
27702
+ * const failed = results.filter(r => !r.ok)
27703
+ */
27704
+ async invokeCap(capName, methodName, args, opts = {}) {
27705
+ const targets = this.filterByCap(capName);
27706
+ const parallelism = Math.max(1, opts.parallelism ?? targets.length);
27707
+ const out = [];
27708
+ for (let i = 0; i < targets.length; i += parallelism) {
27709
+ const chunk = targets.slice(i, i + parallelism);
27710
+ const settled = await Promise.allSettled(chunk.map(async (proxy) => {
27711
+ const cap = proxy[capName];
27712
+ if (!cap || typeof cap[methodName] !== "function") throw new Error(`device '${proxy.deviceId}' does not expose '${capName}.${methodName}'`);
27713
+ return await cap[methodName](args);
27714
+ }));
27715
+ for (let j = 0; j < settled.length; j++) {
27716
+ const proxy = chunk[j];
27717
+ const r = settled[j];
27718
+ if (r.status === "fulfilled") out.push({
27719
+ deviceId: proxy.deviceId,
27720
+ ok: true,
27721
+ result: r.value
27722
+ });
27723
+ else out.push({
27724
+ deviceId: proxy.deviceId,
27725
+ ok: false,
27726
+ error: r.reason
27727
+ });
27728
+ }
27729
+ }
27730
+ return out;
27731
+ }
27732
+ /**
27733
+ * One-shot summary — fleet size, breakdown by cap / addon / type.
27734
+ * Designed for REPL inspection (`sm.summary()`).
27735
+ */
27736
+ summary() {
27737
+ const byCap = {};
27738
+ const byAddon = {};
27739
+ const byType = {};
27740
+ let online = 0;
27741
+ let offline = 0;
27742
+ for (const binding of this.bindings.values()) for (const entry of binding.entries) byCap[entry.capName] = (byCap[entry.capName] ?? 0) + 1;
27743
+ for (const info of this.devices.values()) {
27744
+ byAddon[info.addonId] = (byAddon[info.addonId] ?? 0) + 1;
27745
+ byType[info.type] = (byType[info.type] ?? 0) + 1;
27746
+ if (info.online) online++;
27747
+ else offline++;
27748
+ }
27749
+ return {
27750
+ totalDevices: this.bindings.size,
27751
+ online,
27752
+ offline,
27753
+ byCap,
27754
+ byAddon,
27755
+ byType,
27756
+ statedDevices: this.stateMirror.size
27757
+ };
27758
+ }
27759
+ /**
27760
+ * Debug-friendly dump — full state + binding + metadata for one
27761
+ * device or all devices. Cheap deep clone so caller mutations don't
27762
+ * leak into the mirror.
27763
+ */
27764
+ dump(deviceId) {
27765
+ const dumpOne = (id) => {
27766
+ const info = this.devices.get(id) ?? null;
27767
+ const binding = this.bindings.get(id) ?? null;
27768
+ const state = {};
27769
+ const perCap = this.stateMirror.get(id);
27770
+ if (perCap) for (const [cap, slice] of perCap) state[cap] = { ...slice };
27771
+ return {
27772
+ deviceId: id,
27773
+ info,
27774
+ binding: binding ? {
27775
+ ...binding,
27776
+ entries: binding.entries.map((e) => ({ ...e }))
27777
+ } : null,
27778
+ state
27779
+ };
27780
+ };
27781
+ if (deviceId !== void 0) return dumpOne(deviceId);
27782
+ const out = [];
27783
+ for (const id of this.bindings.keys()) out.push(dumpOne(id));
27784
+ return out;
27785
+ }
27786
+ /**
27787
+ * Direct read-only access to the underlying state mirror. Use
27788
+ * sparingly — `getSystemState` returns a deep copy that's safer for
27789
+ * exploratory work; this avoids the clone cost when iterating
27790
+ * thousands of slices.
27791
+ */
27792
+ getRawMirror() {
27793
+ return this.stateMirror;
27794
+ }
27795
+ /**
27796
+ * Snapshot of the full state mirror — same shape as the warm-boot
27797
+ * payload. Deep-cloned; safe to mutate.
27798
+ */
27799
+ getSystemState() {
27800
+ const copy = /* @__PURE__ */ new Map();
27801
+ for (const [id, perCap] of this.stateMirror) {
27802
+ const dup = /* @__PURE__ */ new Map();
27803
+ for (const [k, v] of perCap) dup.set(k, { ...v });
27804
+ copy.set(id, dup);
27805
+ }
27806
+ return copy;
27807
+ }
27808
+ subscribeBus() {
27809
+ if (!this.api.live?.onEvent) return;
27810
+ const sub = this.api.live.onEvent;
27811
+ this.bridges.push(sub.subscribe({ category: STATE_CHANGED_CATEGORY }, { onData: (evt) => {
27812
+ const data = evt.data;
27813
+ if (!data || typeof data.deviceId !== "number" || typeof data.capName !== "string") return;
27814
+ this.applyStateUpdate(data.deviceId, data.capName, data.slice);
27815
+ } }));
27816
+ this.bridges.push(sub.subscribe({ category: BINDING_CHANGED_CATEGORY }, { onData: (evt) => {
27817
+ const data = evt.data;
27818
+ const deviceId = typeof data?.deviceId === "number" ? data.deviceId : data?.source?.type === "device" && typeof data.source.id === "number" ? data.source.id : null;
27819
+ if (deviceId === null) return;
27820
+ this.refreshBinding(deviceId);
27821
+ } }));
27822
+ this.bridges.push(sub.subscribe({ category: DEVICE_REGISTERED_CATEGORY }, { onData: (evt) => {
27823
+ const data = evt.data;
27824
+ if (typeof data?.deviceId !== "number") return;
27825
+ this.refreshDeviceMetadata(data.deviceId, "added");
27826
+ } }));
27827
+ this.bridges.push(sub.subscribe({ category: DEVICE_UNREGISTERED_CATEGORY }, { onData: (evt) => {
27828
+ const data = evt.data;
27829
+ if (typeof data?.deviceId !== "number") return;
27830
+ this.applyDeviceRemoval(data.deviceId);
27831
+ } }));
27832
+ this.bridges.push(sub.subscribe({ category: DEVICE_UPDATED_CATEGORY }, { onData: (evt) => {
27833
+ const data = evt.data;
27834
+ if (typeof data?.deviceId !== "number") return;
27835
+ this.refreshDeviceMetadata(data.deviceId, "updated");
27836
+ } }));
27837
+ }
27838
+ applyStateUpdate(deviceId, capName, slice) {
27839
+ let perCap = this.stateMirror.get(deviceId);
27840
+ if (!perCap) {
27841
+ perCap = /* @__PURE__ */ new Map();
27842
+ this.stateMirror.set(deviceId, perCap);
27843
+ }
27844
+ if (slice === void 0) perCap.delete(capName);
27845
+ else perCap.set(capName, slice);
27846
+ const handleKey = `${deviceId}:${capName}`;
27847
+ const handleSet = this.handleListeners.get(handleKey);
27848
+ if (handleSet) for (const cb of handleSet) try {
27849
+ cb(slice);
27850
+ } catch {}
27851
+ for (const cb of this.globalStateListeners) try {
27852
+ cb(deviceId, capName, slice);
27853
+ } catch {}
27854
+ const capSet = this.capListeners.get(capName);
27855
+ if (capSet) for (const cb of capSet) try {
27856
+ cb(deviceId, slice);
27857
+ } catch {}
27858
+ const devSet = this.deviceListeners.get(deviceId);
27859
+ if (devSet) for (const cb of devSet) try {
27860
+ cb(deviceId, capName, slice);
27861
+ } catch {}
27862
+ }
27863
+ applyDeviceRemoval(deviceId) {
27864
+ const lastInfo = this.devices.get(deviceId) ?? null;
27865
+ this.bindings.delete(deviceId);
27866
+ this.devices.delete(deviceId);
27867
+ this.stateMirror.delete(deviceId);
27868
+ for (const cb of this.removedListeners) try {
27869
+ cb(deviceId, lastInfo);
27870
+ } catch {}
27871
+ }
27872
+ async refreshBinding(deviceId) {
27873
+ try {
27874
+ const fresh = (await this.api.deviceManager.getAllBindings.query({})).find((b) => b.deviceId === deviceId);
27875
+ if (fresh) this.bindings.set(deviceId, fresh);
27876
+ else this.applyDeviceRemoval(deviceId);
27877
+ } catch {}
27878
+ }
27879
+ async refreshDeviceMetadata(deviceId, kind) {
27880
+ try {
27881
+ const info = await this.api.deviceManager.getDevice.query({ deviceId });
27882
+ if (!info) return;
27883
+ const wasNew = !this.devices.has(deviceId);
27884
+ this.devices.set(deviceId, info);
27885
+ if (kind === "added" && wasNew) {
27886
+ await this.refreshBinding(deviceId);
27887
+ for (const cb of this.addedListeners) try {
27888
+ cb(deviceId, info);
27889
+ } catch {}
27890
+ }
27891
+ } catch {}
27892
+ }
27893
+ };
27894
+ function inSet(value, set) {
27895
+ if (Array.isArray(set)) return set.includes(value);
27896
+ return value === set;
27897
+ }
27898
+ function toArray(value) {
27899
+ return Array.isArray(value) ? value : [value];
27900
+ }
27901
+ function matchesString(haystack, match) {
27902
+ if (typeof match === "string") return haystack === match;
27903
+ if (match instanceof RegExp) return match.test(haystack);
27904
+ if ("exact" in match) return haystack === match.exact;
27905
+ if ("contains" in match) return haystack.toLowerCase().includes(match.contains.toLowerCase());
27906
+ return false;
27907
+ }
27908
+ //#endregion
27909
+ //#region src/device/zod-to-config-ui.ts
27910
+ /** Access Zod v4 internal .def — not in public typings but stable at runtime */
27911
+ function zodDef(schema) {
27912
+ return schema.def;
27913
+ }
27914
+ /** Access internal properties on a Zod schema instance */
27915
+ function zodInternals(schema) {
27916
+ return schema;
27917
+ }
27918
+ /**
27919
+ * Convert DeviceConfig.entries() output to ConfigUISchema for the admin UI FormBuilder.
27920
+ *
27921
+ * Each entry's Zod type is inspected to determine the correct ConfigField type:
27922
+ * - ZodString → 'text' (or 'password' when key contains "password"/"secret"/"token"/"apikey")
27923
+ * - ZodNumber → 'number' (extracts min/max/step from Zod v4 checks)
27924
+ * - ZodBoolean → 'boolean'
27925
+ * - ZodEnum → 'select' (options built from enum values)
27926
+ * - Anything else → 'text' fallback
27927
+ *
27928
+ * Wrapper types ZodDefault, ZodOptional, and ZodNullable are unwrapped transparently.
27929
+ * Default values are extracted from ZodDefault wrappers.
27930
+ */
27931
+ function zodEntriesToConfigUI(entries, sectionTitle = "Configuration", sectionId = "main") {
27932
+ return { sections: [{
27933
+ id: sectionId,
27934
+ title: sectionTitle,
27935
+ fields: entries.map((entry) => zodToConfigField(entry.key, entry.schema, entry.description))
27936
+ }] };
27937
+ }
27938
+ function zodToConfigField(key, schema, description) {
27939
+ const inner = unwrapZod(schema);
27940
+ const defaultValue = getZodDefault(schema);
27941
+ const base = {
27942
+ key,
27943
+ label: description ?? humanizeKey(key),
27944
+ description,
27945
+ default: defaultValue
27946
+ };
27947
+ if (inner instanceof zod.z.ZodString) return buildStringField(key, base);
27948
+ if (inner instanceof zod.z.ZodNumber) return buildNumberField(inner, base);
27949
+ if (inner instanceof zod.z.ZodBoolean) return {
27950
+ ...base,
27951
+ type: "boolean"
27952
+ };
27953
+ if (inner instanceof zod.z.ZodEnum) return buildEnumField(inner, base);
27954
+ if (inner instanceof zod.z.ZodArray || inner instanceof zod.z.ZodObject) return {
27955
+ ...base,
27956
+ type: "textarea",
27957
+ rows: 6,
27958
+ isJson: true
27959
+ };
27960
+ return {
27961
+ ...base,
27962
+ type: "text"
27963
+ };
27964
+ }
27965
+ function buildStringField(key, base) {
27966
+ const lowerKey = key.toLowerCase();
27967
+ if (lowerKey.includes("password") || lowerKey.includes("secret") || lowerKey.includes("token") || lowerKey.includes("apikey") || lowerKey.includes("api_key")) return {
27968
+ ...base,
27969
+ type: "password",
27970
+ showToggle: true
27971
+ };
27972
+ return {
27973
+ ...base,
27974
+ type: "text"
27975
+ };
27976
+ }
27977
+ function buildNumberField(inner, base) {
27978
+ const anyInner = zodInternals(inner);
27979
+ const rawMin = anyInner.minValue;
27980
+ const rawMax = anyInner.maxValue;
27981
+ const min = rawMin != null && isFinite(rawMin) ? rawMin : void 0;
27982
+ const max = rawMax != null && isFinite(rawMax) ? rawMax : void 0;
27983
+ const step = getMultipleOfStep(inner);
27984
+ return {
27985
+ ...base,
27986
+ type: "number",
27987
+ ...min !== void 0 ? { min } : {},
27988
+ ...max !== void 0 ? { max } : {},
27989
+ ...step !== void 0 ? { step } : {}
27990
+ };
27991
+ }
27992
+ function getMultipleOfStep(inner) {
27993
+ const checks = zodDef(inner).checks ?? [];
27994
+ for (const check of checks) if (check._zod?.def?.check === "multiple_of" && check._zod.def.value !== void 0) return check._zod.def.value;
27995
+ }
27996
+ function buildEnumField(inner, base) {
27997
+ const values = inner.options.map((v) => String(v));
27998
+ return {
27999
+ ...base,
28000
+ type: "select",
28001
+ options: values.map((v) => ({
28002
+ label: humanizeKey(v),
28003
+ value: v
28004
+ }))
28005
+ };
28006
+ }
28007
+ function unwrapZod(schema) {
28008
+ if (schema instanceof zod.z.ZodDefault) return unwrapZod(zodDef(schema).innerType);
28009
+ if (schema instanceof zod.z.ZodOptional) return unwrapZod(zodDef(schema).innerType);
28010
+ if (schema instanceof zod.z.ZodNullable) return unwrapZod(zodDef(schema).innerType);
28011
+ return schema;
28012
+ }
28013
+ function getZodDefault(schema) {
28014
+ if (schema instanceof zod.z.ZodDefault) return zodDef(schema).defaultValue;
28015
+ }
28016
+ function humanizeKey(key) {
28017
+ return key.replace(/([A-Z])/g, " $1").replace(/[_-]/g, " ").replace(/^\w/, (c) => c.toUpperCase()).trim();
28018
+ }
28019
+ //#endregion
28020
+ //#region src/expression/errors.ts
28021
+ /**
28022
+ * Error types for the safe expression engine. Two distinct classes so callers
28023
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
28024
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
28025
+ */
28026
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
28027
+ * the failure is anchored to a character (author-facing inline feedback). */
28028
+ var ExpressionParseError = class extends Error {
28029
+ position;
28030
+ constructor(message, position) {
28031
+ super(message);
28032
+ this.name = "ExpressionParseError";
28033
+ this.position = position;
28034
+ }
28035
+ };
28036
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
28037
+ * result, unknown builtin, step-budget exceeded). */
28038
+ var ExpressionEvalError = class extends Error {
28039
+ constructor(message) {
28040
+ super(message);
28041
+ this.name = "ExpressionEvalError";
28042
+ }
28043
+ };
28044
+ //#endregion
28045
+ //#region src/expression/builtins.ts
28046
+ /**
28047
+ * Frozen, null-prototype builtin function table for the expression engine
28048
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
28049
+ * parser rejects any callee not in it, and the evaluator gates each call on an
28050
+ * own-property check against it.
28051
+ *
28052
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
28053
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
28054
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
28055
+ * (there is no `Object.prototype` in the chain), so those names are not
28056
+ * callable — they are simply "unknown function" at parse time.
28057
+ *
28058
+ * Every numeric argument is validated as a finite number and every numeric
28059
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
28060
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
28061
+ * closed rather than emitting a garbage value.
28062
+ */
28063
+ function asFiniteNumber(value, name, index) {
28064
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
28065
+ return value;
28066
+ }
28067
+ function asString$1(value, name, index) {
28068
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
28069
+ return value;
28070
+ }
28071
+ function finiteResult(value, name) {
28072
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
28073
+ return value;
28074
+ }
28075
+ function allFiniteNumbers(args, name) {
28076
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
28077
+ }
28078
+ var INF = Number.POSITIVE_INFINITY;
28079
+ var table = {
28080
+ min: {
28081
+ minArgs: 1,
28082
+ maxArgs: INF,
28083
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
28084
+ },
28085
+ max: {
28086
+ minArgs: 1,
28087
+ maxArgs: INF,
28088
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
28089
+ },
28090
+ abs: {
28091
+ minArgs: 1,
28092
+ maxArgs: 1,
28093
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
28094
+ },
28095
+ floor: {
28096
+ minArgs: 1,
28097
+ maxArgs: 1,
28098
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
28099
+ },
28100
+ ceil: {
28101
+ minArgs: 1,
28102
+ maxArgs: 1,
28103
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
28104
+ },
28105
+ sqrt: {
28106
+ minArgs: 1,
28107
+ maxArgs: 1,
28108
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
28109
+ },
28110
+ round: {
28111
+ minArgs: 1,
28112
+ maxArgs: 2,
28113
+ apply: (args) => {
28114
+ const x = asFiniteNumber(args[0], "round", 0);
28115
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
28116
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
28117
+ const factor = 10 ** digits;
28118
+ return finiteResult(Math.round(x * factor) / factor, "round");
28119
+ }
28120
+ },
28121
+ pow: {
28122
+ minArgs: 2,
28123
+ maxArgs: 2,
28124
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
28125
+ },
28126
+ clamp: {
28127
+ minArgs: 3,
28128
+ maxArgs: 3,
28129
+ apply: (args) => {
28130
+ const x = asFiniteNumber(args[0], "clamp", 0);
28131
+ const lo = asFiniteNumber(args[1], "clamp", 1);
28132
+ const hi = asFiniteNumber(args[2], "clamp", 2);
28133
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
28134
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
28135
+ }
28136
+ },
28137
+ avg: {
28138
+ minArgs: 1,
28139
+ maxArgs: INF,
28140
+ apply: (args) => {
28141
+ const nums = allFiniteNumbers(args, "avg");
28142
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
28143
+ }
28144
+ },
28145
+ sum: {
28146
+ minArgs: 1,
28147
+ maxArgs: INF,
28148
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
28149
+ },
28150
+ coalesce: {
28151
+ minArgs: 1,
28152
+ maxArgs: INF,
28153
+ apply: (args) => {
28154
+ for (const a of args) if (a !== null) return a;
28155
+ return null;
28156
+ }
28157
+ },
28158
+ age: {
28159
+ minArgs: 2,
28160
+ maxArgs: 2,
28161
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
28162
+ },
28163
+ convert: {
28164
+ minArgs: 3,
28165
+ maxArgs: 3,
28166
+ apply: (args, hooks) => {
28167
+ const x = asFiniteNumber(args[0], "convert", 0);
28168
+ const from = asString$1(args[1], "convert", 1).trim();
28169
+ const to = asString$1(args[2], "convert", 2).trim();
28170
+ if (hooks.convert) {
28171
+ const out = hooks.convert(x, from, to);
28172
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
28173
+ return finiteResult(out, "convert");
28174
+ }
28175
+ if (from === to) return x;
28176
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
28177
+ }
28178
+ }
28179
+ };
28180
+ /** Frozen, null-prototype builtin table. */
28181
+ var EXPRESSION_BUILTINS = Object.freeze(Object.assign(Object.create(null), table));
28182
+ /** The set of valid builtin names — used by the parser to reject unknown
28183
+ * callees at parse time (immediate author feedback). */
28184
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
28185
+ //#endregion
28186
+ //#region src/expression/limits.ts
28187
+ /**
28188
+ * Resource-bound constants for the safe expression engine.
28189
+ *
28190
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
28191
+ * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
28192
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
28193
+ * work a single author-supplied expression can request, so a hostile or
28194
+ * accidental pathological string can never spend unbounded CPU/memory.
28195
+ */
28196
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
28197
+ * rejected without allocation. */
28198
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
28199
+ /** Max AST nodes — checked during parse; a deeply nested grouping that exceeds
28200
+ * this is rejected as "expression too complex". */
28201
+ var MAX_EXPRESSION_AST_NODES = 256;
28202
+ /** Defense-in-depth walker step budget — one increment per node visit during
28203
+ * evaluation. The grammar guarantees O(nodeCount) walks, so this can only trip
28204
+ * on a crafted maximum-size AST. */
28205
+ var MAX_EXPRESSION_EVAL_STEPS = 4096;
28206
+ /** Max named bindings on one {@link ExpressionSource}. */
28207
+ var MAX_EXPRESSION_BINDINGS = 32;
28208
+ /** Max positional arguments to any builtin call. */
28209
+ var MAX_EXPRESSION_CALL_ARGS = 16;
28210
+ /** LRU compile-cache capacity (parsed ASTs keyed by raw source string). */
28211
+ var EXPRESSION_COMPILE_CACHE_CAPACITY = 256;
28212
+ /** A legal binding / identifier name. */
28213
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
28214
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
28215
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
28216
+ var RESERVED_BINDING_NAMES = new Set([
28217
+ "now",
28218
+ "true",
28219
+ "false",
28220
+ "null"
28221
+ ]);
28222
+ //#endregion
28223
+ //#region src/expression/tokenizer.ts
28224
+ /**
28225
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
28226
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
28227
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
28228
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
28229
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
28230
+ * is a parse error with a source position, so member access / assignment /
28231
+ * template literals are lexically impossible.
28232
+ */
28233
+ var KEYWORDS = new Set([
28234
+ "true",
28235
+ "false",
28236
+ "null"
28237
+ ]);
28238
+ function isDigit(ch) {
28239
+ return ch >= "0" && ch <= "9";
28240
+ }
28241
+ function isIdentStart(ch) {
28242
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
28243
+ }
28244
+ function isIdentPart(ch) {
28245
+ return isIdentStart(ch) || isDigit(ch);
28246
+ }
28247
+ function isWhitespace(ch) {
28248
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
28249
+ }
28250
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
28251
+ * Throws `ExpressionParseError` on any illegal character or unterminated
28252
+ * string. */
28253
+ function tokenize(source) {
28254
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
28255
+ const tokens = [];
28256
+ let i = 0;
28257
+ const n = source.length;
28258
+ while (i < n) {
28259
+ const ch = source[i];
28260
+ if (isWhitespace(ch)) {
28261
+ i += 1;
28262
+ continue;
28263
+ }
28264
+ if (isDigit(ch)) {
28265
+ const start = i;
28266
+ while (i < n && isDigit(source[i])) i += 1;
28267
+ if (i < n && source[i] === ".") {
28268
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
28269
+ i += 1;
28270
+ while (i < n && isDigit(source[i])) i += 1;
28271
+ }
28272
+ const text = source.slice(start, i);
28273
+ const value = Number(text);
28274
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
28275
+ tokens.push({
28276
+ type: "number",
28277
+ value,
28278
+ pos: start
28279
+ });
28280
+ continue;
28128
28281
  }
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);
28282
+ if (ch === "'" || ch === "\"") {
28283
+ const quote = ch;
28284
+ const start = i;
28285
+ i += 1;
28286
+ let out = "";
28287
+ let closed = false;
28288
+ while (i < n) {
28289
+ const c = source[i];
28290
+ if (c === "\\") {
28291
+ const next = i + 1 < n ? source[i + 1] : "";
28292
+ if (next === "\\" || next === "'" || next === "\"") {
28293
+ out += next;
28294
+ i += 2;
28295
+ continue;
28296
+ }
28297
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
28298
+ }
28299
+ if (c === quote) {
28300
+ closed = true;
28301
+ i += 1;
28302
+ break;
28303
+ }
28304
+ out += c;
28305
+ i += 1;
28306
+ }
28307
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
28308
+ tokens.push({
28309
+ type: "string",
28310
+ value: out,
28311
+ pos: start
28312
+ });
28313
+ continue;
28138
28314
  }
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++;
28315
+ if (isIdentStart(ch)) {
28316
+ const start = i;
28317
+ while (i < n && isIdentPart(source[i])) i += 1;
28318
+ const text = source.slice(start, i);
28319
+ if (KEYWORDS.has(text)) tokens.push({
28320
+ type: "keyword",
28321
+ keyword: keywordOf(text),
28322
+ pos: start
28323
+ });
28324
+ else tokens.push({
28325
+ type: "identifier",
28326
+ name: text,
28327
+ pos: start
28328
+ });
28329
+ continue;
28154
28330
  }
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);
28331
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
28332
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
28333
+ tokens.push({
28334
+ type: "punct",
28335
+ punct: two,
28336
+ pos: i
28337
+ });
28338
+ i += 2;
28339
+ continue;
28177
28340
  }
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);
28341
+ if (isSinglePunct(ch)) {
28342
+ tokens.push({
28343
+ type: "punct",
28344
+ punct: ch,
28345
+ pos: i
28346
+ });
28347
+ i += 1;
28348
+ continue;
28192
28349
  }
28193
- set.add(cb);
28194
- return () => {
28195
- set.delete(cb);
28196
- if (set.size === 0) this.deviceListeners.delete(deviceId);
28197
- };
28350
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
28198
28351
  }
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
- };
28352
+ tokens.push({
28353
+ type: "eof",
28354
+ pos: n
28355
+ });
28356
+ return tokens;
28357
+ }
28358
+ function keywordOf(text) {
28359
+ if (text === "true") return "true";
28360
+ if (text === "false") return "false";
28361
+ return "null";
28362
+ }
28363
+ function isSinglePunct(ch) {
28364
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
28365
+ }
28366
+ //#endregion
28367
+ //#region src/expression/parser.ts
28368
+ /**
28369
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
28370
+ *
28371
+ * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
28372
+ * → relational → additive → multiplicative → unary `! -` → call / primary.
28373
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
28374
+ * string validated against the builtin table at parse time, so an unknown
28375
+ * function is rejected immediately (author feedback) and a persisted expression
28376
+ * that references a since-removed builtin degrades at read.
28377
+ *
28378
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
28379
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
28380
+ */
28381
+ /** Binary/logical operator precedence (higher binds tighter). */
28382
+ var BINARY_PRECEDENCE = {
28383
+ "||": 1,
28384
+ "&&": 2,
28385
+ "==": 3,
28386
+ "!=": 3,
28387
+ "<": 4,
28388
+ "<=": 4,
28389
+ ">": 4,
28390
+ ">=": 4,
28391
+ "+": 5,
28392
+ "-": 5,
28393
+ "*": 6,
28394
+ "/": 6,
28395
+ "%": 6
28396
+ };
28397
+ function isLogicalOp(op) {
28398
+ return op === "&&" || op === "||";
28399
+ }
28400
+ function isBinaryOp(op) {
28401
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
28402
+ }
28403
+ var Parser = class {
28404
+ tokens;
28405
+ pos = 0;
28406
+ nodeCount = 0;
28407
+ identifiers = /* @__PURE__ */ new Set();
28408
+ callees = /* @__PURE__ */ new Set();
28409
+ constructor(tokens) {
28410
+ this.tokens = tokens;
28205
28411
  }
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);
28412
+ parse() {
28413
+ const ast = this.parseTernary();
28414
+ const tok = this.peek();
28415
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
28416
+ return {
28417
+ ast,
28418
+ identifiers: this.identifiers,
28419
+ callees: this.callees,
28420
+ nodeCount: this.nodeCount
28212
28421
  };
28213
28422
  }
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
- });
28423
+ peek() {
28424
+ return this.tokens[this.pos];
28250
28425
  }
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
- });
28426
+ next() {
28427
+ return this.tokens[this.pos++];
28277
28428
  }
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);
28429
+ /** Consume a punctuator token, erroring if the next token isn't it. */
28430
+ expectPunct(punct) {
28431
+ const tok = this.peek();
28432
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
28433
+ this.pos += 1;
28285
28434
  }
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
- }
28435
+ matchPunct(punct) {
28436
+ const tok = this.peek();
28437
+ if (tok.type === "punct" && tok.punct === punct) {
28438
+ this.pos += 1;
28439
+ return true;
28322
28440
  }
28323
- return out;
28441
+ return false;
28324
28442
  }
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
- };
28443
+ countNode() {
28444
+ this.nodeCount += 1;
28445
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
28351
28446
  }
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 };
28447
+ parseTernary() {
28448
+ const test = this.parseBinary(1);
28449
+ if (this.matchPunct("?")) {
28450
+ const consequent = this.parseTernary();
28451
+ this.expectPunct(":");
28452
+ const alternate = this.parseTernary();
28453
+ this.countNode();
28364
28454
  return {
28365
- deviceId: id,
28366
- info,
28367
- binding: binding ? {
28368
- ...binding,
28369
- entries: binding.entries.map((e) => ({ ...e }))
28370
- } : null,
28371
- state
28455
+ kind: "conditional",
28456
+ test,
28457
+ consequent,
28458
+ alternate
28372
28459
  };
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;
28460
+ }
28461
+ return test;
28387
28462
  }
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);
28463
+ parseBinary(minPrec) {
28464
+ let left = this.parseUnary();
28465
+ for (;;) {
28466
+ const tok = this.peek();
28467
+ if (tok.type !== "punct") break;
28468
+ const prec = BINARY_PRECEDENCE[tok.punct];
28469
+ if (prec === void 0 || prec < minPrec) break;
28470
+ const op = tok.punct;
28471
+ this.pos += 1;
28472
+ const right = this.parseBinary(prec + 1);
28473
+ this.countNode();
28474
+ if (isLogicalOp(op)) left = {
28475
+ kind: "logical",
28476
+ op,
28477
+ left,
28478
+ right
28479
+ };
28480
+ else if (isBinaryOp(op)) left = {
28481
+ kind: "binary",
28482
+ op,
28483
+ left,
28484
+ right
28485
+ };
28486
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
28487
+ }
28488
+ return left;
28489
+ }
28490
+ parseUnary() {
28491
+ const tok = this.peek();
28492
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
28493
+ const op = tok.punct;
28494
+ this.pos += 1;
28495
+ const operand = this.parseUnary();
28496
+ this.countNode();
28497
+ return {
28498
+ kind: "unary",
28499
+ op,
28500
+ operand
28501
+ };
28398
28502
  }
28399
- return copy;
28503
+ return this.parsePrimary();
28400
28504
  }
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
- } }));
28505
+ parsePrimary() {
28506
+ const tok = this.next();
28507
+ switch (tok.type) {
28508
+ case "number":
28509
+ this.countNode();
28510
+ return {
28511
+ kind: "literal",
28512
+ value: tok.value
28513
+ };
28514
+ case "string":
28515
+ this.countNode();
28516
+ return {
28517
+ kind: "literal",
28518
+ value: tok.value
28519
+ };
28520
+ case "keyword":
28521
+ this.countNode();
28522
+ return {
28523
+ kind: "literal",
28524
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
28525
+ };
28526
+ case "identifier": {
28527
+ const nextTok = this.peek();
28528
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
28529
+ this.identifiers.add(tok.name);
28530
+ this.countNode();
28531
+ return {
28532
+ kind: "identifier",
28533
+ name: tok.name
28534
+ };
28535
+ }
28536
+ case "punct":
28537
+ if (tok.punct === "(") {
28538
+ const inner = this.parseTernary();
28539
+ this.expectPunct(")");
28540
+ return inner;
28541
+ }
28542
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
28543
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
28544
+ }
28430
28545
  }
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);
28546
+ parseCall(callee, pos) {
28547
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
28548
+ this.expectPunct("(");
28549
+ const args = [];
28550
+ if (!this.matchPunct(")")) for (;;) {
28551
+ args.push(this.parseTernary());
28552
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
28553
+ if (this.matchPunct(",")) continue;
28554
+ this.expectPunct(")");
28555
+ break;
28436
28556
  }
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 {}
28557
+ this.callees.add(callee);
28558
+ this.countNode();
28559
+ return {
28560
+ kind: "call",
28561
+ callee,
28562
+ args
28563
+ };
28455
28564
  }
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 {}
28565
+ };
28566
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
28567
+ * `ExpressionParseError` on any lexical or grammatical failure. */
28568
+ function parseExpression(source) {
28569
+ return new Parser(tokenize(source)).parse();
28570
+ }
28571
+ //#endregion
28572
+ //#region src/expression/compile.ts
28573
+ /**
28574
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
28575
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
28576
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
28577
+ * one per read on a hot resolve path.
28578
+ *
28579
+ * The cache is a module-level singleton: entries are pure, content-addressed
28580
+ * ASTs keyed by the raw source string, so sharing one instance across all
28581
+ * callers is safe and maximises hit rate.
28582
+ */
28583
+ var cache = /* @__PURE__ */ new Map();
28584
+ function getCached(source) {
28585
+ const hit = cache.get(source);
28586
+ if (hit !== void 0) {
28587
+ cache.delete(source);
28588
+ cache.set(source, hit);
28589
+ return hit;
28464
28590
  }
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 {}
28591
+ let result;
28592
+ try {
28593
+ result = {
28594
+ ok: true,
28595
+ parsed: parseExpression(source)
28596
+ };
28597
+ } catch (err) {
28598
+ result = {
28599
+ ok: false,
28600
+ error: err instanceof ExpressionParseError ? err.message : String(err)
28601
+ };
28471
28602
  }
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 {}
28603
+ cache.set(source, result);
28604
+ if (cache.size > 256) {
28605
+ const oldest = cache.keys().next().value;
28606
+ if (oldest !== void 0) cache.delete(oldest);
28485
28607
  }
28486
- };
28487
- function inSet(value, set) {
28488
- if (Array.isArray(set)) return set.includes(value);
28489
- return value === set;
28608
+ return result;
28490
28609
  }
28491
- function toArray(value) {
28492
- return Array.isArray(value) ? value : [value];
28610
+ /** Compile `source` to a `ParsedExpression`, throwing `ExpressionParseError`
28611
+ * on failure. LRU/negative-cached. */
28612
+ function compileExpression(source) {
28613
+ const result = getCached(source);
28614
+ if (result.ok) return result.parsed;
28615
+ throw new ExpressionParseError(result.error);
28493
28616
  }
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;
28617
+ /** Compile `source`, returning a discriminated result instead of throwing.
28618
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
28619
+ function compileExpressionSafe(source) {
28620
+ return getCached(source);
28500
28621
  }
28501
28622
  //#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
- }
28623
+ //#region src/expression/evaluator.ts
28511
28624
  /**
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
28625
+ * Tree-walking evaluator for the safe expression mini-language.
28520
28626
  *
28521
- * Wrapper types ZodDefault, ZodOptional, and ZodNullable are unwrapped transparently.
28522
- * Default values are extracted from ZodDefault wrappers.
28627
+ * SECURITY (spec §4 rule 2/5):
28628
+ * - The scope is an `Object.create(null)` copy of ONLY the caller's own
28629
+ * enumerable binding entries, so `name in scope` is a pure own-key check and
28630
+ * `constructor` / `__proto__` / `toString` are plain unknown identifiers.
28631
+ * - Performs ZERO I/O and never touches `globalThis` / `Date` / `Math`
28632
+ * directly — the only external calls are into the frozen builtin table.
28633
+ * - The grammar has no loops/recursion/lambdas, so a walk is O(nodeCount) by
28634
+ * construction; the step counter is defense-in-depth for a crafted max-size
28635
+ * AST. Nothing blocks: there are no timers, awaits or unbounded loops.
28523
28636
  */
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
- }] };
28637
+ var EMPTY_HOOKS = Object.freeze({});
28638
+ /** Build a null-prototype scope from own-enumerable binding entries. Inherited
28639
+ * keys of the input (e.g. from a `{__proto__: {...}}` payload) are NOT copied,
28640
+ * so nothing smuggles in via the prototype chain. */
28641
+ function createExpressionScope(bindings) {
28642
+ const scope = Object.create(null);
28643
+ for (const key of Object.keys(bindings)) if (Object.prototype.hasOwnProperty.call(bindings, key)) scope[key] = bindings[key];
28644
+ return scope;
28530
28645
  }
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
- };
28646
+ function isFiniteNumber(value) {
28647
+ return typeof value === "number" && Number.isFinite(value);
28557
28648
  }
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
- };
28649
+ /** JS truthiness of a primitive value. */
28650
+ function truthy(value) {
28651
+ return Boolean(value);
28569
28652
  }
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
- };
28653
+ function requireFinite(value, context) {
28654
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${context} produced a non-finite result`);
28655
+ return value;
28584
28656
  }
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;
28657
+ function step(ctx) {
28658
+ ctx.steps += 1;
28659
+ if (ctx.steps > ctx.maxSteps) throw new ExpressionEvalError("expression evaluation step budget exceeded");
28588
28660
  }
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
- };
28661
+ function evalNode(node, ctx) {
28662
+ step(ctx);
28663
+ switch (node.kind) {
28664
+ case "literal": return node.value;
28665
+ case "identifier":
28666
+ if (!(node.name in ctx.scope)) throw new ExpressionEvalError(`unknown identifier: ${node.name}`);
28667
+ return ctx.scope[node.name];
28668
+ case "unary": return evalUnary(node.op, evalNode(node.operand, ctx));
28669
+ case "binary": return evalBinary(node.op, evalNode(node.left, ctx), evalNode(node.right, ctx));
28670
+ case "logical": {
28671
+ const left = evalNode(node.left, ctx);
28672
+ if (node.op === "&&") return truthy(left) ? evalNode(node.right, ctx) : left;
28673
+ return truthy(left) ? left : evalNode(node.right, ctx);
28674
+ }
28675
+ case "conditional": return truthy(evalNode(node.test, ctx)) ? evalNode(node.consequent, ctx) : evalNode(node.alternate, ctx);
28676
+ case "call": return evalCall(node.callee, node.args.map((a) => evalNode(a, ctx)), ctx.hooks);
28677
+ }
28599
28678
  }
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;
28679
+ function evalUnary(op, operand) {
28680
+ if (op === "!") return !truthy(operand);
28681
+ if (!isFiniteNumber(operand)) throw new ExpressionEvalError("unary \"-\" requires a finite number");
28682
+ return requireFinite(-operand, "unary \"-\"");
28605
28683
  }
28606
- function getZodDefault(schema) {
28607
- if (schema instanceof zod.z.ZodDefault) return zodDef(schema).defaultValue;
28684
+ function evalBinary(op, left, right) {
28685
+ switch (op) {
28686
+ case "==": return left === right;
28687
+ case "!=": return left !== right;
28688
+ case "+":
28689
+ if (typeof left === "string" && typeof right === "string") return left + right;
28690
+ if (isFiniteNumber(left) && isFiniteNumber(right)) return requireFinite(left + right, "\"+\"");
28691
+ throw new ExpressionEvalError("\"+\" requires two numbers or two strings");
28692
+ case "-":
28693
+ case "*":
28694
+ case "/":
28695
+ case "%":
28696
+ if (!isFiniteNumber(left) || !isFiniteNumber(right)) throw new ExpressionEvalError(`"${op}" requires two finite numbers`);
28697
+ return requireFinite(op === "-" ? left - right : op === "*" ? left * right : op === "/" ? left / right : left % right, `"${op}"`);
28698
+ case "<":
28699
+ case "<=":
28700
+ case ">":
28701
+ case ">=":
28702
+ if (isFiniteNumber(left) && isFiniteNumber(right)) return op === "<" ? left < right : op === "<=" ? left <= right : op === ">" ? left > right : left >= right;
28703
+ if (typeof left === "string" && typeof right === "string") return op === "<" ? left < right : op === "<=" ? left <= right : op === ">" ? left > right : left >= right;
28704
+ throw new ExpressionEvalError(`"${op}" requires two numbers or two strings`);
28705
+ }
28608
28706
  }
28609
- function humanizeKey(key) {
28610
- return key.replace(/([A-Z])/g, " $1").replace(/[_-]/g, " ").replace(/^\w/, (c) => c.toUpperCase()).trim();
28707
+ function evalCall(callee, args, hooks) {
28708
+ if (!Object.prototype.hasOwnProperty.call(EXPRESSION_BUILTINS, callee)) throw new ExpressionEvalError(`unknown function: ${callee}`);
28709
+ const builtin = EXPRESSION_BUILTINS[callee];
28710
+ if (args.length < builtin.minArgs || args.length > builtin.maxArgs) throw new ExpressionEvalError(`${callee}: wrong number of arguments (${args.length})`);
28711
+ return builtin.apply(args, hooks);
28712
+ }
28713
+ /** Evaluate an AST node against a scope. Throws `ExpressionEvalError` on any
28714
+ * runtime failure (unknown identifier, type mismatch, non-finite result,
28715
+ * step-budget exhaustion). */
28716
+ function evaluateAst(node, scope, opts) {
28717
+ return evalNode(node, {
28718
+ scope,
28719
+ hooks: opts?.hooks ?? EMPTY_HOOKS,
28720
+ maxSteps: opts?.maxSteps ?? 4096,
28721
+ steps: 0
28722
+ });
28723
+ }
28724
+ //#endregion
28725
+ //#region src/expression/expression-source.ts
28726
+ /** The `now` epoch-ms binding is auto-injected into every evaluation and is a
28727
+ * reserved binding name (authors may not rebind it). */
28728
+ var EXPRESSION_INJECTED_NOW = "now";
28729
+ /**
28730
+ * Coerce an untrusted `getByPath` / mirror read to an `ExpressionValue`.
28731
+ * Non-primitive values (objects, arrays, `undefined`, functions, bigint,
28732
+ * symbol) and non-finite numbers become `undefined` so the caller can apply
28733
+ * its binding-miss policy (→ `null`). `null` itself is a valid value.
28734
+ */
28735
+ function toExpressionValue(raw) {
28736
+ if (raw === null) return null;
28737
+ if (typeof raw === "string") return raw;
28738
+ if (typeof raw === "boolean") return raw;
28739
+ if (typeof raw === "number") return Number.isFinite(raw) ? raw : void 0;
28740
+ }
28741
+ /**
28742
+ * Author-time validation. Returns `null` when the source is valid, else a
28743
+ * human-readable error message. Checks: the expression compiles; binding count
28744
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
28745
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
28746
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
28747
+ */
28748
+ function validateExpressionSource(src) {
28749
+ const names = Object.keys(src.bindings);
28750
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
28751
+ for (const name of names) {
28752
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
28753
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
28754
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
28755
+ }
28756
+ const compiled = compileExpressionSafe(src.expr);
28757
+ if (!compiled.ok) return compiled.error;
28758
+ const bound = new Set(names);
28759
+ for (const id of compiled.parsed.identifiers) {
28760
+ if (id === "now") continue;
28761
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
28762
+ }
28763
+ return null;
28764
+ }
28765
+ /**
28766
+ * Shared read-path evaluation. Builds a null-proto scope from `bindingValues`
28767
+ * plus the injected `now` (supplied by the caller for determinism and
28768
+ * testability), compiles via the LRU, and evaluates. Any failure (parse or
28769
+ * eval) returns `{ ok: false }` — the caller treats that as "skip this
28770
+ * derivation", never as a throw that takes the pass down.
28771
+ */
28772
+ function evaluateExpressionSource(expr, bindingValues, now, opts) {
28773
+ const compiled = compileExpressionSafe(expr);
28774
+ if (!compiled.ok) return {
28775
+ ok: false,
28776
+ error: compiled.error
28777
+ };
28778
+ const scope = createExpressionScope({
28779
+ ...bindingValues,
28780
+ ["now"]: now
28781
+ });
28782
+ try {
28783
+ return {
28784
+ ok: true,
28785
+ value: evaluateAst(compiled.parsed.ast, scope, opts)
28786
+ };
28787
+ } catch (err) {
28788
+ return {
28789
+ ok: false,
28790
+ error: err instanceof ExpressionEvalError ? err.message : String(err)
28791
+ };
28792
+ }
28611
28793
  }
28612
28794
  //#endregion
28795
+ //#region src/expression/binding-source.ts
28796
+ /**
28797
+ * What an expression's named bindings READ from.
28798
+ *
28799
+ * Salvaged verbatim from the deleted device-link mechanism. Wiring's source
28800
+ * kinds were the one part of it worth keeping: addressing a device field by
28801
+ * re-sync-stable `stableId`, a per-device constant, and a sibling-accessory
28802
+ * read are the vocabulary any cross-device derivation needs, and they were
28803
+ * already correct. What wiring got wrong was the DESTINATION — a field on
28804
+ * somebody else's device, with no identity — not the source.
28805
+ *
28806
+ * These shapes are therefore kept, re-homed next to the engine that consumes
28807
+ * them, and are the binding type of a composition recipe (the source picker
28808
+ * stays `deviceManager.getWireableFields`). They deliberately do NOT nest: a
28809
+ * binding is a read, never another expression.
28810
+ *
28811
+ * Schemas are authoritative; every type is `z.infer` of one, so a wire shape and
28812
+ * a TypeScript shape cannot drift apart (`scripts/check-schema-type-twins.ts`).
28813
+ */
28814
+ /** Read a sibling accessory's status field, addressed by the sibling's key.
28815
+ * `kind` is optional for wire compatibility — absent means `'field'`. */
28816
+ var ExpressionFieldBindingSchema = zod.z.object({
28817
+ kind: zod.z.literal("field").optional(),
28818
+ sourceKey: zod.z.string(),
28819
+ cap: zod.z.string(),
28820
+ fieldPath: zod.z.string()
28821
+ });
28822
+ /** A constant. No device is read. */
28823
+ var ExpressionLiteralBindingSchema = zod.z.object({
28824
+ kind: zod.z.literal("literal"),
28825
+ value: zod.z.union([
28826
+ zod.z.string(),
28827
+ zod.z.number(),
28828
+ zod.z.boolean(),
28829
+ zod.z.null()
28830
+ ])
28831
+ });
28832
+ /** Read ANY device's status field, addressed by its re-sync-stable `stableId` —
28833
+ * never by numeric id, which a re-adoption reissues. */
28834
+ var ExpressionGlobalBindingSchema = zod.z.object({
28835
+ kind: zod.z.literal("global"),
28836
+ sourceStableId: zod.z.string(),
28837
+ cap: zod.z.string(),
28838
+ fieldPath: zod.z.string()
28839
+ });
28840
+ var ExpressionBindingSourceSchema = zod.z.union([
28841
+ ExpressionFieldBindingSchema,
28842
+ ExpressionLiteralBindingSchema,
28843
+ ExpressionGlobalBindingSchema
28844
+ ]);
28845
+ /**
28846
+ * An expression plus the bindings its free identifiers resolve against.
28847
+ *
28848
+ * The `superRefine` runs the SAME author-time validation as
28849
+ * `validateExpressionSource` — compiles the expression, checks binding names,
28850
+ * checks identifier coverage — so every boundary that parses one
28851
+ * validates-at-write rather than discovering the problem at read time.
28852
+ * Compiles are LRU-cached, so repeated validation of the same string is a hit.
28853
+ */
28854
+ var ExpressionSourceSchema = zod.z.object({
28855
+ expr: zod.z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
28856
+ bindings: zod.z.record(zod.z.string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
28857
+ }).superRefine((src, ctx) => {
28858
+ const err = validateExpressionSource(src);
28859
+ if (err !== null) ctx.addIssue({
28860
+ code: "custom",
28861
+ message: err,
28862
+ path: ["expr"]
28863
+ });
28864
+ });
28865
+ //#endregion
28613
28866
  //#region src/generated/cap-status-types.ts
28614
28867
  /**
28615
28868
  * Runtime list of cap names with status. Used by the settings
@@ -30411,6 +30664,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
30411
30664
  addonId: null,
30412
30665
  access: "view"
30413
30666
  },
30667
+ "coreBlocks.restart": {
30668
+ capName: "core-blocks",
30669
+ capScope: "system",
30670
+ addonId: null,
30671
+ access: "create"
30672
+ },
30414
30673
  "coreBlocks.setEnabled": {
30415
30674
  capName: "core-blocks",
30416
30675
  capScope: "system",
@@ -31047,12 +31306,6 @@ var METHOD_ACCESS_MAP = Object.freeze({
31047
31306
  addonId: null,
31048
31307
  access: "create"
31049
31308
  },
31050
- "deviceManager.setDeviceLinks": {
31051
- capName: "device-manager",
31052
- capScope: "system",
31053
- addonId: null,
31054
- access: "create"
31055
- },
31056
31309
  "deviceManager.setDisabled": {
31057
31310
  capName: "device-manager",
31058
31311
  capScope: "system",
@@ -35465,6 +35718,7 @@ function createSystemProxy(api) {
35465
35718
  update: (input) => dispatch("coreBlocks", "update", "mutation", input),
35466
35719
  delete: (input) => dispatch("coreBlocks", "delete", "mutation", input),
35467
35720
  setEnabled: (input) => dispatch("coreBlocks", "setEnabled", "mutation", input),
35721
+ restart: (input) => dispatch("coreBlocks", "restart", "mutation", input),
35468
35722
  compile: (input) => dispatch("coreBlocks", "compile", "mutation", input),
35469
35723
  getTypeDefs: (input) => dispatch("coreBlocks", "getTypeDefs", "query", input)
35470
35724
  },
@@ -36388,6 +36642,441 @@ var TimelapseRuleSchema = TimelapseRuleInputSchema.extend({
36388
36642
  updatedAt: zod.z.number()
36389
36643
  });
36390
36644
  //#endregion
36645
+ //#region src/pipeline/detail-crop.ts
36646
+ /**
36647
+ * THE detail-crop convention — the single derivation of the rectangle a
36648
+ * detail/enrichment step (clip-embedding, face-detection, plate-detection…)
36649
+ * is fed.
36650
+ *
36651
+ * ## Why this is one module and not two constants
36652
+ *
36653
+ * `object-clip` is ONE vector index, and cosine similarity is only meaningful
36654
+ * between vectors produced from the same crop convention. Two encode paths
36655
+ * write into it — the live detail plane and the embedding rebuild — and they
36656
+ * used to derive their crops independently: `DETAIL_CROP_PADDING_RATIO = 0.15`
36657
+ * with no squaring on one side, `DEFAULT_CROP_MARGIN = 0.2` with squaring on
36658
+ * by default on the other. Every rebuild therefore poured a second, silently
36659
+ * incomparable feature space into the index it exists to keep consistent.
36660
+ *
36661
+ * So the rectangle is derived HERE, once, from ONE convention value. Both
36662
+ * paths now reach this function through `pipelineRunner.runDetailSubtree` —
36663
+ * the runner is the only process that cuts (see `detail-subtree.ts`), and the
36664
+ * convention is a cluster-global `pipeline-orchestrator` setting. There is
36665
+ * deliberately no per-node or per-device scope: a per-accelerator crop margin
36666
+ * would reintroduce the same split, merely relocated.
36667
+ *
36668
+ * ## The default IS the live convention
36669
+ *
36670
+ * {@link DEFAULT_DETAIL_CROP_CONVENTION} reproduces what the live path has
36671
+ * been storing (0.15, no squaring). Anything else would invalidate every
36672
+ * vector already in the index on the day it shipped. Changing the convention
36673
+ * is legitimate — that is what the operator knob is for — but it must be
36674
+ * followed by a rebuild, which is now guaranteed to produce crops from this
36675
+ * same function.
36676
+ */
36677
+ /**
36678
+ * Store identity of the convention in `pipeline-orchestrator`'s GLOBAL
36679
+ * (cluster-wide) settings.
36680
+ *
36681
+ * These live here rather than in the orchestrator because the reader is a
36682
+ * different addon — the pipeline runner, over the hub-routed `addon-settings`
36683
+ * cap. Addons never import each other, so a key owned by the writer would have
36684
+ * to be hand-copied by the reader, and a hand-copied key is how a setting
36685
+ * silently stops arriving while both sides still look correct.
36686
+ */
36687
+ var DETAIL_CROP_SECTION_ID = "detail-crop";
36688
+ var DETAIL_CROP_PADDING_KEY = "detailCropPaddingRatio";
36689
+ var DETAIL_CROP_SQUARE_KEY = "detailCropSquare";
36690
+ /**
36691
+ * Operator-tunable crop convention. Single-valued and cluster-wide — see the
36692
+ * module docblock for why it cannot be scoped per node or per device.
36693
+ */
36694
+ var DetailCropConventionSchema = zod.z.object({
36695
+ /**
36696
+ * Fraction of the box's own size added on EACH side before cutting.
36697
+ *
36698
+ * CLIP is trained on natural images WITH surroundings; a pixel-tight crop
36699
+ * removes exactly the context it is strongest on (a dog cut to its outline
36700
+ * is a dark blob). The right value is an empirical question, which is why it
36701
+ * is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
36702
+ */
36703
+ paddingRatio: zod.z.number().min(0).max(4),
36704
+ /**
36705
+ * Square the window (in PIXELS) before cutting.
36706
+ *
36707
+ * CLIP's input is square, so a tall bbox resized straight to NxN is squashed
36708
+ * — a standing person becomes a shape the model never saw. Squaring costs
36709
+ * extra background, which is context the model wants anyway. Off by default
36710
+ * because the live path has never squared and the stored index reflects that.
36711
+ */
36712
+ square: zod.z.boolean()
36713
+ });
36714
+ /**
36715
+ * The convention in force when nobody has configured one — byte-for-byte the
36716
+ * behaviour of the pre-unification LIVE path (`DETAIL_CROP_PADDING_RATIO`).
36717
+ */
36718
+ var DEFAULT_DETAIL_CROP_CONVENTION = {
36719
+ paddingRatio: .15,
36720
+ square: false
36721
+ };
36722
+ /**
36723
+ * Narrow a FLAT settings record to the convention.
36724
+ *
36725
+ * Per-FIELD fallback, deliberately: a junk padding must not also discard a
36726
+ * valid squaring choice. An absent or invalid value resolves to
36727
+ * {@link DEFAULT_DETAIL_CROP_CONVENTION} — the historical live behaviour —
36728
+ * rather than to a clamped number nobody chose, so a bad read can never
36729
+ * quietly change what the stored vectors mean.
36730
+ */
36731
+ function readDetailCropConvention(config) {
36732
+ const paddingRatio = DetailCropConventionSchema.shape.paddingRatio.safeParse(config[DETAIL_CROP_PADDING_KEY]);
36733
+ const square = DetailCropConventionSchema.shape.square.safeParse(config[DETAIL_CROP_SQUARE_KEY]);
36734
+ return {
36735
+ paddingRatio: paddingRatio.success ? paddingRatio.data : DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio,
36736
+ square: square.success ? square.data : DEFAULT_DETAIL_CROP_CONVENTION.square
36737
+ };
36738
+ }
36739
+ function isHydratedField$1(entry) {
36740
+ return typeof entry === "object" && entry !== null && "key" in entry;
36741
+ }
36742
+ /**
36743
+ * Extract the convention from an `addon-settings.getGlobalSettings` payload.
36744
+ *
36745
+ * Walks EVERY section rather than looking inside {@link DETAIL_CROP_SECTION_ID}
36746
+ * alone: the keys are unique across the addon's schema, and a section rename
36747
+ * must not silently revert the whole cluster to the default. A `null` payload
36748
+ * (addon mid-boot) is the default convention.
36749
+ */
36750
+ function pickDetailCropConvention(view) {
36751
+ if (view === null) return DEFAULT_DETAIL_CROP_CONVENTION;
36752
+ const flat = {};
36753
+ for (const section of view.sections) for (const entry of section.fields) {
36754
+ if (!isHydratedField$1(entry) || typeof entry.key !== "string") continue;
36755
+ if (entry.key === "detailCropPaddingRatio" || entry.key === "detailCropSquare") flat[entry.key] = entry.value;
36756
+ }
36757
+ return readDetailCropConvention(flat);
36758
+ }
36759
+ /** Slider bounds for the operator-facing padding knob (orchestrator settings UI). */
36760
+ var DETAIL_CROP_PADDING_FIELD = {
36761
+ min: 0,
36762
+ max: 1,
36763
+ step: .05,
36764
+ default: DEFAULT_DETAIL_CROP_CONVENTION.paddingRatio
36765
+ };
36766
+ /**
36767
+ * Derive the crop rectangle for one parent detection.
36768
+ *
36769
+ * Order: pad by `paddingRatio` of the box's own size → optionally square in
36770
+ * pixel space around the padded centre → keep it inside the frame. Pure:
36771
+ * always returns a new rect and never mutates `bbox`.
36772
+ *
36773
+ * Edge handling differs by mode, on purpose:
36774
+ *
36775
+ * - **unsquared** — TRUNCATED at the frame border, byte-for-byte what the live
36776
+ * path has always done (`padAndClampFrameBbox`). A subject against the edge
36777
+ * gets a slightly smaller window. Changing this would silently reinterpret
36778
+ * every edge-touching vector already in the index.
36779
+ * - **squared** — SLID inward instead, because a truncated square is not
36780
+ * square and squaring exists precisely to preserve the aspect the model
36781
+ * sees. It only shrinks when the square is larger than the frame itself.
36782
+ */
36783
+ function deriveDetailCropRect(bbox, frameWidth, frameHeight, convention) {
36784
+ const padX = convention.paddingRatio * bbox.w;
36785
+ const padY = convention.paddingRatio * bbox.h;
36786
+ const padded = {
36787
+ x: bbox.x - padX,
36788
+ y: bbox.y - padY,
36789
+ w: bbox.w + 2 * padX,
36790
+ h: bbox.h + 2 * padY
36791
+ };
36792
+ return convention.square ? slideInsideFrame(squareInPixels(padded, frameWidth, frameHeight), frameWidth, frameHeight) : truncateToFrame(padded, frameWidth, frameHeight);
36793
+ }
36794
+ /**
36795
+ * Grow the shorter side to the longer one around the window's centre, bounded
36796
+ * by the frame's shorter side — a square larger than the frame cannot exist,
36797
+ * and collapsing to the frame's short side is the most that does.
36798
+ */
36799
+ function squareInPixels(rect, frameWidth, frameHeight) {
36800
+ const side = Math.min(Math.max(rect.w, rect.h), Math.min(frameWidth, frameHeight));
36801
+ const cx = rect.x + rect.w / 2;
36802
+ const cy = rect.y + rect.h / 2;
36803
+ return {
36804
+ x: cx - side / 2,
36805
+ y: cy - side / 2,
36806
+ w: side,
36807
+ h: side
36808
+ };
36809
+ }
36810
+ /**
36811
+ * Cut the window at the frame border — the pre-unification live behaviour,
36812
+ * preserved exactly so unsquared crops keep matching the stored index.
36813
+ */
36814
+ function truncateToFrame(rect, frameWidth, frameHeight) {
36815
+ const x1 = Math.max(0, rect.x);
36816
+ const y1 = Math.max(0, rect.y);
36817
+ const x2 = Math.min(frameWidth, rect.x + rect.w);
36818
+ const y2 = Math.min(frameHeight, rect.y + rect.h);
36819
+ return {
36820
+ x: x1,
36821
+ y: y1,
36822
+ w: Math.max(0, x2 - x1),
36823
+ h: Math.max(0, y2 - y1)
36824
+ };
36825
+ }
36826
+ /**
36827
+ * Move the window inside the frame keeping its extent — used only for squared
36828
+ * windows, where truncating would destroy the squareness that is the point.
36829
+ */
36830
+ function slideInsideFrame(rect, frameWidth, frameHeight) {
36831
+ const w = Math.max(0, Math.min(rect.w, frameWidth));
36832
+ const h = Math.max(0, Math.min(rect.h, frameHeight));
36833
+ return {
36834
+ x: Math.min(Math.max(0, rect.x), Math.max(0, frameWidth - w)),
36835
+ y: Math.min(Math.max(0, rect.y), Math.max(0, frameHeight - h)),
36836
+ w,
36837
+ h
36838
+ };
36839
+ }
36840
+ //#endregion
36841
+ //#region src/pipeline/native-lease.ts
36842
+ /**
36843
+ * THE native-frame **lease** knobs — TTL, RAM budget and demand window for the
36844
+ * decode worker's native-resolution frame retention.
36845
+ *
36846
+ * ## Why they live here and not in the addon that reads them
36847
+ *
36848
+ * The WRITER is `pipeline-orchestrator` (the cluster-wide settings authority);
36849
+ * the READER is a private child process of `addon-pipeline`'s pipeline-runner.
36850
+ * Addons never import each other, so a key owned by either side would have to
36851
+ * be hand-copied by the other — and a hand-copied key is how a setting silently
36852
+ * stops arriving while both sides still look correct. Same reasoning, same
36853
+ * placement as `detail-crop.ts` (D52's "one cluster-wide orchestrator setting").
36854
+ *
36855
+ * ## Why cluster-wide and not per-node
36856
+ *
36857
+ * The lease is a per-decode-worker RAM window. Its purpose — the late
36858
+ * cross-process native crop landing on a full-resolution frame rather than the
36859
+ * ≤640 detection fallback — is a property of the PIPELINE, not of a node's
36860
+ * hardware: a per-node TTL would mean the same camera produces different crop
36861
+ * quality depending on which node the balancer placed it on, and nobody could
36862
+ * tell that from the stored media. Node-level RAM pressure is already handled
36863
+ * by the per-session budget ceiling, which is itself one of these knobs.
36864
+ *
36865
+ * ## What each knob costs
36866
+ *
36867
+ * A retained frame is a full NATIVE-resolution copy in system RAM. With the
36868
+ * default pinned-RGB24 lease path (`CAMSTACK_SESSION_PINNED_RGB_CROP`, on):
36869
+ * 4K ≈ 24.9 MB/frame, 1080p ≈ 6.2 MB/frame. On the YUV420P path (flag off, and
36870
+ * for software-decoded sessions): 4K ≈ 12.4 MB, 1080p ≈ 3.1 MB. Worst-case
36871
+ * resident RAM for ONE busy camera ≈ frameBytes × deliveredFps × ttlSeconds,
36872
+ * clamped by the budget ceiling. See `docs/design/decode-path.md` → "Lease
36873
+ * admission" for what actually gets admitted.
36874
+ */
36875
+ /**
36876
+ * Store identity of the lease knobs in `pipeline-orchestrator`'s GLOBAL
36877
+ * (cluster-wide) settings. Keys are unique across that addon's whole schema, so
36878
+ * the reader can walk every section instead of trusting the section id.
36879
+ */
36880
+ var NATIVE_LEASE_SECTION_ID = "native-lease";
36881
+ var NATIVE_LEASE_TTL_KEY = "nativeLeaseTtlMs";
36882
+ var NATIVE_LEASE_BUDGET_KEY = "nativeLeaseBudgetMb";
36883
+ var NATIVE_LEASE_ACTIVITY_KEY = "nativeLeaseActivityMs";
36884
+ var NATIVE_LEASE_ADMISSION_KEY = "nativeLeaseAdmission";
36885
+ /**
36886
+ * WHICH delivered frames the decode worker retains a native copy of.
36887
+ *
36888
+ * - `all` — every frame the worker delivered to the runner. The shipped
36889
+ * behaviour, and the only correct one if something can ask for a crop of a
36890
+ * frame the runner never sent to inference.
36891
+ * - `inferred` — only the frames the runner ADMITTED to its detection queue.
36892
+ * A native-crop request always names a `frameId` that rode an inference
36893
+ * result, so that is the only set a request can name. How much it drops is
36894
+ * the two-plane governor's admit ratio and nothing else: measured at ~50% on
36895
+ * this cluster, not the ~80% the design sketch assumed, because the governor
36896
+ * was not throttling as hard as the sketch supposed. Read
36897
+ * `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
36898
+ * of you rather than quoting a number from here. The newest delivered frame is
36899
+ * croppable regardless — it is still the worker's reserved slot, not a lease —
36900
+ * which covers the one-frame race between a mark and the supersede that
36901
+ * consumes it.
36902
+ */
36903
+ var NativeLeaseAdmissionSchema = zod.z.enum(["all", "inferred"]);
36904
+ /**
36905
+ * Operator-tunable native-lease settings. Bounds are enforced HERE (not only in
36906
+ * the slider) because the value also travels to a forked child process, where a
36907
+ * junk number would silently become a 0-length or unbounded retention window.
36908
+ */
36909
+ var NativeLeaseSettingsSchema = zod.z.object({
36910
+ /**
36911
+ * How long a retained native frame is served before it counts as a miss.
36912
+ *
36913
+ * Must cover the FULL late-crop horizon: detection inference + the
36914
+ * cross-process inference-result hop to hub post-analysis + tracking + the
36915
+ * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
36916
+ * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
36917
+ * RAM per busy camera grows linearly with no measured hit-rate gain.
36918
+ */
36919
+ ttlMs: zod.z.number().int().min(250).max(1e4),
36920
+ /**
36921
+ * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
36922
+ *
36923
+ * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
36924
+ * which one is actually binding before reasoning from that. At the shipped
36925
+ * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
36926
+ * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
36927
+ * `leaseFrames` on the metrics line say which. When the ceiling binds, a
36928
+ * change that admits fewer frames buys retention WINDOW at constant RAM
36929
+ * rather than giving RAM back — lower this knob if RAM is what you wanted.
36930
+ * `0` DISABLES the lease entirely and falls the worker back to the tiny
36931
+ * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
36932
+ * to replace).
36933
+ */
36934
+ budgetMb: zod.z.number().int().min(0).max(4096),
36935
+ /**
36936
+ * Demand window: eager per-frame native retention runs only within this many
36937
+ * ms of the last native-crop request (or of the dial starting).
36938
+ *
36939
+ * `0` means ALWAYS ON — it disables the gate, it does not disable retention.
36940
+ * That is the legacy behaviour that saturated an N100 (24 native-4K downloads
36941
+ * per second on a camera with zero crop demand), so leave it non-zero unless
36942
+ * you are reproducing that.
36943
+ */
36944
+ activityMs: zod.z.number().int().min(0).max(12e4),
36945
+ /**
36946
+ * Which delivered frames are retained at all — see
36947
+ * {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
36948
+ * changes WHAT is kept rather than for how long, so it is also the only one
36949
+ * that can turn a crop that used to hit into a miss. The worker counts every
36950
+ * crop request naming a frame it did NOT see marked
36951
+ * (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
36952
+ * there is the signal that some caller names frames outside the inference set
36953
+ * and that this must go back to `all`.
36954
+ */
36955
+ admission: NativeLeaseAdmissionSchema
36956
+ });
36957
+ /**
36958
+ * The values in force when the operator has set nothing — byte-for-byte the
36959
+ * constants the decode worker shipped with as env-var defaults, so making these
36960
+ * settings changed no behaviour on the day it landed.
36961
+ */
36962
+ var DEFAULT_NATIVE_LEASE_SETTINGS = {
36963
+ ttlMs: 1200,
36964
+ budgetMb: 1024,
36965
+ activityMs: 15e3,
36966
+ admission: "inferred"
36967
+ };
36968
+ /** Slider bounds for the operator-facing knobs (orchestrator settings UI). */
36969
+ var NATIVE_LEASE_TTL_FIELD = {
36970
+ min: 250,
36971
+ max: 1e4,
36972
+ step: 50,
36973
+ default: DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs
36974
+ };
36975
+ var NATIVE_LEASE_BUDGET_FIELD = {
36976
+ min: 0,
36977
+ max: 4096,
36978
+ step: 64,
36979
+ default: DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb
36980
+ };
36981
+ var NATIVE_LEASE_ACTIVITY_FIELD = {
36982
+ min: 0,
36983
+ max: 12e4,
36984
+ step: 1e3,
36985
+ default: DEFAULT_NATIVE_LEASE_SETTINGS.activityMs
36986
+ };
36987
+ /** Select options for the admission knob (orchestrator settings UI). */
36988
+ var NATIVE_LEASE_ADMISSION_FIELD = {
36989
+ options: [{
36990
+ value: "all",
36991
+ label: "Every delivered frame"
36992
+ }, {
36993
+ value: "inferred",
36994
+ label: "Only frames sent to inference"
36995
+ }],
36996
+ default: DEFAULT_NATIVE_LEASE_SETTINGS.admission
36997
+ };
36998
+ /**
36999
+ * Parse one knob, reporting `null` for absent, junk, out-of-bounds — AND for a
37000
+ * value equal to the shipped default.
37001
+ *
37002
+ * That last rule is not tidiness, it is the difference between the documented
37003
+ * precedence being true and being a lie. `addon-settings.getGlobalSettings`
37004
+ * returns a HYDRATED payload, and `hydrateField` fills an unstored field with
37005
+ * the schema's own `default` (verified live on the hub: a cluster that has never
37006
+ * opened the form still reports `nativeLeaseTtlMs = 1200`). A reader that took
37007
+ * that at face value would report all three knobs as "set" on every cluster on
37008
+ * the day this shipped, permanently retiring the `CAMSTACK_SESSION_NATIVE_LEASE_*`
37009
+ * emergency override that the precedence promises. There is no raw-store read on
37010
+ * this cap to distinguish the two, so the default value itself is treated as
37011
+ * "the operator has expressed no preference" — which is also what leaving a
37012
+ * slider untouched means.
37013
+ *
37014
+ * The cost is one honest edge: an operator who deliberately selects the default
37015
+ * value in order to overrule an env var does not get it. Clear the env var
37016
+ * instead; the worker's spawn line names the source, so this is visible rather
37017
+ * than mysterious.
37018
+ */
37019
+ function readKnob(knob, raw) {
37020
+ const parsed = NativeLeaseSettingsSchema.shape[knob].safeParse(raw);
37021
+ if (!parsed.success) return null;
37022
+ return parsed.data === DEFAULT_NATIVE_LEASE_SETTINGS[knob] ? null : parsed.data;
37023
+ }
37024
+ /** {@link readKnob} for the one non-numeric knob. Same default-means-unset rule. */
37025
+ function readAdmissionKnob(raw) {
37026
+ const parsed = NativeLeaseAdmissionSchema.safeParse(raw);
37027
+ if (!parsed.success) return null;
37028
+ return parsed.data === DEFAULT_NATIVE_LEASE_SETTINGS.admission ? null : parsed.data;
37029
+ }
37030
+ /**
37031
+ * Narrow a FLAT settings record to the knobs the operator set.
37032
+ *
37033
+ * Per-FIELD parse, deliberately: a junk TTL must not also discard a valid
37034
+ * budget. An absent, out-of-bounds or default-valued knob is OMITTED (not
37035
+ * clamped, not defaulted) so the caller can still fall through to the env
37036
+ * override — clamping here would turn a typo into a value nobody chose. See
37037
+ * {@link readKnob} for why the default counts as unset.
37038
+ */
37039
+ function readNativeLeaseOverride(config) {
37040
+ const ttlMs = readKnob("ttlMs", config[NATIVE_LEASE_TTL_KEY]);
37041
+ const budgetMb = readKnob("budgetMb", config[NATIVE_LEASE_BUDGET_KEY]);
37042
+ const activityMs = readKnob("activityMs", config[NATIVE_LEASE_ACTIVITY_KEY]);
37043
+ const admission = readAdmissionKnob(config[NATIVE_LEASE_ADMISSION_KEY]);
37044
+ return {
37045
+ ...ttlMs === null ? {} : { ttlMs },
37046
+ ...budgetMb === null ? {} : { budgetMb },
37047
+ ...activityMs === null ? {} : { activityMs },
37048
+ ...admission === null ? {} : { admission }
37049
+ };
37050
+ }
37051
+ function isHydratedField(entry) {
37052
+ return typeof entry === "object" && entry !== null && "key" in entry;
37053
+ }
37054
+ var LEASE_KEYS = [
37055
+ NATIVE_LEASE_TTL_KEY,
37056
+ NATIVE_LEASE_BUDGET_KEY,
37057
+ NATIVE_LEASE_ACTIVITY_KEY,
37058
+ NATIVE_LEASE_ADMISSION_KEY
37059
+ ];
37060
+ /**
37061
+ * Extract the operator's lease overrides from an
37062
+ * `addon-settings.getGlobalSettings` payload.
37063
+ *
37064
+ * Walks EVERY section rather than looking inside {@link NATIVE_LEASE_SECTION_ID}
37065
+ * alone: the keys are unique across the addon's schema, and a section rename
37066
+ * must not silently revert the whole cluster to the defaults. A `null` payload
37067
+ * (addon mid-boot) means "operator set nothing" — the env/default fallback then
37068
+ * applies, which is the correct read of "I could not ask".
37069
+ */
37070
+ function pickNativeLeaseOverride(view) {
37071
+ if (view === null) return {};
37072
+ const flat = {};
37073
+ for (const section of view.sections) for (const entry of section.fields) {
37074
+ if (!isHydratedField(entry) || typeof entry.key !== "string") continue;
37075
+ if (LEASE_KEYS.includes(entry.key)) flat[entry.key] = entry.value;
37076
+ }
37077
+ return readNativeLeaseOverride(flat);
37078
+ }
37079
+ //#endregion
36391
37080
  //#region src/types/device-type.ts
36392
37081
  var DEVICE_TYPE_INFO = { ["camera"]: {
36393
37082
  type: "camera",
@@ -37447,441 +38136,6 @@ function pointInPolygon(point, polygon) {
37447
38136
  return inside;
37448
38137
  }
37449
38138
  //#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
38139
  //#region src/helpers/bind-addon-actions.ts
37886
38140
  /**
37887
38141
  * Bind an addon's custom-action catalog to its tRPC surface, returning a
@@ -38140,7 +38394,7 @@ exports.APPLE_SA_TO_MACRO = APPLE_SA_TO_MACRO;
38140
38394
  exports.AUDIO_ANALYSIS_CAP_NAME = AUDIO_ANALYSIS_CAP_NAME;
38141
38395
  exports.AUDIO_BACKEND_CHOICES = AUDIO_BACKEND_CHOICES;
38142
38396
  exports.AUDIO_MACRO_LABELS = AUDIO_MACRO_LABELS;
38143
- exports.AUDIO_PRESETS = require_fmp4_box_splitter.AUDIO_PRESETS;
38397
+ exports.AUDIO_PRESETS = require_canonical_hash.AUDIO_PRESETS;
38144
38398
  exports.AccessoriesStatusSchema = AccessoriesStatusSchema;
38145
38399
  exports.AccessoryKind = AccessoryKind;
38146
38400
  exports.AddBrokerInputSchema = AddBrokerInputSchema;
@@ -38237,6 +38491,7 @@ exports.CAP_NODE_PIN_CONTEXT_KEY = require_sleep.CAP_NODE_PIN_CONTEXT_KEY;
38237
38491
  exports.CAP_PROVIDER_KIND_MAP = CAP_PROVIDER_KIND_MAP;
38238
38492
  exports.COCO_80_LABELS = COCO_80_LABELS;
38239
38493
  exports.COCO_TO_MACRO = COCO_TO_MACRO;
38494
+ exports.CORE_BLOCK_ADDON_PREFIX = CORE_BLOCK_ADDON_PREFIX;
38240
38495
  exports.CamProfileSchema = require_sleep.CamProfileSchema;
38241
38496
  exports.CamStreamDescriptorSchema = CamStreamDescriptorSchema;
38242
38497
  exports.CamStreamKindSchema = require_sleep.CamStreamKindSchema;
@@ -38309,6 +38564,8 @@ exports.CreateUserInputSchema = CreateUserInputSchema;
38309
38564
  exports.CustomActionInputSchema = CustomActionInputSchema;
38310
38565
  exports.CustomModelDescriptorSchema = CustomModelDescriptorSchema;
38311
38566
  exports.DATAPLANE_SECRET_HEADER = require_sleep.DATAPLANE_SECRET_HEADER;
38567
+ exports.DECLARED_DEVICE_SWEEP_LIMIT = DECLARED_DEVICE_SWEEP_LIMIT;
38568
+ exports.DECLARED_INTEGRATION_FIXED_KEY = DECLARED_INTEGRATION_FIXED_KEY;
38312
38569
  exports.DEFAULT_ADDON_PLACEMENT = DEFAULT_ADDON_PLACEMENT;
38313
38570
  exports.DEFAULT_AUDIO_ANALYZER_CONFIG = DEFAULT_AUDIO_ANALYZER_CONFIG;
38314
38571
  exports.DEFAULT_DECODER_HWACCEL_CONFIG = DEFAULT_DECODER_HWACCEL_CONFIG;
@@ -38337,6 +38594,7 @@ exports.DayNightModeSchema = DayNightModeSchema;
38337
38594
  exports.DayNightOptionsSchema = DayNightOptionsSchema;
38338
38595
  exports.DayNightSettingsPatchSchema = DayNightSettingsPatchSchema;
38339
38596
  exports.DayNightStatusSchema = DayNightStatusSchema;
38597
+ exports.DeclaredDevices = DeclaredDevices;
38340
38598
  exports.DecodedAudioChunkSchema = require_sleep.DecodedAudioChunkSchema;
38341
38599
  exports.DecodedFrameSchema = require_sleep.DecodedFrameSchema;
38342
38600
  exports.DecoderSessionConfigSchema = DecoderSessionConfigSchema;
@@ -38352,7 +38610,6 @@ exports.DeviceExportStatusSchema = DeviceExportStatusSchema;
38352
38610
  exports.DeviceExportUnexposeInputSchema = UnexposeInputSchema;
38353
38611
  exports.DeviceFeature = require_sleep.DeviceFeature;
38354
38612
  exports.DeviceInfoSchema = DeviceInfoSchema;
38355
- exports.DeviceLinkModeSchema = DeviceLinkModeSchema;
38356
38613
  exports.DeviceNetworkStatsSchema = DeviceNetworkStatsSchema;
38357
38614
  exports.DeviceRole = require_sleep.DeviceRole;
38358
38615
  exports.DeviceRuntimeState = DeviceRuntimeState;
@@ -38410,14 +38667,19 @@ exports.ExportStateSchema = ExportStateSchema;
38410
38667
  exports.ExportTimelapseSchema = ExportTimelapseSchema;
38411
38668
  exports.ExposedDeviceSchema = ExposedDeviceSchema;
38412
38669
  exports.ExposureModeSchema = ExposureModeSchema;
38670
+ exports.ExpressionBindingSourceSchema = ExpressionBindingSourceSchema;
38413
38671
  exports.ExpressionEvalError = ExpressionEvalError;
38672
+ exports.ExpressionFieldBindingSchema = ExpressionFieldBindingSchema;
38673
+ exports.ExpressionGlobalBindingSchema = ExpressionGlobalBindingSchema;
38674
+ exports.ExpressionLiteralBindingSchema = ExpressionLiteralBindingSchema;
38414
38675
  exports.ExpressionParseError = ExpressionParseError;
38676
+ exports.ExpressionSourceSchema = ExpressionSourceSchema;
38415
38677
  exports.FanControlStatusSchema = FanControlStatusSchema;
38416
38678
  exports.FanDirectionSchema = FanDirectionSchema;
38417
38679
  exports.FeatureManifestSchema = FeatureManifestSchema;
38418
38680
  exports.FeatureProbeStatusSchema = FeatureProbeStatusSchema;
38419
38681
  exports.FloodStatusSchema = FloodStatusSchema;
38420
- exports.Fmp4BoxSplitter = require_fmp4_box_splitter.Fmp4BoxSplitter;
38682
+ exports.Fmp4BoxSplitter = require_canonical_hash.Fmp4BoxSplitter;
38421
38683
  exports.FrameHandleFormatSchema = require_sleep.FrameHandleFormatSchema;
38422
38684
  exports.FrameHandleSchema = require_sleep.FrameHandleSchema;
38423
38685
  exports.FrameInputSchema = FrameInputSchema;
@@ -38459,6 +38721,7 @@ exports.LabelTierSchema = LabelTierSchema;
38459
38721
  exports.LawnMowerActivitySchema = LawnMowerActivitySchema;
38460
38722
  exports.LawnMowerControlStatusSchema = LawnMowerControlStatusSchema;
38461
38723
  exports.LinkedDeviceSchema = LinkedDeviceSchema;
38724
+ exports.LinkedDevicesModeSchema = LinkedDevicesModeSchema;
38462
38725
  exports.LlmDefaultSchema = LlmDefaultSchema;
38463
38726
  exports.LlmDefaultSelectorSchema = LlmDefaultSelectorSchema;
38464
38727
  exports.LlmErrorCodeSchema = LlmErrorCodeSchema;
@@ -38929,7 +39192,6 @@ exports.airQualitySensorCapability = airQualitySensorCapability;
38929
39192
  exports.alarmPanelCapability = alarmPanelCapability;
38930
39193
  exports.alertsCapability = alertsCapability;
38931
39194
  exports.ambientLightSensorCapability = ambientLightSensorCapability;
38932
- exports.applyTransform = applyTransform;
38933
39195
  exports.asBoolean = require_sleep.asBoolean;
38934
39196
  exports.asJsonArray = require_sleep.asJsonArray;
38935
39197
  exports.asJsonObject = require_sleep.asJsonObject;
@@ -38939,7 +39201,7 @@ exports.audioAnalysisCapability = audioAnalysisCapability;
38939
39201
  exports.audioAnalyzerCapability = audioAnalyzerCapability;
38940
39202
  exports.audioCodecCapability = audioCodecCapability;
38941
39203
  exports.audioMetricsCapability = audioMetricsCapability;
38942
- exports.audioPlanFromEncodeProfile = require_fmp4_box_splitter.audioPlanFromEncodeProfile;
39204
+ exports.audioPlanFromEncodeProfile = require_canonical_hash.audioPlanFromEncodeProfile;
38943
39205
  exports.authProviderCapability = authProviderCapability;
38944
39206
  exports.autoAssignProfiles = autoAssignProfiles;
38945
39207
  exports.automationControlCapability = automationControlCapability;
@@ -38952,14 +39214,14 @@ exports.bindAddonActions = bindAddonActions;
38952
39214
  exports.brightnessCapability = brightnessCapability;
38953
39215
  exports.brokerCapability = brokerCapability;
38954
39216
  exports.buildAddonRouteProvider = buildAddonRouteProvider;
38955
- exports.buildAudioArgs = require_fmp4_box_splitter.buildAudioArgs;
39217
+ exports.buildAudioArgs = require_canonical_hash.buildAudioArgs;
38956
39218
  exports.buildEventKindDescriptor = buildEventKindDescriptor;
38957
- exports.buildFfmpegArgs = require_fmp4_box_splitter.buildFfmpegArgs;
38958
- exports.buildInputArgs = require_fmp4_box_splitter.buildInputArgs;
39219
+ exports.buildFfmpegArgs = require_canonical_hash.buildFfmpegArgs;
39220
+ exports.buildInputArgs = require_canonical_hash.buildInputArgs;
38959
39221
  exports.buildModelVariantGroups = buildModelVariantGroups;
38960
39222
  exports.buildNcTaxonomy = buildNcTaxonomy;
38961
39223
  exports.buildStreamParamsConfigSchema = buildStreamParamsConfigSchema;
38962
- exports.buildVideoArgs = require_fmp4_box_splitter.buildVideoArgs;
39224
+ exports.buildVideoArgs = require_canonical_hash.buildVideoArgs;
38963
39225
  exports.buttonCapability = buttonCapability;
38964
39226
  exports.cameraCredentialsCapability = cameraCredentialsCapability;
38965
39227
  exports.cameraPipelineConfigCapability = cameraPipelineConfigCapability;
@@ -38982,6 +39244,8 @@ exports.consumablesCapability = consumablesCapability;
38982
39244
  exports.contactCapability = contactCapability;
38983
39245
  exports.controlCapability = controlCapability;
38984
39246
  exports.convertUnit = convertUnit;
39247
+ exports.coreBlockAddonId = coreBlockAddonId;
39248
+ exports.coreBlockIdFromAddonId = coreBlockIdFromAddonId;
38985
39249
  exports.coreBlocksCapability = coreBlocksCapability;
38986
39250
  exports.cosineSimilarity = cosineSimilarity;
38987
39251
  exports.coverCapability = coverCapability;
@@ -38999,6 +39263,7 @@ exports.customAction = customAction;
38999
39263
  exports.customModelRegistryCapability = customModelRegistryCapability;
39000
39264
  exports.dataStoreProviderCapability = dataStoreProviderCapability;
39001
39265
  exports.dayNightCapability = dayNightCapability;
39266
+ exports.declarationOwnerNodeId = declarationOwnerNodeId;
39002
39267
  exports.decodeVectorBase64 = decodeVectorBase64;
39003
39268
  exports.decoderCapability = decoderCapability;
39004
39269
  exports.defaultDeviceFor = defaultDeviceFor;
@@ -39033,7 +39298,7 @@ exports.enumerateItemArrayFields = enumerateItemArrayFields;
39033
39298
  exports.enumerateSchemaFields = enumerateSchemaFields;
39034
39299
  exports.errMsg = require_err_msg.errMsg;
39035
39300
  exports.evaluateAst = evaluateAst;
39036
- exports.evaluateLinkExpression = evaluateLinkExpression;
39301
+ exports.evaluateExpressionSource = evaluateExpressionSource;
39037
39302
  exports.evaluateZoneRules = evaluateZoneRules;
39038
39303
  exports.event = require_sleep.event;
39039
39304
  exports.eventEmitterCapability = eventEmitterCapability;
@@ -39064,7 +39329,7 @@ exports.imageCapability = imageCapability;
39064
39329
  exports.imageSettingsCapability = imageSettingsCapability;
39065
39330
  exports.integrationsCapability = integrationsCapability;
39066
39331
  exports.intercomCapability = intercomCapability;
39067
- exports.invocationFromEncodeProfile = require_fmp4_box_splitter.invocationFromEncodeProfile;
39332
+ exports.invocationFromEncodeProfile = require_canonical_hash.invocationFromEncodeProfile;
39068
39333
  exports.isAgentOnlyPlacement = isAgentOnlyPlacement;
39069
39334
  exports.isArrayOutputSchema = isArrayOutputSchema;
39070
39335
  exports.isBaseConditionKey = isBaseConditionKey;
@@ -39077,7 +39342,7 @@ exports.isNode = isNode;
39077
39342
  exports.isObjectInput = isObjectInput;
39078
39343
  exports.isSameAddonId = isSameAddonId;
39079
39344
  exports.isScheduleActive = isScheduleActive;
39080
- exports.isSoftwareDecode = require_fmp4_box_splitter.isSoftwareDecode;
39345
+ exports.isSoftwareDecode = require_canonical_hash.isSoftwareDecode;
39081
39346
  exports.isVoidInput = isVoidInput;
39082
39347
  exports.jobKindSchema = jobKindSchema;
39083
39348
  exports.kebabToCamel = kebabToCamel;
@@ -39092,7 +39357,7 @@ exports.llmRuntimeCapability = llmRuntimeCapability;
39092
39357
  exports.localNetworkCapability = localNetworkCapability;
39093
39358
  exports.locationSimilarity = locationSimilarity;
39094
39359
  exports.lockControlCapability = lockControlCapability;
39095
- exports.logBannerArgs = require_fmp4_box_splitter.logBannerArgs;
39360
+ exports.logBannerArgs = require_canonical_hash.logBannerArgs;
39096
39361
  exports.logDestinationCapability = logDestinationCapability;
39097
39362
  exports.logLevelAtMost = logLevelAtMost;
39098
39363
  exports.loginMethodCapability = loginMethodCapability;
@@ -39143,7 +39408,7 @@ exports.pickAccessoryControl = pickAccessoryControl;
39143
39408
  exports.pickDetailCropConvention = pickDetailCropConvention;
39144
39409
  exports.pickNativeLeaseOverride = pickNativeLeaseOverride;
39145
39410
  exports.pickPreferredRtspEntry = pickPreferredRtspEntry;
39146
- exports.pickVideoEncoder = require_fmp4_box_splitter.pickVideoEncoder;
39411
+ exports.pickVideoEncoder = require_canonical_hash.pickVideoEncoder;
39147
39412
  exports.pickerForCondition = pickerForCondition;
39148
39413
  exports.pipelineAnalyticsCapability = pipelineAnalyticsCapability;
39149
39414
  exports.pipelineExecutorCapability = pipelineExecutorCapability;