@camstack/addon-pipeline-orchestrator 1.1.18 → 1.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4631,7 +4631,7 @@ function _instanceof(cls, params = {}) {
4631
4631
  return inst;
4632
4632
  }
4633
4633
  //#endregion
4634
- //#region ../types/dist/sleep-MHm--th-.mjs
4634
+ //#region ../types/dist/sleep-BiDFW0E7.mjs
4635
4635
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4636
4636
  EventCategory["SystemBoot"] = "system.boot";
4637
4637
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -5460,6 +5460,100 @@ function createDurableState(deps) {
5460
5460
  };
5461
5461
  }
5462
5462
  /**
5463
+ * Per-node scoping for the shared addon-settings blob.
5464
+ *
5465
+ * An addon's settings store is hub-central (the `addon-settings` cap is
5466
+ * hub-routed — the hub instance answers for every node), so fields whose
5467
+ * value is a NODE fact (decoder backend, engine pick, probed hardware
5468
+ * capabilities) persist under the node-scoped key `<key>@<nodeId>` inside
5469
+ * the single shared blob. The SCHEMA field key stays bare: the write path
5470
+ * ({@link scopePatch}) maps the bare field onto the target node's scoped
5471
+ * key; the read/hydrate path ({@link projectStore}) maps THIS node's value
5472
+ * back, so UI forms and `resolveConfig` only ever see the bare key.
5473
+ *
5474
+ * Fields opt in via the `perNode: true` schema marker on `ConfigFieldBase`
5475
+ * (`interfaces/config-ui.ts`); `BaseAddon` derives the key set from the
5476
+ * schema and routes reads/writes through these helpers.
5477
+ *
5478
+ * ## No bare-key fallback — deliberate
5479
+ *
5480
+ * {@link readNodeValue} reads the node-scoped key ONLY. A node with no
5481
+ * scoped key resolves to `undefined` so the field's schema `default` wins —
5482
+ * NEVER `store[base]` and never another node's value. A bare legacy key in
5483
+ * the store is invisible to every node, hub included, so one node's
5484
+ * selection can never leak onto another. (This generalizes the
5485
+ * `decoder-backend-keys.ts` semantics — the no-fallback variant — over an
5486
+ * arbitrary set of per-node field keys.)
5487
+ *
5488
+ * Pure functions only — no I/O, no imports beyond the language. This is a
5489
+ * LEAF module: import it via its deep path, never from the root barrel.
5490
+ */
5491
+ /**
5492
+ * Normalize a raw kernel node id to the bare node id used for scoping.
5493
+ * `localNodeId` can carry a `<node>/<addon>` suffix on forked child
5494
+ * processes; per-node settings are per-NODE, so strip the addon segment.
5495
+ * `undefined` / `null` / empty falls back to `'hub'`.
5496
+ */
5497
+ function normalizeNodeId(raw) {
5498
+ if (raw === void 0 || raw === null || raw === "") return "hub";
5499
+ const slashIdx = raw.indexOf("/");
5500
+ if (slashIdx < 0) return raw;
5501
+ const bare = raw.slice(0, slashIdx);
5502
+ return bare === "" ? "hub" : bare;
5503
+ }
5504
+ /** The node-scoped store key for a per-node field: `<base>@<nodeId>`. */
5505
+ function nodeScopedKey(base, nodeId) {
5506
+ return `${base}@${normalizeNodeId(nodeId)}`;
5507
+ }
5508
+ /**
5509
+ * Read a node's value for a per-node field from the raw shared store:
5510
+ * the node-scoped key when present, otherwise `undefined`.
5511
+ * Deliberately NO bare-key fallback (see module doc) — the caller lets the
5512
+ * schema `default` win on `undefined`.
5513
+ */
5514
+ function readNodeValue(store, base, nodeId) {
5515
+ return store[nodeScopedKey(base, nodeId)];
5516
+ }
5517
+ /**
5518
+ * Re-map a UI/settings patch so every bare perNode field persists under the
5519
+ * TARGET node's scoped key; all other keys pass through unchanged. Used on
5520
+ * the write path so a save for one node never clobbers another node's value
5521
+ * (and the bare key is never written). Returns a new object — the input
5522
+ * patch is not mutated.
5523
+ */
5524
+ function scopePatch(patch, perNodeKeys, nodeId) {
5525
+ const out = {};
5526
+ for (const [key, value] of Object.entries(patch)) out[perNodeKeys.has(key) ? nodeScopedKey(key, nodeId) : key] = value;
5527
+ return out;
5528
+ }
5529
+ /**
5530
+ * Project the raw shared store onto the bare schema keys for ONE node so a
5531
+ * UI schema (whose field keys are bare) hydrates from that node's own
5532
+ * values:
5533
+ *
5534
+ * - EVERY `@`-scoped key (any node's) is dropped from the pass-through.
5535
+ * - Every bare perNode key is dropped from the pass-through (a stray bare
5536
+ * legacy key must never hydrate any node — no bare fallback).
5537
+ * - THIS node's effective value ({@link readNodeValue}) is then laid onto
5538
+ * each bare perNode key; when the node has no scoped key the bare key is
5539
+ * left ABSENT so the field's schema `default` wins.
5540
+ *
5541
+ * Returns a new object — the input store is not mutated.
5542
+ */
5543
+ function projectStore(store, perNodeKeys, nodeId) {
5544
+ const out = {};
5545
+ for (const [key, value] of Object.entries(store)) {
5546
+ if (key.includes("@")) continue;
5547
+ if (perNodeKeys.has(key)) continue;
5548
+ out[key] = value;
5549
+ }
5550
+ for (const base of perNodeKeys) {
5551
+ const value = readNodeValue(store, base, nodeId);
5552
+ if (value !== void 0) out[base] = value;
5553
+ }
5554
+ return out;
5555
+ }
5556
+ /**
5463
5557
  * Base class for CamStack addons. Eliminates settings boilerplate:
5464
5558
  *
5465
5559
  * - Typed `config` property with automatic resolution from store + defaults
@@ -5627,23 +5721,63 @@ var BaseAddon = class {
5627
5721
  deviceSettingsSchema() {
5628
5722
  return null;
5629
5723
  }
5630
- async getGlobalSettings(overlay, cap, _nodeId) {
5724
+ async getGlobalSettings(overlay, cap, nodeId) {
5631
5725
  const schema = this.globalSettingsSchema(cap);
5632
5726
  if (!schema) return { sections: [] };
5633
- const raw = await this._ctx?.settings?.readAddonStore() ?? {};
5727
+ const projected = await this.resolveGlobalStore(nodeId, cap);
5634
5728
  return hydrateSchema(schema, overlay ? {
5635
- ...raw,
5729
+ ...projected,
5636
5730
  ...overlay
5637
- } : raw);
5731
+ } : projected);
5638
5732
  }
5639
- async updateGlobalSettings(patch, _nodeId) {
5640
- await this._ctx?.settings?.writeAddonStore(patch);
5733
+ /**
5734
+ * The raw addon store PROJECTED onto the target node's bare per-node keys:
5735
+ * every `perNode: true` field carries THAT node's scoped value on its bare
5736
+ * key (absent scoped key ⇒ key absent, so the schema `default` wins — no
5737
+ * bare fallback), all `@`-scoped and stray bare per-node keys are dropped.
5738
+ * A no-op passthrough when the schema declares no `perNode` field.
5739
+ *
5740
+ * This is the sanctioned way for a `getGlobalSettings` OVERRIDE that needs
5741
+ * the store for custom option logic (option narrowing, value snapping) to
5742
+ * read it per-node — never `ctx.settings.readAddonStore()` directly.
5743
+ * `nodeId` omitted ⇒ the local node (node-blind injection from ctx).
5744
+ */
5745
+ async resolveGlobalStore(nodeId, cap) {
5746
+ const raw = await this._ctx?.settings?.readAddonStore() ?? {};
5747
+ const keys = this.perNodeKeys(cap);
5748
+ const node = normalizeNodeId(nodeId ?? this._ctx?.kernel?.localNodeId);
5749
+ return keys.size > 0 ? projectStore(raw, keys, node) : raw;
5750
+ }
5751
+ async updateGlobalSettings(patch, nodeId) {
5752
+ const keys = this.perNodeKeys();
5753
+ const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
5754
+ const target = normalizeNodeId(nodeId ?? this._ctx?.kernel?.localNodeId);
5755
+ const barePatch = patch;
5756
+ const scoped = keys.size > 0 ? scopePatch(barePatch, keys, target) : barePatch;
5757
+ await this._ctx?.settings?.writeAddonStore(scoped);
5758
+ if (target !== localNode) return;
5641
5759
  await this.resolveConfig();
5642
5760
  await this.onConfigChanged();
5643
5761
  this.emitLifecycle(EventCategory.AddonUpdated, { level: "global" });
5644
5762
  this.maybeAutoRestart(patch, this.globalSettingsSchema());
5645
5763
  }
5646
5764
  /**
5765
+ * The set of field keys the global settings schema declares `perNode: true`
5766
+ * — derived once per `cap` argument and memoized (schemas are static
5767
+ * declarations). Empty set ⇒ every per-node code path is bypassed and the
5768
+ * settings API behaves exactly like the legacy node-agnostic one.
5769
+ */
5770
+ _perNodeKeysCache = /* @__PURE__ */ new Map();
5771
+ perNodeKeys(cap) {
5772
+ const cacheKey = cap ?? "";
5773
+ const cached = this._perNodeKeysCache.get(cacheKey);
5774
+ if (cached) return cached;
5775
+ const schema = this.globalSettingsSchema(cap);
5776
+ const keys = new Set(schema ? schema.sections.flatMap((section) => collectPerNodeFieldKeys(section.fields)) : []);
5777
+ this._perNodeKeysCache.set(cacheKey, keys);
5778
+ return keys;
5779
+ }
5780
+ /**
5647
5781
  * If any field in `patch` is marked `requiresRestart` in `schema`,
5648
5782
  * schedule an addon restart for the next tick. Deferred via
5649
5783
  * `setImmediate` so the tRPC mutation that triggered the write has
@@ -5796,12 +5930,19 @@ var BaseAddon = class {
5796
5930
  * The merge is shallow: each key in `defaults` is checked against the store.
5797
5931
  * Only keys present in defaults are read — the store can contain extra keys
5798
5932
  * (e.g. from older versions) without polluting the typed config.
5933
+ *
5934
+ * Keys the global settings schema declares `perNode: true` resolve from
5935
+ * THIS node's scoped key (`<key>@<localNode>`) via `readNodeValue` — never
5936
+ * from the bare key — so a per-node field resolves to this node's own
5937
+ * selection at boot (absent scoped key ⇒ the constructor default wins).
5799
5938
  */
5800
5939
  async resolveConfig() {
5801
5940
  const stored = await this.readAddonStoreWithRetry();
5941
+ const perNode = this.perNodeKeys();
5942
+ const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
5802
5943
  const resolved = { ...this.defaults };
5803
5944
  for (const key of Object.keys(this.defaults)) {
5804
- const storedValue = stored[key];
5945
+ const storedValue = perNode.has(key) ? readNodeValue(stored, key, localNode) : stored[key];
5805
5946
  if (storedValue !== void 0 && storedValue !== null) {
5806
5947
  const defaultType = typeof this.defaults[key];
5807
5948
  if (typeof storedValue === defaultType) resolved[key] = storedValue;
@@ -5885,6 +6026,27 @@ var BaseAddon = class {
5885
6026
  }
5886
6027
  };
5887
6028
  /**
6029
+ * Collect the keys of every field marked `perNode: true`, recursing into
6030
+ * layout containers (`group` fields and `sub-tabs` tabs) the same way
6031
+ * `hydrateSchema` does. Valueless structural fields (separator/info/…)
6032
+ * don't declare `perNode` and are excluded by the `in` narrowing.
6033
+ */
6034
+ function collectPerNodeFieldKeys(fields) {
6035
+ const collected = [];
6036
+ for (const field of fields) {
6037
+ if (field.type === "group") {
6038
+ collected.push(...collectPerNodeFieldKeys(field.fields));
6039
+ continue;
6040
+ }
6041
+ if (field.type === "sub-tabs") {
6042
+ for (const tab of field.tabs) collected.push(...collectPerNodeFieldKeys(tab.fields));
6043
+ continue;
6044
+ }
6045
+ if ("perNode" in field && field.perNode === true) collected.push(field.key);
6046
+ }
6047
+ return collected;
6048
+ }
6049
+ /**
5888
6050
  * Normalize an `ICamstackAddon.initialize()` return value into the
5889
6051
  * `AddonInitResult` envelope. Arrays are wrapped into `{ providers }`;
5890
6052
  * envelopes pass through; void stays void.
@@ -6652,6 +6814,13 @@ var DeviceType = /* @__PURE__ */ function(DeviceType) {
6652
6814
  /** Single still-image entity (HA `image.*`). Read-only display of an
6653
6815
  * `entity_picture` signed URL the browser loads directly. `image` cap. */
6654
6816
  DeviceType["Image"] = "image";
6817
+ /** Smart pet feeder — cloud-connected food dispenser with a bowl food
6818
+ * level, battery, desiccant life, feeding state and manual-feed /
6819
+ * call-pet / maintenance actions. Installed with the `pet-feeder` cap;
6820
+ * dual-hopper models (D4S/D4SH) expose per-hopper portions. Sources:
6821
+ * native PetKit (`nodepetkit` `FeederDevice`), reusable by other feeder
6822
+ * integrations sharing the same food/desiccant/hopper surface. */
6823
+ DeviceType["PetFeeder"] = "pet-feeder";
6655
6824
  return DeviceType;
6656
6825
  }({});
6657
6826
  var DeviceFeature = /* @__PURE__ */ function(DeviceFeature) {
@@ -7836,6 +8005,601 @@ var _macroLookup = /* @__PURE__ */ new Map();
7836
8005
  for (const [k, v] of Object.entries(YAMNET_TO_MACRO.mapping)) _macroLookup.set(k.toLowerCase(), v);
7837
8006
  for (const [k, v] of Object.entries(APPLE_SA_TO_MACRO.mapping)) _macroLookup.set(k.toLowerCase(), v);
7838
8007
  /**
8008
+ * Error types for the safe expression engine. Two distinct classes so callers
8009
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
8010
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
8011
+ */
8012
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
8013
+ * the failure is anchored to a character (author-facing inline feedback). */
8014
+ var ExpressionParseError = class extends Error {
8015
+ position;
8016
+ constructor(message, position) {
8017
+ super(message);
8018
+ this.name = "ExpressionParseError";
8019
+ this.position = position;
8020
+ }
8021
+ };
8022
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
8023
+ * result, unknown builtin, step-budget exceeded). */
8024
+ var ExpressionEvalError = class extends Error {
8025
+ constructor(message) {
8026
+ super(message);
8027
+ this.name = "ExpressionEvalError";
8028
+ }
8029
+ };
8030
+ /**
8031
+ * Resource-bound constants for the safe expression engine.
8032
+ *
8033
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
8034
+ * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
8035
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
8036
+ * work a single author-supplied expression can request, so a hostile or
8037
+ * accidental pathological string can never spend unbounded CPU/memory.
8038
+ */
8039
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
8040
+ * rejected without allocation. */
8041
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
8042
+ /** A legal binding / identifier name. */
8043
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
8044
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
8045
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
8046
+ var RESERVED_BINDING_NAMES = new Set([
8047
+ "now",
8048
+ "true",
8049
+ "false",
8050
+ "null"
8051
+ ]);
8052
+ /**
8053
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
8054
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
8055
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
8056
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
8057
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
8058
+ * is a parse error with a source position, so member access / assignment /
8059
+ * template literals are lexically impossible.
8060
+ */
8061
+ var KEYWORDS = new Set([
8062
+ "true",
8063
+ "false",
8064
+ "null"
8065
+ ]);
8066
+ function isDigit(ch) {
8067
+ return ch >= "0" && ch <= "9";
8068
+ }
8069
+ function isIdentStart(ch) {
8070
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
8071
+ }
8072
+ function isIdentPart(ch) {
8073
+ return isIdentStart(ch) || isDigit(ch);
8074
+ }
8075
+ function isWhitespace(ch) {
8076
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
8077
+ }
8078
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
8079
+ * Throws `ExpressionParseError` on any illegal character or unterminated
8080
+ * string. */
8081
+ function tokenize(source) {
8082
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
8083
+ const tokens = [];
8084
+ let i = 0;
8085
+ const n = source.length;
8086
+ while (i < n) {
8087
+ const ch = source[i];
8088
+ if (isWhitespace(ch)) {
8089
+ i += 1;
8090
+ continue;
8091
+ }
8092
+ if (isDigit(ch)) {
8093
+ const start = i;
8094
+ while (i < n && isDigit(source[i])) i += 1;
8095
+ if (i < n && source[i] === ".") {
8096
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
8097
+ i += 1;
8098
+ while (i < n && isDigit(source[i])) i += 1;
8099
+ }
8100
+ const text = source.slice(start, i);
8101
+ const value = Number(text);
8102
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
8103
+ tokens.push({
8104
+ type: "number",
8105
+ value,
8106
+ pos: start
8107
+ });
8108
+ continue;
8109
+ }
8110
+ if (ch === "'" || ch === "\"") {
8111
+ const quote = ch;
8112
+ const start = i;
8113
+ i += 1;
8114
+ let out = "";
8115
+ let closed = false;
8116
+ while (i < n) {
8117
+ const c = source[i];
8118
+ if (c === "\\") {
8119
+ const next = i + 1 < n ? source[i + 1] : "";
8120
+ if (next === "\\" || next === "'" || next === "\"") {
8121
+ out += next;
8122
+ i += 2;
8123
+ continue;
8124
+ }
8125
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
8126
+ }
8127
+ if (c === quote) {
8128
+ closed = true;
8129
+ i += 1;
8130
+ break;
8131
+ }
8132
+ out += c;
8133
+ i += 1;
8134
+ }
8135
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
8136
+ tokens.push({
8137
+ type: "string",
8138
+ value: out,
8139
+ pos: start
8140
+ });
8141
+ continue;
8142
+ }
8143
+ if (isIdentStart(ch)) {
8144
+ const start = i;
8145
+ while (i < n && isIdentPart(source[i])) i += 1;
8146
+ const text = source.slice(start, i);
8147
+ if (KEYWORDS.has(text)) tokens.push({
8148
+ type: "keyword",
8149
+ keyword: keywordOf(text),
8150
+ pos: start
8151
+ });
8152
+ else tokens.push({
8153
+ type: "identifier",
8154
+ name: text,
8155
+ pos: start
8156
+ });
8157
+ continue;
8158
+ }
8159
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
8160
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
8161
+ tokens.push({
8162
+ type: "punct",
8163
+ punct: two,
8164
+ pos: i
8165
+ });
8166
+ i += 2;
8167
+ continue;
8168
+ }
8169
+ if (isSinglePunct(ch)) {
8170
+ tokens.push({
8171
+ type: "punct",
8172
+ punct: ch,
8173
+ pos: i
8174
+ });
8175
+ i += 1;
8176
+ continue;
8177
+ }
8178
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
8179
+ }
8180
+ tokens.push({
8181
+ type: "eof",
8182
+ pos: n
8183
+ });
8184
+ return tokens;
8185
+ }
8186
+ function keywordOf(text) {
8187
+ if (text === "true") return "true";
8188
+ if (text === "false") return "false";
8189
+ return "null";
8190
+ }
8191
+ function isSinglePunct(ch) {
8192
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
8193
+ }
8194
+ /**
8195
+ * Frozen, null-prototype builtin function table for the expression engine
8196
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
8197
+ * parser rejects any callee not in it, and the evaluator gates each call on an
8198
+ * own-property check against it.
8199
+ *
8200
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
8201
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
8202
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
8203
+ * (there is no `Object.prototype` in the chain), so those names are not
8204
+ * callable — they are simply "unknown function" at parse time.
8205
+ *
8206
+ * Every numeric argument is validated as a finite number and every numeric
8207
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
8208
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
8209
+ * closed rather than emitting a garbage value.
8210
+ */
8211
+ function asFiniteNumber(value, name, index) {
8212
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
8213
+ return value;
8214
+ }
8215
+ function asString$1(value, name, index) {
8216
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
8217
+ return value;
8218
+ }
8219
+ function finiteResult(value, name) {
8220
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
8221
+ return value;
8222
+ }
8223
+ function allFiniteNumbers(args, name) {
8224
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
8225
+ }
8226
+ var INF = Number.POSITIVE_INFINITY;
8227
+ var table = {
8228
+ min: {
8229
+ minArgs: 1,
8230
+ maxArgs: INF,
8231
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
8232
+ },
8233
+ max: {
8234
+ minArgs: 1,
8235
+ maxArgs: INF,
8236
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
8237
+ },
8238
+ abs: {
8239
+ minArgs: 1,
8240
+ maxArgs: 1,
8241
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
8242
+ },
8243
+ floor: {
8244
+ minArgs: 1,
8245
+ maxArgs: 1,
8246
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
8247
+ },
8248
+ ceil: {
8249
+ minArgs: 1,
8250
+ maxArgs: 1,
8251
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
8252
+ },
8253
+ sqrt: {
8254
+ minArgs: 1,
8255
+ maxArgs: 1,
8256
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
8257
+ },
8258
+ round: {
8259
+ minArgs: 1,
8260
+ maxArgs: 2,
8261
+ apply: (args) => {
8262
+ const x = asFiniteNumber(args[0], "round", 0);
8263
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
8264
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
8265
+ const factor = 10 ** digits;
8266
+ return finiteResult(Math.round(x * factor) / factor, "round");
8267
+ }
8268
+ },
8269
+ pow: {
8270
+ minArgs: 2,
8271
+ maxArgs: 2,
8272
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
8273
+ },
8274
+ clamp: {
8275
+ minArgs: 3,
8276
+ maxArgs: 3,
8277
+ apply: (args) => {
8278
+ const x = asFiniteNumber(args[0], "clamp", 0);
8279
+ const lo = asFiniteNumber(args[1], "clamp", 1);
8280
+ const hi = asFiniteNumber(args[2], "clamp", 2);
8281
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
8282
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
8283
+ }
8284
+ },
8285
+ avg: {
8286
+ minArgs: 1,
8287
+ maxArgs: INF,
8288
+ apply: (args) => {
8289
+ const nums = allFiniteNumbers(args, "avg");
8290
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
8291
+ }
8292
+ },
8293
+ sum: {
8294
+ minArgs: 1,
8295
+ maxArgs: INF,
8296
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
8297
+ },
8298
+ coalesce: {
8299
+ minArgs: 1,
8300
+ maxArgs: INF,
8301
+ apply: (args) => {
8302
+ for (const a of args) if (a !== null) return a;
8303
+ return null;
8304
+ }
8305
+ },
8306
+ age: {
8307
+ minArgs: 2,
8308
+ maxArgs: 2,
8309
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
8310
+ },
8311
+ convert: {
8312
+ minArgs: 3,
8313
+ maxArgs: 3,
8314
+ apply: (args, hooks) => {
8315
+ const x = asFiniteNumber(args[0], "convert", 0);
8316
+ const from = asString$1(args[1], "convert", 1).trim();
8317
+ const to = asString$1(args[2], "convert", 2).trim();
8318
+ if (hooks.convert) {
8319
+ const out = hooks.convert(x, from, to);
8320
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
8321
+ return finiteResult(out, "convert");
8322
+ }
8323
+ if (from === to) return x;
8324
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
8325
+ }
8326
+ }
8327
+ };
8328
+ Object.freeze(Object.assign(Object.create(null), table));
8329
+ /** The set of valid builtin names — used by the parser to reject unknown
8330
+ * callees at parse time (immediate author feedback). */
8331
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
8332
+ /**
8333
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
8334
+ *
8335
+ * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
8336
+ * → relational → additive → multiplicative → unary `! -` → call / primary.
8337
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
8338
+ * string validated against the builtin table at parse time, so an unknown
8339
+ * function is rejected immediately (author feedback) and a persisted expression
8340
+ * that references a since-removed builtin degrades at read.
8341
+ *
8342
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
8343
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
8344
+ */
8345
+ /** Binary/logical operator precedence (higher binds tighter). */
8346
+ var BINARY_PRECEDENCE = {
8347
+ "||": 1,
8348
+ "&&": 2,
8349
+ "==": 3,
8350
+ "!=": 3,
8351
+ "<": 4,
8352
+ "<=": 4,
8353
+ ">": 4,
8354
+ ">=": 4,
8355
+ "+": 5,
8356
+ "-": 5,
8357
+ "*": 6,
8358
+ "/": 6,
8359
+ "%": 6
8360
+ };
8361
+ function isLogicalOp(op) {
8362
+ return op === "&&" || op === "||";
8363
+ }
8364
+ function isBinaryOp(op) {
8365
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
8366
+ }
8367
+ var Parser = class {
8368
+ tokens;
8369
+ pos = 0;
8370
+ nodeCount = 0;
8371
+ identifiers = /* @__PURE__ */ new Set();
8372
+ callees = /* @__PURE__ */ new Set();
8373
+ constructor(tokens) {
8374
+ this.tokens = tokens;
8375
+ }
8376
+ parse() {
8377
+ const ast = this.parseTernary();
8378
+ const tok = this.peek();
8379
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
8380
+ return {
8381
+ ast,
8382
+ identifiers: this.identifiers,
8383
+ callees: this.callees,
8384
+ nodeCount: this.nodeCount
8385
+ };
8386
+ }
8387
+ peek() {
8388
+ return this.tokens[this.pos];
8389
+ }
8390
+ next() {
8391
+ return this.tokens[this.pos++];
8392
+ }
8393
+ /** Consume a punctuator token, erroring if the next token isn't it. */
8394
+ expectPunct(punct) {
8395
+ const tok = this.peek();
8396
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
8397
+ this.pos += 1;
8398
+ }
8399
+ matchPunct(punct) {
8400
+ const tok = this.peek();
8401
+ if (tok.type === "punct" && tok.punct === punct) {
8402
+ this.pos += 1;
8403
+ return true;
8404
+ }
8405
+ return false;
8406
+ }
8407
+ countNode() {
8408
+ this.nodeCount += 1;
8409
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
8410
+ }
8411
+ parseTernary() {
8412
+ const test = this.parseBinary(1);
8413
+ if (this.matchPunct("?")) {
8414
+ const consequent = this.parseTernary();
8415
+ this.expectPunct(":");
8416
+ const alternate = this.parseTernary();
8417
+ this.countNode();
8418
+ return {
8419
+ kind: "conditional",
8420
+ test,
8421
+ consequent,
8422
+ alternate
8423
+ };
8424
+ }
8425
+ return test;
8426
+ }
8427
+ parseBinary(minPrec) {
8428
+ let left = this.parseUnary();
8429
+ for (;;) {
8430
+ const tok = this.peek();
8431
+ if (tok.type !== "punct") break;
8432
+ const prec = BINARY_PRECEDENCE[tok.punct];
8433
+ if (prec === void 0 || prec < minPrec) break;
8434
+ const op = tok.punct;
8435
+ this.pos += 1;
8436
+ const right = this.parseBinary(prec + 1);
8437
+ this.countNode();
8438
+ if (isLogicalOp(op)) left = {
8439
+ kind: "logical",
8440
+ op,
8441
+ left,
8442
+ right
8443
+ };
8444
+ else if (isBinaryOp(op)) left = {
8445
+ kind: "binary",
8446
+ op,
8447
+ left,
8448
+ right
8449
+ };
8450
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
8451
+ }
8452
+ return left;
8453
+ }
8454
+ parseUnary() {
8455
+ const tok = this.peek();
8456
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
8457
+ const op = tok.punct;
8458
+ this.pos += 1;
8459
+ const operand = this.parseUnary();
8460
+ this.countNode();
8461
+ return {
8462
+ kind: "unary",
8463
+ op,
8464
+ operand
8465
+ };
8466
+ }
8467
+ return this.parsePrimary();
8468
+ }
8469
+ parsePrimary() {
8470
+ const tok = this.next();
8471
+ switch (tok.type) {
8472
+ case "number":
8473
+ this.countNode();
8474
+ return {
8475
+ kind: "literal",
8476
+ value: tok.value
8477
+ };
8478
+ case "string":
8479
+ this.countNode();
8480
+ return {
8481
+ kind: "literal",
8482
+ value: tok.value
8483
+ };
8484
+ case "keyword":
8485
+ this.countNode();
8486
+ return {
8487
+ kind: "literal",
8488
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
8489
+ };
8490
+ case "identifier": {
8491
+ const nextTok = this.peek();
8492
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
8493
+ this.identifiers.add(tok.name);
8494
+ this.countNode();
8495
+ return {
8496
+ kind: "identifier",
8497
+ name: tok.name
8498
+ };
8499
+ }
8500
+ case "punct":
8501
+ if (tok.punct === "(") {
8502
+ const inner = this.parseTernary();
8503
+ this.expectPunct(")");
8504
+ return inner;
8505
+ }
8506
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
8507
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
8508
+ }
8509
+ }
8510
+ parseCall(callee, pos) {
8511
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
8512
+ this.expectPunct("(");
8513
+ const args = [];
8514
+ if (!this.matchPunct(")")) for (;;) {
8515
+ args.push(this.parseTernary());
8516
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
8517
+ if (this.matchPunct(",")) continue;
8518
+ this.expectPunct(")");
8519
+ break;
8520
+ }
8521
+ this.callees.add(callee);
8522
+ this.countNode();
8523
+ return {
8524
+ kind: "call",
8525
+ callee,
8526
+ args
8527
+ };
8528
+ }
8529
+ };
8530
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
8531
+ * `ExpressionParseError` on any lexical or grammatical failure. */
8532
+ function parseExpression(source) {
8533
+ return new Parser(tokenize(source)).parse();
8534
+ }
8535
+ Object.freeze({});
8536
+ /**
8537
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
8538
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
8539
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
8540
+ * one per read on a hot resolve path.
8541
+ *
8542
+ * The cache is a module-level singleton: entries are pure, content-addressed
8543
+ * ASTs keyed by the raw source string, so sharing one instance across all
8544
+ * callers is safe and maximises hit rate.
8545
+ */
8546
+ var cache = /* @__PURE__ */ new Map();
8547
+ function getCached(source) {
8548
+ const hit = cache.get(source);
8549
+ if (hit !== void 0) {
8550
+ cache.delete(source);
8551
+ cache.set(source, hit);
8552
+ return hit;
8553
+ }
8554
+ let result;
8555
+ try {
8556
+ result = {
8557
+ ok: true,
8558
+ parsed: parseExpression(source)
8559
+ };
8560
+ } catch (err) {
8561
+ result = {
8562
+ ok: false,
8563
+ error: err instanceof ExpressionParseError ? err.message : String(err)
8564
+ };
8565
+ }
8566
+ cache.set(source, result);
8567
+ if (cache.size > 256) {
8568
+ const oldest = cache.keys().next().value;
8569
+ if (oldest !== void 0) cache.delete(oldest);
8570
+ }
8571
+ return result;
8572
+ }
8573
+ /** Compile `source`, returning a discriminated result instead of throwing.
8574
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
8575
+ function compileExpressionSafe(source) {
8576
+ return getCached(source);
8577
+ }
8578
+ /**
8579
+ * Author-time validation. Returns `null` when the source is valid, else a
8580
+ * human-readable error message. Checks: the expression compiles; binding count
8581
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
8582
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
8583
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
8584
+ */
8585
+ function validateExpressionSource(src) {
8586
+ const names = Object.keys(src.bindings);
8587
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
8588
+ for (const name of names) {
8589
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
8590
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
8591
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
8592
+ }
8593
+ const compiled = compileExpressionSafe(src.expr);
8594
+ if (!compiled.ok) return compiled.error;
8595
+ const bound = new Set(names);
8596
+ for (const id of compiled.parsed.identifiers) {
8597
+ if (id === "now") continue;
8598
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
8599
+ }
8600
+ return null;
8601
+ }
8602
+ /**
7839
8603
  * Accessory device helpers — shared across drivers.
7840
8604
  *
7841
8605
  * Many vendor-specific drivers register accessory child devices on
@@ -9769,7 +10533,8 @@ var MotionAnalysisResultSchema = object({
9769
10533
  });
9770
10534
  method(object({
9771
10535
  deviceId: number(),
9772
- frame: FrameInputSchema
10536
+ frame: FrameInputSchema.optional(),
10537
+ frameHandle: FrameHandleSchema.optional()
9773
10538
  }), MotionAnalysisResultSchema, { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), object({
9774
10539
  deviceId: number(),
9775
10540
  detected: boolean(),
@@ -10076,11 +10841,20 @@ var pipelineExecutorCapability = {
10076
10841
  * legacy call shape used by existing benchmark code; once all
10077
10842
  * callers pass it explicitly we make it required.
10078
10843
  *
10079
- * Exactly one of `frame`, `imageBase64`, `referenceImage` must be
10080
- * provided:
10844
+ * Exactly one of `frame`, `frameHandle`, `imageBase64`,
10845
+ * `referenceImage` must be provided:
10081
10846
  * - `frame`: runtime dispatch path (runner → decoded broker frame).
10082
10847
  * Carries the raw buffer, dimensions, and format; the executor
10083
10848
  * uses it directly without base64 round-tripping.
10849
+ * - `frameHandle` (CB5): a zero-pixel shm `FrameHandle` for the SAME
10850
+ * decoded frame. Both runner and executor are hub-local processes
10851
+ * sharing `/dev/shm`, so the executor maps the named segment and
10852
+ * reads the pixels back zero-copy — eliminating the ~1.2MB
10853
+ * re-serialisation over UDS/MsgPack the `frame` path pays per call.
10854
+ * High-risk: the FrameRing is a latest-wins seqlock with no
10855
+ * refcount, so a recycled slot yields a null read; the executor
10856
+ * then degrades to an empty result and the runner ships pixels via
10857
+ * `frame` as the fallback (queue-depth gated on the runner side).
10084
10858
  * - `imageBase64`: one-shot test path (benchmark ImageTab).
10085
10859
  * - `referenceImage`: named file from the reference-image store.
10086
10860
  */
@@ -10088,6 +10862,12 @@ var pipelineExecutorCapability = {
10088
10862
  engine: PipelineEngineChoiceSchema.optional(),
10089
10863
  steps: array(PipelineStepInputSchema).min(1),
10090
10864
  frame: FrameInputSchema.optional(),
10865
+ /**
10866
+ * CB5 shm passthrough — a `FrameHandle` naming the same ring slot
10867
+ * the decoded pixels live in. One more member of the one-of
10868
+ * frame/frameHandle/image/imageBase64/referenceImage group.
10869
+ */
10870
+ frameHandle: FrameHandleSchema.optional(),
10091
10871
  imageBase64: string().optional(),
10092
10872
  /**
10093
10873
  * Binary JPEG bytes — preferred over `imageBase64` on internal
@@ -10981,6 +11761,113 @@ object({
10981
11761
  lastFetchedAt: number()
10982
11762
  });
10983
11763
  DeviceType.Sensor;
11764
+ /**
11765
+ * Feeder connectivity / power status — mirrors the HA petkit device-status
11766
+ * enum: `normal` (online, mains), `offline` (not reaching PetKit cloud),
11767
+ * `on_batteries` (running on battery backup). `null` until first reported.
11768
+ */
11769
+ var PetFeederDeviceStatusSchema = _enum([
11770
+ "normal",
11771
+ "offline",
11772
+ "on_batteries"
11773
+ ]);
11774
+ var gramsPortion = number().int().min(4).max(200);
11775
+ object({
11776
+ /** Food currently in the bowl (grams). Null when the device has not
11777
+ * reported a reading yet. On dual-hopper models this is the combined
11778
+ * bowl reading; per-hopper levels live in `food1`/`food2`. */
11779
+ foodLevel: number().nullable(),
11780
+ /** Hopper-1 food level (grams) on dual-hopper feeders; null on
11781
+ * single-hopper models. */
11782
+ food1: number().nullable(),
11783
+ /** Hopper-2 food level (grams) on dual-hopper feeders; null on
11784
+ * single-hopper models. */
11785
+ food2: number().nullable(),
11786
+ /** Derived low-food flag — mirrors the HA `food_level` binary_sensor
11787
+ * (`device_class: problem`, on = low). True when the bowl is empty /
11788
+ * below the feeder's low threshold. */
11789
+ lowFood: boolean(),
11790
+ /** Battery charge 0..100 (%). Null on mains-powered models or when the
11791
+ * device has no battery reading. */
11792
+ batteryPower: number().min(0).max(100).nullable(),
11793
+ /** Days of desiccant life remaining. Null when the model has no
11794
+ * desiccant sensor. */
11795
+ desiccantLeftDays: number().nullable(),
11796
+ /** True while a feed is in progress. */
11797
+ feeding: boolean(),
11798
+ /** Decoded connectivity / power status (HA petkit device-status enum).
11799
+ * Null until the device has reported a status. */
11800
+ status: PetFeederDeviceStatusSchema.nullable(),
11801
+ /** Decoded human-readable fault message. Null (or the device's `no_error`
11802
+ * sentinel) means healthy; a non-null string is an active fault. Pairs
11803
+ * with `errorCode` for consumers that want the raw integer. */
11804
+ error: string().nullable(),
11805
+ /** Raw device error code (0 / null = no error). */
11806
+ errorCode: number().nullable(),
11807
+ /** True for D4S/D4SH dual-hopper hardware — gates the per-hopper UI. */
11808
+ isDualHopper: boolean(),
11809
+ /** Child-lock (manual-lock) setting — buttons on the unit are disabled. */
11810
+ childLock: boolean(),
11811
+ /** Front indicator-light setting. */
11812
+ indicatorLight: boolean(),
11813
+ /** Play a chime when dispensing. */
11814
+ feedSound: boolean(),
11815
+ /** Speaker / prompt volume level (device-scaled integer). */
11816
+ volume: number(),
11817
+ /** Ms epoch when the slice was last refreshed from the cloud. */
11818
+ lastFetchedAt: number()
11819
+ });
11820
+ DeviceType.PetFeeder, method(object({
11821
+ deviceId: number().int().nonnegative(),
11822
+ grams: gramsPortion.optional(),
11823
+ hopper1: gramsPortion.optional(),
11824
+ hopper2: gramsPortion.optional()
11825
+ }), _void(), {
11826
+ kind: "mutation",
11827
+ auth: "admin"
11828
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
11829
+ kind: "mutation",
11830
+ auth: "admin"
11831
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
11832
+ kind: "mutation",
11833
+ auth: "admin"
11834
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
11835
+ kind: "mutation",
11836
+ auth: "admin"
11837
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
11838
+ kind: "mutation",
11839
+ auth: "admin"
11840
+ }), method(object({
11841
+ deviceId: number().int().nonnegative(),
11842
+ soundId: number().int().nonnegative()
11843
+ }), _void(), {
11844
+ kind: "mutation",
11845
+ auth: "admin"
11846
+ }), method(object({
11847
+ deviceId: number().int().nonnegative(),
11848
+ on: boolean()
11849
+ }), _void(), {
11850
+ kind: "mutation",
11851
+ auth: "admin"
11852
+ }), method(object({
11853
+ deviceId: number().int().nonnegative(),
11854
+ on: boolean()
11855
+ }), _void(), {
11856
+ kind: "mutation",
11857
+ auth: "admin"
11858
+ }), method(object({
11859
+ deviceId: number().int().nonnegative(),
11860
+ on: boolean()
11861
+ }), _void(), {
11862
+ kind: "mutation",
11863
+ auth: "admin"
11864
+ }), method(object({
11865
+ deviceId: number().int().nonnegative(),
11866
+ level: number().int().nonnegative()
11867
+ }), _void(), {
11868
+ kind: "mutation",
11869
+ auth: "admin"
11870
+ });
10984
11871
  object({
10985
11872
  /** Instantaneous power draw in watts. */
10986
11873
  watts: number().optional(),
@@ -12914,10 +13801,12 @@ method(object({ codec: string() }), boolean()), method(_void(), object({
12914
13801
  url: string()
12915
13802
  }), _void()), method(object({
12916
13803
  sessionId: string(),
12917
- maxCount: number().default(1)
13804
+ maxCount: number().default(1),
13805
+ waitMs: number().optional()
12918
13806
  }), array(DecodedFrameSchema)), method(object({
12919
13807
  sessionId: string(),
12920
- maxCount: number().default(1)
13808
+ maxCount: number().default(1),
13809
+ waitMs: number().optional()
12921
13810
  }), array(FrameHandleSchema)), method(object({ handle: FrameHandleSchema }), DecodedFrameSchema.nullable()), method(object({ sessionId: string() }), ShmRingStatsSchema.nullable()), method(object({
12922
13811
  sessionId: string(),
12923
13812
  config: DecoderSessionConfigSchema.partial()
@@ -13209,30 +14098,57 @@ var ChildLayoutEntrySchema = object({
13209
14098
  * LITERAL source carries a per-device constant (no sibling is read); a
13210
14099
  * GLOBAL source (P2e) copies ANY device's status field, addressed by the
13211
14100
  * source device's full re-sync-stable `stableId`. */
14101
+ var DeviceLinkFieldSourceSchema = object({
14102
+ kind: literal("field").optional(),
14103
+ sourceKey: string(),
14104
+ cap: string(),
14105
+ fieldPath: string()
14106
+ });
14107
+ var DeviceLinkLiteralSourceSchema = object({
14108
+ kind: literal("literal"),
14109
+ value: union([
14110
+ string(),
14111
+ number(),
14112
+ boolean(),
14113
+ _null()
14114
+ ])
14115
+ });
14116
+ var DeviceLinkGlobalSourceSchema = object({
14117
+ kind: literal("global"),
14118
+ sourceStableId: string(),
14119
+ cap: string(),
14120
+ fieldPath: string()
14121
+ });
14122
+ /** Expression source (Stage X): compute the target field from N named bindings
14123
+ * via the safe expression engine. Bindings are field | literal | global — never
14124
+ * another expression (no nesting). The `superRefine` runs the SAME author-time
14125
+ * validation as `validateExpressionSource` (compiles the expr, checks binding
14126
+ * names + identifier coverage) so every wire boundary that parses a DeviceLink
14127
+ * (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
14128
+ * Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
14129
+ var DeviceLinkExpressionSourceSchema = object({
14130
+ kind: literal("expression"),
14131
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
14132
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), union([
14133
+ DeviceLinkFieldSourceSchema,
14134
+ DeviceLinkLiteralSourceSchema,
14135
+ DeviceLinkGlobalSourceSchema
14136
+ ]))
14137
+ }).superRefine((src, ctx) => {
14138
+ const err = validateExpressionSource(src);
14139
+ if (err !== null) ctx.addIssue({
14140
+ code: "custom",
14141
+ message: err,
14142
+ path: ["expr"]
14143
+ });
14144
+ });
13212
14145
  var DeviceLinkSchema = object({
13213
14146
  id: string(),
13214
14147
  source: union([
13215
- object({
13216
- kind: literal("field").optional(),
13217
- sourceKey: string(),
13218
- cap: string(),
13219
- fieldPath: string()
13220
- }),
13221
- object({
13222
- kind: literal("literal"),
13223
- value: union([
13224
- string(),
13225
- number(),
13226
- boolean(),
13227
- _null()
13228
- ])
13229
- }),
13230
- object({
13231
- kind: literal("global"),
13232
- sourceStableId: string(),
13233
- cap: string(),
13234
- fieldPath: string()
13235
- })
14148
+ DeviceLinkFieldSourceSchema,
14149
+ DeviceLinkLiteralSourceSchema,
14150
+ DeviceLinkGlobalSourceSchema,
14151
+ DeviceLinkExpressionSourceSchema
13236
14152
  ]),
13237
14153
  target: object({
13238
14154
  cap: string(),
@@ -13262,6 +14178,31 @@ var DeviceLinkSchema = object({
13262
14178
  })
13263
14179
  ]).optional()
13264
14180
  });
14181
+ /** Cap-wire shape of a per-cap display refinement — mirrors
14182
+ * `DeviceCapDisplayOverride` in `device-management.ts`. */
14183
+ var DeviceCapDisplayOverrideSchema = object({
14184
+ unit: string().min(1).optional(),
14185
+ precision: number().int().min(0).max(10).optional()
14186
+ });
14187
+ /** Cap-wire shape of an operator-authored per-device display override —
14188
+ * mirrors `DeviceDisplayOverride` in `device-management.ts`. `precision`
14189
+ * bounds mirror `numeric-sensor.cap.ts` (`int 0-10`). */
14190
+ var DeviceDisplayOverrideSchema = object({
14191
+ icon: string().min(1).optional(),
14192
+ label: string().min(1).optional(),
14193
+ unit: string().min(1).optional(),
14194
+ precision: number().int().min(0).max(10).optional(),
14195
+ hidden: boolean().optional(),
14196
+ perCap: record(string(), DeviceCapDisplayOverrideSchema).optional()
14197
+ });
14198
+ /** Cap-wire shape of a per-role display default — mirrors `RoleDisplayDefault`
14199
+ * in `device-management.ts`. Keyed by `DeviceRole` string (role strings cross
14200
+ * the wire as plain strings everywhere else — cf. `DeviceInfoSchema.role`). */
14201
+ var RoleDisplayDefaultSchema = object({
14202
+ unit: string().min(1).optional(),
14203
+ precision: number().int().min(0).max(10).optional(),
14204
+ icon: string().min(1).optional()
14205
+ });
13265
14206
  /**
13266
14207
  * Serializable projection of a live IDevice.
13267
14208
  * Returned by listAll, getDevice, getChildren.
@@ -13317,7 +14258,9 @@ var DeviceInfoSchema = object({
13317
14258
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
13318
14259
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
13319
14260
  /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
13320
- deviceLinks: array(DeviceLinkSchema).readonly().optional()
14261
+ deviceLinks: array(DeviceLinkSchema).readonly().optional(),
14262
+ /** Operator-authored per-device display override. See `DeviceMeta.display`. */
14263
+ display: DeviceDisplayOverrideSchema.optional()
13321
14264
  });
13322
14265
  var ConfigEntrySchema = object({
13323
14266
  key: string(),
@@ -13382,7 +14325,9 @@ var DeviceMetaSchema = object({
13382
14325
  deviceLinks: array(DeviceLinkSchema).readonly().optional(),
13383
14326
  /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
13384
14327
  * Optional: only present for accessory children that carry a known role. */
13385
- role: string().nullable().optional()
14328
+ role: string().nullable().optional(),
14329
+ /** Operator-authored per-device display override. See `DeviceMeta.display`. */
14330
+ display: DeviceDisplayOverrideSchema.optional()
13386
14331
  });
13387
14332
  /** ConfigUISchema passed through as unknown — mirrors device-provider's CreationSchemaOutputSchema */
13388
14333
  var ConfigUISchemaOutput = unknown().nullable();
@@ -13476,6 +14421,15 @@ method(object({
13476
14421
  }), _void(), {
13477
14422
  kind: "mutation",
13478
14423
  auth: "admin"
14424
+ }), method(object({
14425
+ deviceId: number(),
14426
+ display: DeviceDisplayOverrideSchema.nullable()
14427
+ }), _void(), {
14428
+ kind: "mutation",
14429
+ auth: "admin"
14430
+ }), method(object({}), object({ defaults: record(string(), RoleDisplayDefaultSchema) }), { kind: "query" }), method(object({ defaults: record(string(), RoleDisplayDefaultSchema) }), _void(), {
14431
+ kind: "mutation",
14432
+ auth: "admin"
13479
14433
  }), method(object({
13480
14434
  deviceId: number(),
13481
14435
  includeSynthesizable: boolean().optional()
@@ -15000,7 +15954,10 @@ var pipelineOrchestratorCapability = {
15000
15954
  methods: {
15001
15955
  /**
15002
15956
  * Pin a camera's pipeline to a specific agent (L1 affinity).
15003
- * The orchestrator re-evaluates the assignment immediately.
15957
+ * The orchestrator re-evaluates the assignment immediately and persists
15958
+ * the pin under the canonical `pipelineNodeId` device-store key (the
15959
+ * legacy `preferredAgent` key is nulled on write and kept only as a
15960
+ * read-only fallback for stores written before the unification).
15004
15961
  */
15005
15962
  assignPipeline: method(object({
15006
15963
  deviceId: number(),
@@ -15011,8 +15968,9 @@ var pipelineOrchestratorCapability = {
15011
15968
  }),
15012
15969
  /**
15013
15970
  * Clear a camera's pipeline pin and let the auto-balancer re-pick
15014
- * the optimal agent. The orchestrator persists `preferredAgent=null`
15015
- * (and `pipelineNodeId='auto'`), then re-runs the balancer with the
15971
+ * the optimal agent. The orchestrator persists the canonical
15972
+ * `pipelineNodeId='auto'` (and nulls the legacy `preferredAgent`),
15973
+ * then re-runs the balancer with the
15016
15974
  * cached `RunnerCameraConfig` and migrates only when the chosen
15017
15975
  * node differs. The camera stays in `getPipelineAssignments()` —
15018
15976
  * just with `pinned=false`. If no runner is currently available
@@ -17513,7 +18471,10 @@ var HwAccelBackendInputSchema = _enum([
17513
18471
  "webgpu",
17514
18472
  "none"
17515
18473
  ]).nullable().optional();
17516
- var HwAccelResolutionSchema = object({ preferred: array(string()).readonly() });
18474
+ var HwAccelResolutionSchema = object({
18475
+ preferred: array(string()).readonly(),
18476
+ rationale: string()
18477
+ });
17517
18478
  var HardwareEncoderIdSchema = _enum([
17518
18479
  "h264_videotoolbox",
17519
18480
  "hevc_videotoolbox",
@@ -17618,10 +18579,7 @@ var ResolvedInferenceConfigSchema = object({
17618
18579
  format: ModelFormatSchema,
17619
18580
  reason: string()
17620
18581
  });
17621
- method(_void(), PlatformCapabilitiesSchema), method(_void(), HardwareInfoSchema), method(object({ requirements: array(ModelRequirementSchema).readonly() }), ResolvedInferenceConfigSchema), method(object({
17622
- prefer: HwAccelBackendInputSchema,
17623
- nodeId: string().optional()
17624
- }), HwAccelResolutionSchema), method(_void(), HardwareEncodersSchema), method(_void(), HardwareEncodersSchema, {
18582
+ method(_void(), PlatformCapabilitiesSchema), method(_void(), HardwareInfoSchema), method(object({ requirements: array(ModelRequirementSchema).readonly() }), ResolvedInferenceConfigSchema), method(object({ prefer: HwAccelBackendInputSchema }), HwAccelResolutionSchema), method(_void(), HardwareEncodersSchema), method(_void(), HardwareEncodersSchema, {
17625
18583
  kind: "mutation",
17626
18584
  auth: "admin"
17627
18585
  }), method(_void(), HardwareDecodeAccelsSchema), method(_void(), HardwareDecodeAccelsSchema, {
@@ -19326,6 +20284,12 @@ Object.freeze({
19326
20284
  addonId: null,
19327
20285
  access: "view"
19328
20286
  },
20287
+ "deviceManager.getRoleDisplayDefaults": {
20288
+ capName: "device-manager",
20289
+ capScope: "system",
20290
+ addonId: null,
20291
+ access: "view"
20292
+ },
19329
20293
  "deviceManager.getSettingsSchema": {
19330
20294
  capName: "device-manager",
19331
20295
  capScope: "system",
@@ -19476,6 +20440,12 @@ Object.freeze({
19476
20440
  addonId: null,
19477
20441
  access: "create"
19478
20442
  },
20443
+ "deviceManager.setDisplay": {
20444
+ capName: "device-manager",
20445
+ capScope: "system",
20446
+ addonId: null,
20447
+ access: "create"
20448
+ },
19479
20449
  "deviceManager.setIntegrationId": {
19480
20450
  capName: "device-manager",
19481
20451
  capScope: "system",
@@ -19518,6 +20488,12 @@ Object.freeze({
19518
20488
  addonId: null,
19519
20489
  access: "create"
19520
20490
  },
20491
+ "deviceManager.setRoleDisplayDefaults": {
20492
+ capName: "device-manager",
20493
+ capScope: "system",
20494
+ addonId: null,
20495
+ access: "create"
20496
+ },
19521
20497
  "deviceManager.setStreamProfileMap": {
19522
20498
  capName: "device-manager",
19523
20499
  capScope: "system",
@@ -20568,6 +21544,66 @@ Object.freeze({
20568
21544
  addonId: null,
20569
21545
  access: "create"
20570
21546
  },
21547
+ "petFeeder.callPet": {
21548
+ capName: "pet-feeder",
21549
+ capScope: "device",
21550
+ addonId: null,
21551
+ access: "create"
21552
+ },
21553
+ "petFeeder.cancelFeed": {
21554
+ capName: "pet-feeder",
21555
+ capScope: "device",
21556
+ addonId: null,
21557
+ access: "create"
21558
+ },
21559
+ "petFeeder.feed": {
21560
+ capName: "pet-feeder",
21561
+ capScope: "device",
21562
+ addonId: null,
21563
+ access: "create"
21564
+ },
21565
+ "petFeeder.markFoodReplenished": {
21566
+ capName: "pet-feeder",
21567
+ capScope: "device",
21568
+ addonId: null,
21569
+ access: "create"
21570
+ },
21571
+ "petFeeder.playSound": {
21572
+ capName: "pet-feeder",
21573
+ capScope: "device",
21574
+ addonId: null,
21575
+ access: "create"
21576
+ },
21577
+ "petFeeder.resetDesiccant": {
21578
+ capName: "pet-feeder",
21579
+ capScope: "device",
21580
+ addonId: null,
21581
+ access: "delete"
21582
+ },
21583
+ "petFeeder.setChildLock": {
21584
+ capName: "pet-feeder",
21585
+ capScope: "device",
21586
+ addonId: null,
21587
+ access: "create"
21588
+ },
21589
+ "petFeeder.setFeedSound": {
21590
+ capName: "pet-feeder",
21591
+ capScope: "device",
21592
+ addonId: null,
21593
+ access: "create"
21594
+ },
21595
+ "petFeeder.setIndicatorLight": {
21596
+ capName: "pet-feeder",
21597
+ capScope: "device",
21598
+ addonId: null,
21599
+ access: "create"
21600
+ },
21601
+ "petFeeder.setVolume": {
21602
+ capName: "pet-feeder",
21603
+ capScope: "device",
21604
+ addonId: null,
21605
+ access: "create"
21606
+ },
20571
21607
  "pipelineAnalytics.clearTracks": {
20572
21608
  capName: "pipeline-analytics",
20573
21609
  capScope: "device",
@@ -23940,6 +24976,14 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
23940
24976
  if (!this._cameraSettingsState) this._cameraSettingsState = this.state("cameraSettings", CameraSettingsMapSchema, {});
23941
24977
  return this._cameraSettingsState;
23942
24978
  }
24979
+ /** One-shot `migrateLegacyFlagsToBindings` guard flag. Absent or
24980
+ * corrupt ⇒ `false` (the migration runs — same fallback as the old
24981
+ * raw `store[key] === true` check). */
24982
+ _bindingsMigrationDoneState = null;
24983
+ get bindingsMigrationDoneState() {
24984
+ if (!this._bindingsMigrationDoneState) this._bindingsMigrationDoneState = this.state("bindingsMigration_v1_done", boolean(), false);
24985
+ return this._bindingsMigrationDoneState;
24986
+ }
23943
24987
  /**
23944
24988
  * Per-camera zones CRUD provider. Constructed lazily in `onInitialize`
23945
24989
  * because it captures `this.ctx` for settings + api access; cleared
@@ -24119,7 +25163,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24119
25163
  async onInitialize() {
24120
25164
  this.initTimestamp = Date.now();
24121
25165
  try {
24122
- const stored = await this.ctx.settings?.readAddonStore() ?? {};
25166
+ const stored = await this.resolveGlobalStore();
24123
25167
  this.globalSettings = { ...stored };
24124
25168
  this.applyRuntimeSettings(this.globalSettings);
24125
25169
  } catch (err) {
@@ -24331,7 +25375,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24331
25375
  this.pendingRetryTimer = setInterval(() => void this.retryPendingDispatches(), PENDING_RETRY_INTERVAL_MS);
24332
25376
  this.ctx.logger.info("Pipeline orchestrator detection-wiring subscriptions installed");
24333
25377
  this.migrateLegacyFlagsToBindings().catch((err) => {
24334
- this.ctx.logger.warn("bindings migration failed — will retry on next boot", { meta: { error: errMsg(err) } });
25378
+ this.ctxIfReady?.logger.warn("bindings migration failed — will retry on next boot", { meta: { error: errMsg(err) } });
24335
25379
  });
24336
25380
  this.zoneRulesProvider = new ZoneRulesProvider({
24337
25381
  logger: this.ctx.logger.child("zone-rules"),
@@ -24461,9 +25505,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24461
25505
  * persist the flag after a successful pass.
24462
25506
  */
24463
25507
  async migrateLegacyFlagsToBindings() {
24464
- const MIGRATION_KEY = "bindingsMigration_v1_done";
24465
- const store = await this.ctx.settings?.readAddonStore() ?? {};
24466
- if (store[MIGRATION_KEY] === true) return;
25508
+ if (await this.bindingsMigrationDoneState.get()) return;
24467
25509
  let api = this.api;
24468
25510
  for (let i = 0; !api && i < 30; i++) {
24469
25511
  await new Promise((r) => setTimeout(r, 200));
@@ -24519,10 +25561,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24519
25561
  });
24520
25562
  detectionDisabled++;
24521
25563
  }
24522
- await this.ctx.settings?.writeAddonStore({
24523
- ...store,
24524
- [MIGRATION_KEY]: true
24525
- });
25564
+ await this.bindingsMigrationDoneState.set(true);
24526
25565
  this.ctx.logger.info("bindings migration complete", { meta: {
24527
25566
  cameras: cameras.length,
24528
25567
  audioDisabled,
@@ -24661,9 +25700,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24661
25700
  }
24662
25701
  }
24663
25702
  }
24664
- const pipelinePin = (await this.ctx.settings?.readDeviceStore(runnerConfig.deviceId) ?? {})["pipelineNodeId"];
24665
- const legacyPreferred = await this.readPreferredAgent(runnerConfig.deviceId);
24666
- const preferredAgent = typeof pipelinePin === "string" && pipelinePin !== "auto" ? pipelinePin : legacyPreferred;
25703
+ const preferredAgent = await this.readPipelinePin(runnerConfig.deviceId);
24667
25704
  const decision = balance({
24668
25705
  nodes: await this.collectAgentLoad({ onlyEnabled: true }),
24669
25706
  preferredAgent,
@@ -24735,7 +25772,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24735
25772
  if (!this.ctx) throw new Error("PipelineOrchestrator: assignPipeline called before initialize");
24736
25773
  const eligible = this.detectionEligibleNodes(input.deviceId);
24737
25774
  if (!eligible.includes(input.agentNodeId)) throw new Error(`Cannot pin camera ${input.deviceId} detection to '${input.agentNodeId}': the node cannot obtain this camera's decoded frames (frame-source nodes: ${eligible.join(", ") || "none"}). Add it to Enabled Decoder Nodes first.`);
24738
- await this.ctx.settings?.writeDeviceStore(input.deviceId, { [PREFERRED_AGENT_SETTING]: input.agentNodeId }).catch((err) => {
25775
+ await this.ctx.settings?.writeDeviceStore(input.deviceId, {
25776
+ pipelineNodeId: input.agentNodeId,
25777
+ [PREFERRED_AGENT_SETTING]: null
25778
+ }).catch((err) => {
24739
25779
  const msg = errMsg(err);
24740
25780
  this.ctx.logger.warn("assignPipeline: failed to persist pin", {
24741
25781
  tags: { deviceId: input.deviceId },
@@ -24845,7 +25885,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
24845
25885
  for (const [deviceId, config] of this.cameraConfigs) {
24846
25886
  const current = this.assignments.get(deviceId);
24847
25887
  if (current?.pinned) continue;
24848
- const preferredAgent = await this.readPreferredAgent(deviceId);
25888
+ const preferredAgent = await this.readPipelinePin(deviceId);
24849
25889
  const decision = balance({
24850
25890
  nodes: talliedLoads(),
24851
25891
  preferredAgent,
@@ -25235,11 +26275,22 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25235
26275
  if (timer) clearTimeout(timer);
25236
26276
  }
25237
26277
  }
25238
- async readPreferredAgent(deviceId) {
26278
+ /**
26279
+ * Resolve the persisted pipeline-node pin for a device. `pipelineNodeId` is
26280
+ * the canonical key (matching the sibling `decoderNodeId` / `audioNodeId`
26281
+ * `'auto'`-sentinel pattern); the legacy `preferredAgent` key is a read-only
26282
+ * fallback kept for backward compatibility with stores written before the
26283
+ * unification. A single device-store read serves both. Returns a concrete
26284
+ * node id, or `null` when neither key holds a pin (auto-balance).
26285
+ */
26286
+ async readPipelinePin(deviceId) {
25239
26287
  if (!this.ctx?.settings) return null;
25240
26288
  try {
25241
- const value = (await this.ctx.settings.readDeviceStore(deviceId))[PREFERRED_AGENT_SETTING];
25242
- return typeof value === "string" && value.length > 0 ? value : null;
26289
+ const settings = await this.ctx.settings.readDeviceStore(deviceId);
26290
+ const pipelinePin = settings["pipelineNodeId"];
26291
+ if (typeof pipelinePin === "string" && pipelinePin.length > 0 && pipelinePin !== "auto") return pipelinePin;
26292
+ const legacy = settings[PREFERRED_AGENT_SETTING];
26293
+ return typeof legacy === "string" && legacy.length > 0 ? legacy : null;
25243
26294
  } catch {
25244
26295
  return null;
25245
26296
  }
@@ -25380,7 +26431,10 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
25380
26431
  } });
25381
26432
  continue;
25382
26433
  }
25383
- if (assignment.pinned && this.failoverPolicy.pinnedOnDisconnect === "unpin-and-migrate") await this.ctx.settings?.writeDeviceStore(deviceId, { [PREFERRED_AGENT_SETTING]: null }).catch(() => {});
26434
+ if (assignment.pinned && this.failoverPolicy.pinnedOnDisconnect === "unpin-and-migrate") await this.ctx.settings?.writeDeviceStore(deviceId, {
26435
+ [PREFERRED_AGENT_SETTING]: null,
26436
+ pipelineNodeId: "auto"
26437
+ }).catch(() => {});
25384
26438
  affected.push({
25385
26439
  deviceId,
25386
26440
  config
@@ -27133,8 +28187,8 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
27133
28187
  ] });
27134
28188
  }
27135
28189
  async updateGlobalSettings(patch) {
27136
- await this.ctx.settings.writeAddonStore(patch);
27137
- const full = await this.ctx.settings?.readAddonStore() ?? {};
28190
+ await super.updateGlobalSettings(patch);
28191
+ const full = await this.resolveGlobalStore();
27138
28192
  this.globalSettings = { ...full };
27139
28193
  this.applyRuntimeSettings(full);
27140
28194
  const pausedIds = [...this.loadShedState.entries()].filter(([, s]) => s.pausedAt !== null).map(([id]) => id);