@camstack/addon-provider-homematic 1.2.10 → 1.2.11

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 (3) hide show
  1. package/dist/addon.js +1252 -948
  2. package/dist/addon.mjs +1252 -948
  3. package/package.json +1 -1
package/dist/addon.mjs CHANGED
@@ -7943,6 +7943,780 @@ var ConvertResultSchema = object({
7943
7943
  })).readonly()
7944
7944
  });
7945
7945
  /**
7946
+ * Error types for the safe expression engine. Two distinct classes so callers
7947
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
7948
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
7949
+ */
7950
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
7951
+ * the failure is anchored to a character (author-facing inline feedback). */
7952
+ var ExpressionParseError = class extends Error {
7953
+ position;
7954
+ constructor(message, position) {
7955
+ super(message);
7956
+ this.name = "ExpressionParseError";
7957
+ this.position = position;
7958
+ }
7959
+ };
7960
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
7961
+ * result, unknown builtin, step-budget exceeded). */
7962
+ var ExpressionEvalError = class extends Error {
7963
+ constructor(message) {
7964
+ super(message);
7965
+ this.name = "ExpressionEvalError";
7966
+ }
7967
+ };
7968
+ /**
7969
+ * Frozen, null-prototype builtin function table for the expression engine
7970
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
7971
+ * parser rejects any callee not in it, and the evaluator gates each call on an
7972
+ * own-property check against it.
7973
+ *
7974
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
7975
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
7976
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
7977
+ * (there is no `Object.prototype` in the chain), so those names are not
7978
+ * callable — they are simply "unknown function" at parse time.
7979
+ *
7980
+ * Every numeric argument is validated as a finite number and every numeric
7981
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
7982
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
7983
+ * closed rather than emitting a garbage value.
7984
+ */
7985
+ function asFiniteNumber(value, name, index) {
7986
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
7987
+ return value;
7988
+ }
7989
+ function asString$1(value, name, index) {
7990
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
7991
+ return value;
7992
+ }
7993
+ function finiteResult(value, name) {
7994
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
7995
+ return value;
7996
+ }
7997
+ function allFiniteNumbers(args, name) {
7998
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
7999
+ }
8000
+ var INF = Number.POSITIVE_INFINITY;
8001
+ var table = {
8002
+ min: {
8003
+ minArgs: 1,
8004
+ maxArgs: INF,
8005
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
8006
+ },
8007
+ max: {
8008
+ minArgs: 1,
8009
+ maxArgs: INF,
8010
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
8011
+ },
8012
+ abs: {
8013
+ minArgs: 1,
8014
+ maxArgs: 1,
8015
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
8016
+ },
8017
+ floor: {
8018
+ minArgs: 1,
8019
+ maxArgs: 1,
8020
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
8021
+ },
8022
+ ceil: {
8023
+ minArgs: 1,
8024
+ maxArgs: 1,
8025
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
8026
+ },
8027
+ sqrt: {
8028
+ minArgs: 1,
8029
+ maxArgs: 1,
8030
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
8031
+ },
8032
+ round: {
8033
+ minArgs: 1,
8034
+ maxArgs: 2,
8035
+ apply: (args) => {
8036
+ const x = asFiniteNumber(args[0], "round", 0);
8037
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
8038
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
8039
+ const factor = 10 ** digits;
8040
+ return finiteResult(Math.round(x * factor) / factor, "round");
8041
+ }
8042
+ },
8043
+ pow: {
8044
+ minArgs: 2,
8045
+ maxArgs: 2,
8046
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
8047
+ },
8048
+ clamp: {
8049
+ minArgs: 3,
8050
+ maxArgs: 3,
8051
+ apply: (args) => {
8052
+ const x = asFiniteNumber(args[0], "clamp", 0);
8053
+ const lo = asFiniteNumber(args[1], "clamp", 1);
8054
+ const hi = asFiniteNumber(args[2], "clamp", 2);
8055
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
8056
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
8057
+ }
8058
+ },
8059
+ avg: {
8060
+ minArgs: 1,
8061
+ maxArgs: INF,
8062
+ apply: (args) => {
8063
+ const nums = allFiniteNumbers(args, "avg");
8064
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
8065
+ }
8066
+ },
8067
+ sum: {
8068
+ minArgs: 1,
8069
+ maxArgs: INF,
8070
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
8071
+ },
8072
+ coalesce: {
8073
+ minArgs: 1,
8074
+ maxArgs: INF,
8075
+ apply: (args) => {
8076
+ for (const a of args) if (a !== null) return a;
8077
+ return null;
8078
+ }
8079
+ },
8080
+ age: {
8081
+ minArgs: 2,
8082
+ maxArgs: 2,
8083
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
8084
+ },
8085
+ convert: {
8086
+ minArgs: 3,
8087
+ maxArgs: 3,
8088
+ apply: (args, hooks) => {
8089
+ const x = asFiniteNumber(args[0], "convert", 0);
8090
+ const from = asString$1(args[1], "convert", 1).trim();
8091
+ const to = asString$1(args[2], "convert", 2).trim();
8092
+ if (hooks.convert) {
8093
+ const out = hooks.convert(x, from, to);
8094
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
8095
+ return finiteResult(out, "convert");
8096
+ }
8097
+ if (from === to) return x;
8098
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
8099
+ }
8100
+ }
8101
+ };
8102
+ Object.freeze(Object.assign(Object.create(null), table));
8103
+ /** The set of valid builtin names — used by the parser to reject unknown
8104
+ * callees at parse time (immediate author feedback). */
8105
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
8106
+ /**
8107
+ * Resource-bound constants for the safe expression engine.
8108
+ *
8109
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
8110
+ * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
8111
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
8112
+ * work a single author-supplied expression can request, so a hostile or
8113
+ * accidental pathological string can never spend unbounded CPU/memory.
8114
+ */
8115
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
8116
+ * rejected without allocation. */
8117
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
8118
+ /** A legal binding / identifier name. */
8119
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
8120
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
8121
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
8122
+ var RESERVED_BINDING_NAMES = new Set([
8123
+ "now",
8124
+ "true",
8125
+ "false",
8126
+ "null"
8127
+ ]);
8128
+ /**
8129
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
8130
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
8131
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
8132
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
8133
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
8134
+ * is a parse error with a source position, so member access / assignment /
8135
+ * template literals are lexically impossible.
8136
+ */
8137
+ var KEYWORDS = new Set([
8138
+ "true",
8139
+ "false",
8140
+ "null"
8141
+ ]);
8142
+ function isDigit(ch) {
8143
+ return ch >= "0" && ch <= "9";
8144
+ }
8145
+ function isIdentStart(ch) {
8146
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
8147
+ }
8148
+ function isIdentPart(ch) {
8149
+ return isIdentStart(ch) || isDigit(ch);
8150
+ }
8151
+ function isWhitespace(ch) {
8152
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
8153
+ }
8154
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
8155
+ * Throws `ExpressionParseError` on any illegal character or unterminated
8156
+ * string. */
8157
+ function tokenize(source) {
8158
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
8159
+ const tokens = [];
8160
+ let i = 0;
8161
+ const n = source.length;
8162
+ while (i < n) {
8163
+ const ch = source[i];
8164
+ if (isWhitespace(ch)) {
8165
+ i += 1;
8166
+ continue;
8167
+ }
8168
+ if (isDigit(ch)) {
8169
+ const start = i;
8170
+ while (i < n && isDigit(source[i])) i += 1;
8171
+ if (i < n && source[i] === ".") {
8172
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
8173
+ i += 1;
8174
+ while (i < n && isDigit(source[i])) i += 1;
8175
+ }
8176
+ const text = source.slice(start, i);
8177
+ const value = Number(text);
8178
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
8179
+ tokens.push({
8180
+ type: "number",
8181
+ value,
8182
+ pos: start
8183
+ });
8184
+ continue;
8185
+ }
8186
+ if (ch === "'" || ch === "\"") {
8187
+ const quote = ch;
8188
+ const start = i;
8189
+ i += 1;
8190
+ let out = "";
8191
+ let closed = false;
8192
+ while (i < n) {
8193
+ const c = source[i];
8194
+ if (c === "\\") {
8195
+ const next = i + 1 < n ? source[i + 1] : "";
8196
+ if (next === "\\" || next === "'" || next === "\"") {
8197
+ out += next;
8198
+ i += 2;
8199
+ continue;
8200
+ }
8201
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
8202
+ }
8203
+ if (c === quote) {
8204
+ closed = true;
8205
+ i += 1;
8206
+ break;
8207
+ }
8208
+ out += c;
8209
+ i += 1;
8210
+ }
8211
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
8212
+ tokens.push({
8213
+ type: "string",
8214
+ value: out,
8215
+ pos: start
8216
+ });
8217
+ continue;
8218
+ }
8219
+ if (isIdentStart(ch)) {
8220
+ const start = i;
8221
+ while (i < n && isIdentPart(source[i])) i += 1;
8222
+ const text = source.slice(start, i);
8223
+ if (KEYWORDS.has(text)) tokens.push({
8224
+ type: "keyword",
8225
+ keyword: keywordOf(text),
8226
+ pos: start
8227
+ });
8228
+ else tokens.push({
8229
+ type: "identifier",
8230
+ name: text,
8231
+ pos: start
8232
+ });
8233
+ continue;
8234
+ }
8235
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
8236
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
8237
+ tokens.push({
8238
+ type: "punct",
8239
+ punct: two,
8240
+ pos: i
8241
+ });
8242
+ i += 2;
8243
+ continue;
8244
+ }
8245
+ if (isSinglePunct(ch)) {
8246
+ tokens.push({
8247
+ type: "punct",
8248
+ punct: ch,
8249
+ pos: i
8250
+ });
8251
+ i += 1;
8252
+ continue;
8253
+ }
8254
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
8255
+ }
8256
+ tokens.push({
8257
+ type: "eof",
8258
+ pos: n
8259
+ });
8260
+ return tokens;
8261
+ }
8262
+ function keywordOf(text) {
8263
+ if (text === "true") return "true";
8264
+ if (text === "false") return "false";
8265
+ return "null";
8266
+ }
8267
+ function isSinglePunct(ch) {
8268
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
8269
+ }
8270
+ /**
8271
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
8272
+ *
8273
+ * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
8274
+ * → relational → additive → multiplicative → unary `! -` → call / primary.
8275
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
8276
+ * string validated against the builtin table at parse time, so an unknown
8277
+ * function is rejected immediately (author feedback) and a persisted expression
8278
+ * that references a since-removed builtin degrades at read.
8279
+ *
8280
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
8281
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
8282
+ */
8283
+ /** Binary/logical operator precedence (higher binds tighter). */
8284
+ var BINARY_PRECEDENCE = {
8285
+ "||": 1,
8286
+ "&&": 2,
8287
+ "==": 3,
8288
+ "!=": 3,
8289
+ "<": 4,
8290
+ "<=": 4,
8291
+ ">": 4,
8292
+ ">=": 4,
8293
+ "+": 5,
8294
+ "-": 5,
8295
+ "*": 6,
8296
+ "/": 6,
8297
+ "%": 6
8298
+ };
8299
+ function isLogicalOp(op) {
8300
+ return op === "&&" || op === "||";
8301
+ }
8302
+ function isBinaryOp(op) {
8303
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
8304
+ }
8305
+ var Parser = class {
8306
+ tokens;
8307
+ pos = 0;
8308
+ nodeCount = 0;
8309
+ identifiers = /* @__PURE__ */ new Set();
8310
+ callees = /* @__PURE__ */ new Set();
8311
+ constructor(tokens) {
8312
+ this.tokens = tokens;
8313
+ }
8314
+ parse() {
8315
+ const ast = this.parseTernary();
8316
+ const tok = this.peek();
8317
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
8318
+ return {
8319
+ ast,
8320
+ identifiers: this.identifiers,
8321
+ callees: this.callees,
8322
+ nodeCount: this.nodeCount
8323
+ };
8324
+ }
8325
+ peek() {
8326
+ return this.tokens[this.pos];
8327
+ }
8328
+ next() {
8329
+ return this.tokens[this.pos++];
8330
+ }
8331
+ /** Consume a punctuator token, erroring if the next token isn't it. */
8332
+ expectPunct(punct) {
8333
+ const tok = this.peek();
8334
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
8335
+ this.pos += 1;
8336
+ }
8337
+ matchPunct(punct) {
8338
+ const tok = this.peek();
8339
+ if (tok.type === "punct" && tok.punct === punct) {
8340
+ this.pos += 1;
8341
+ return true;
8342
+ }
8343
+ return false;
8344
+ }
8345
+ countNode() {
8346
+ this.nodeCount += 1;
8347
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
8348
+ }
8349
+ parseTernary() {
8350
+ const test = this.parseBinary(1);
8351
+ if (this.matchPunct("?")) {
8352
+ const consequent = this.parseTernary();
8353
+ this.expectPunct(":");
8354
+ const alternate = this.parseTernary();
8355
+ this.countNode();
8356
+ return {
8357
+ kind: "conditional",
8358
+ test,
8359
+ consequent,
8360
+ alternate
8361
+ };
8362
+ }
8363
+ return test;
8364
+ }
8365
+ parseBinary(minPrec) {
8366
+ let left = this.parseUnary();
8367
+ for (;;) {
8368
+ const tok = this.peek();
8369
+ if (tok.type !== "punct") break;
8370
+ const prec = BINARY_PRECEDENCE[tok.punct];
8371
+ if (prec === void 0 || prec < minPrec) break;
8372
+ const op = tok.punct;
8373
+ this.pos += 1;
8374
+ const right = this.parseBinary(prec + 1);
8375
+ this.countNode();
8376
+ if (isLogicalOp(op)) left = {
8377
+ kind: "logical",
8378
+ op,
8379
+ left,
8380
+ right
8381
+ };
8382
+ else if (isBinaryOp(op)) left = {
8383
+ kind: "binary",
8384
+ op,
8385
+ left,
8386
+ right
8387
+ };
8388
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
8389
+ }
8390
+ return left;
8391
+ }
8392
+ parseUnary() {
8393
+ const tok = this.peek();
8394
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
8395
+ const op = tok.punct;
8396
+ this.pos += 1;
8397
+ const operand = this.parseUnary();
8398
+ this.countNode();
8399
+ return {
8400
+ kind: "unary",
8401
+ op,
8402
+ operand
8403
+ };
8404
+ }
8405
+ return this.parsePrimary();
8406
+ }
8407
+ parsePrimary() {
8408
+ const tok = this.next();
8409
+ switch (tok.type) {
8410
+ case "number":
8411
+ this.countNode();
8412
+ return {
8413
+ kind: "literal",
8414
+ value: tok.value
8415
+ };
8416
+ case "string":
8417
+ this.countNode();
8418
+ return {
8419
+ kind: "literal",
8420
+ value: tok.value
8421
+ };
8422
+ case "keyword":
8423
+ this.countNode();
8424
+ return {
8425
+ kind: "literal",
8426
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
8427
+ };
8428
+ case "identifier": {
8429
+ const nextTok = this.peek();
8430
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
8431
+ this.identifiers.add(tok.name);
8432
+ this.countNode();
8433
+ return {
8434
+ kind: "identifier",
8435
+ name: tok.name
8436
+ };
8437
+ }
8438
+ case "punct":
8439
+ if (tok.punct === "(") {
8440
+ const inner = this.parseTernary();
8441
+ this.expectPunct(")");
8442
+ return inner;
8443
+ }
8444
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
8445
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
8446
+ }
8447
+ }
8448
+ parseCall(callee, pos) {
8449
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
8450
+ this.expectPunct("(");
8451
+ const args = [];
8452
+ if (!this.matchPunct(")")) for (;;) {
8453
+ args.push(this.parseTernary());
8454
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
8455
+ if (this.matchPunct(",")) continue;
8456
+ this.expectPunct(")");
8457
+ break;
8458
+ }
8459
+ this.callees.add(callee);
8460
+ this.countNode();
8461
+ return {
8462
+ kind: "call",
8463
+ callee,
8464
+ args
8465
+ };
8466
+ }
8467
+ };
8468
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
8469
+ * `ExpressionParseError` on any lexical or grammatical failure. */
8470
+ function parseExpression(source) {
8471
+ return new Parser(tokenize(source)).parse();
8472
+ }
8473
+ /**
8474
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
8475
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
8476
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
8477
+ * one per read on a hot resolve path.
8478
+ *
8479
+ * The cache is a module-level singleton: entries are pure, content-addressed
8480
+ * ASTs keyed by the raw source string, so sharing one instance across all
8481
+ * callers is safe and maximises hit rate.
8482
+ */
8483
+ var cache = /* @__PURE__ */ new Map();
8484
+ function getCached(source) {
8485
+ const hit = cache.get(source);
8486
+ if (hit !== void 0) {
8487
+ cache.delete(source);
8488
+ cache.set(source, hit);
8489
+ return hit;
8490
+ }
8491
+ let result;
8492
+ try {
8493
+ result = {
8494
+ ok: true,
8495
+ parsed: parseExpression(source)
8496
+ };
8497
+ } catch (err) {
8498
+ result = {
8499
+ ok: false,
8500
+ error: err instanceof ExpressionParseError ? err.message : String(err)
8501
+ };
8502
+ }
8503
+ cache.set(source, result);
8504
+ if (cache.size > 256) {
8505
+ const oldest = cache.keys().next().value;
8506
+ if (oldest !== void 0) cache.delete(oldest);
8507
+ }
8508
+ return result;
8509
+ }
8510
+ /** Compile `source`, returning a discriminated result instead of throwing.
8511
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
8512
+ function compileExpressionSafe(source) {
8513
+ return getCached(source);
8514
+ }
8515
+ Object.freeze({});
8516
+ /**
8517
+ * Author-time validation. Returns `null` when the source is valid, else a
8518
+ * human-readable error message. Checks: the expression compiles; binding count
8519
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
8520
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
8521
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
8522
+ */
8523
+ function validateExpressionSource(src) {
8524
+ const names = Object.keys(src.bindings);
8525
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
8526
+ for (const name of names) {
8527
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
8528
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
8529
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
8530
+ }
8531
+ const compiled = compileExpressionSafe(src.expr);
8532
+ if (!compiled.ok) return compiled.error;
8533
+ const bound = new Set(names);
8534
+ for (const id of compiled.parsed.identifiers) {
8535
+ if (id === "now") continue;
8536
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
8537
+ }
8538
+ return null;
8539
+ }
8540
+ var ExpressionBindingSourceSchema = union([
8541
+ object({
8542
+ kind: literal("field").optional(),
8543
+ sourceKey: string(),
8544
+ cap: string(),
8545
+ fieldPath: string()
8546
+ }),
8547
+ object({
8548
+ kind: literal("literal"),
8549
+ value: union([
8550
+ string(),
8551
+ number(),
8552
+ boolean(),
8553
+ _null()
8554
+ ])
8555
+ }),
8556
+ object({
8557
+ kind: literal("global"),
8558
+ sourceStableId: string(),
8559
+ cap: string(),
8560
+ fieldPath: string()
8561
+ })
8562
+ ]);
8563
+ object({
8564
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
8565
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
8566
+ }).superRefine((src, ctx) => {
8567
+ const err = validateExpressionSource(src);
8568
+ if (err !== null) ctx.addIssue({
8569
+ code: "custom",
8570
+ message: err,
8571
+ path: ["expr"]
8572
+ });
8573
+ });
8574
+ /** How a leaf compares a device field to a value. Derived from the field's
8575
+ * `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
8576
+ var AutomationConditionOperatorSchema = _enum([
8577
+ "eq",
8578
+ "ne",
8579
+ "gt",
8580
+ "gte",
8581
+ "lt",
8582
+ "lte",
8583
+ "contains",
8584
+ "in"
8585
+ ]);
8586
+ var AutomationConditionLeafSchema = object({
8587
+ kind: literal("condition"),
8588
+ deviceId: number().int().nonnegative(),
8589
+ cap: string().min(1),
8590
+ fieldPath: string().min(1),
8591
+ operator: AutomationConditionOperatorSchema,
8592
+ value: union([
8593
+ string(),
8594
+ number(),
8595
+ boolean(),
8596
+ array(union([string(), number()]))
8597
+ ])
8598
+ });
8599
+ /**
8600
+ * The expression leaf, declared as a plain object rather than an intersection
8601
+ * with {@link ExpressionSourceSchema}: a discriminated union has to be able to
8602
+ * read `kind` off each option, and an intersection hides it. The author-time
8603
+ * validation is the SAME function `ExpressionSourceSchema` runs, so the two
8604
+ * cannot drift — an expression that one accepts, the other accepts.
8605
+ */
8606
+ var AutomationConditionExpressionSchema = object({
8607
+ kind: literal("expression"),
8608
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
8609
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
8610
+ }).superRefine((src, ctx) => {
8611
+ const err = validateExpressionSource(src);
8612
+ if (err !== null) ctx.addIssue({
8613
+ code: "custom",
8614
+ message: err,
8615
+ path: ["expr"]
8616
+ });
8617
+ });
8618
+ var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
8619
+ object({
8620
+ kind: literal("all"),
8621
+ children: array(AutomationConditionSchema)
8622
+ }),
8623
+ object({
8624
+ kind: literal("any"),
8625
+ children: array(AutomationConditionSchema)
8626
+ }),
8627
+ object({
8628
+ kind: literal("not"),
8629
+ child: AutomationConditionSchema
8630
+ }),
8631
+ AutomationConditionLeafSchema,
8632
+ AutomationConditionExpressionSchema
8633
+ ]));
8634
+ /**
8635
+ * What starts a run.
8636
+ *
8637
+ * D8 compliance, and it is the reason `device-state` is not merely an event
8638
+ * subscription: the trigger evaluates against the **state mirror**, which is
8639
+ * reconciled, and an event only WAKES the evaluation. A dropped event therefore
8640
+ * DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
8641
+ * already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
8642
+ * not.
8643
+ */
8644
+ var AutomationTriggerSchema = discriminatedUnion("kind", [
8645
+ object({
8646
+ kind: literal("device-state"),
8647
+ deviceId: number().int().nonnegative(),
8648
+ cap: string().min(1),
8649
+ fieldPath: string().min(1),
8650
+ /** Fire when the field takes this value. Omit to fire on any change. */
8651
+ becomes: union([
8652
+ string(),
8653
+ number(),
8654
+ boolean()
8655
+ ]).optional(),
8656
+ /** Only on a CHANGE of value, not on every re-report. */
8657
+ edge: boolean().optional(),
8658
+ /** The condition must hold this long before the run starts. */
8659
+ forMs: number().int().min(0).max(864e5).optional(),
8660
+ /** Collapse a burst into one run. */
8661
+ debounceMs: number().int().min(0).max(6e5).optional()
8662
+ }),
8663
+ object({
8664
+ kind: literal("device-event"),
8665
+ /** An `EventCategory` value. */
8666
+ category: string().min(1),
8667
+ deviceId: number().int().nonnegative().optional()
8668
+ }),
8669
+ object({
8670
+ kind: literal("schedule"),
8671
+ cron: string().min(1).max(120)
8672
+ }),
8673
+ object({ kind: literal("manual") })
8674
+ ]);
8675
+ /**
8676
+ * One action step.
8677
+ *
8678
+ * `wait` and `cap` are `NcRuleActionSchema`'s two members, kept structurally
8679
+ * identical so `NcRuleActionRunner` runs them unchanged — its device-scope
8680
+ * check, stop-at-first-failure and per-sequence throttle are the whole reason
8681
+ * to reuse it, and none of them are re-implemented here.
8682
+ *
8683
+ * **The one divergence, and it is forced.** `NcRuleActionSchema.cap.deviceId` is
8684
+ * a literal `z.number().int()`, and the NC runner's own `RunSequencesInput`
8685
+ * documents its subject device as *"for the log tag, never for routing"*. So an
8686
+ * NC action can never target the device that triggered it — which is fine for
8687
+ * the NC (its rules already scope to a device) and fatal for an automation
8688
+ * ("sound the siren of the camera that saw the person"). `deviceId` therefore
8689
+ * also accepts `{ $var }`, resolved from the run's `vars` bag BEFORE the runner
8690
+ * is called. The runner still receives a number and is untouched; the
8691
+ * resolution is the recipe's job, not the runner's.
8692
+ */
8693
+ var AutomationActionSchema = discriminatedUnion("kind", [
8694
+ object({
8695
+ kind: literal("wait"),
8696
+ seconds: number().min(0).max(300)
8697
+ }),
8698
+ object({
8699
+ kind: literal("cap"),
8700
+ deviceId: union([number().int(), object({ $var: string().min(1) })]),
8701
+ cap: string().min(1),
8702
+ method: string().min(1),
8703
+ /** Values may carry `{{vars.x}}` slots, which SUBSTITUTE and do not
8704
+ * evaluate (§3.2.3). Anything beyond substitution is the expression leaf. */
8705
+ args: record(string(), unknown()).optional()
8706
+ }),
8707
+ object({
8708
+ kind: literal("code"),
8709
+ /** Compiled into the automation's OWN block by esbuild — not a third
8710
+ * runtime, not a `vm`, and not dynamically evaluated. */
8711
+ code: string().min(1).max(2e4)
8712
+ })
8713
+ ]);
8714
+ object({
8715
+ triggers: array(AutomationTriggerSchema),
8716
+ conditions: AutomationConditionSchema.optional(),
8717
+ actions: array(AutomationActionSchema)
8718
+ });
8719
+ /**
7946
8720
  * `addon-pages` — system-scoped singleton aggregator cap. Public-facing
7947
8721
  * surface that admin-ui consumes through `useAddonPagesListPages()`.
7948
8722
  *
@@ -12054,7 +12828,7 @@ DeviceType.Camera, method(object({
12054
12828
  * Why: pub/sub routing over the system event-bus loses fidelity
12055
12829
  * (callback shape, QoS guarantees, will/retain semantics) and adds
12056
12830
  * refcount bookkeeping that addons would rather own themselves. The
12057
- * canonical consumer (`addon-export-ha-mqtt`) needs raw `mqtt.js`
12831
+ * canonical consumer needs raw `mqtt.js`
12058
12832
  * features anyway — give it the connection config, get out of the way.
12059
12833
  *
12060
12834
  * Consumer flow:
@@ -14622,8 +15396,26 @@ var TrackSchema = object({
14622
15396
  /** Periodic snapshots at snapshotIntervalMs cadence (subject to
14623
15397
  * saveThumbnails policy). */
14624
15398
  snapshots: array(TrackSnapshotSchema).readonly(),
14625
- /** Deduplicated zones the track has entered at least once. */
15399
+ /** Deduplicated zones the track has entered at least once. Zone IDS. */
14626
15400
  zonesVisited: array(string()).readonly(),
15401
+ /**
15402
+ * Human NAMES for {@link zonesVisited}, resolved at READ time against the
15403
+ * `zones` capability.
15404
+ *
15405
+ * `zonesVisited` persists ids (`cfeec78c-8d69-…`), which no operator can type
15406
+ * and no card can render — so every free-text search surface was structurally
15407
+ * unable to answer "show me the tracks in Uscio", and did not fail loudly, it
15408
+ * just returned nothing. Resolving here rather than in each client keeps ONE
15409
+ * derivation and costs the clients no extra call (the `zones` cap is
15410
+ * per-device, so a client-side resolve would be a per-camera fan-out on a
15411
+ * surface built to avoid exactly that).
15412
+ *
15413
+ * Resolved, never invented: a zone deleted since the track was written has no
15414
+ * name and is DROPPED, so this array can be shorter than `zonesVisited` — the
15415
+ * two are not positionally aligned. Absent when the track visited no zone, or
15416
+ * when the zone catalogue could not be read.
15417
+ */
15418
+ zoneNames: array(string()).readonly().optional(),
14627
15419
  /** Deduplicated set of detector classes observed for this track over its
14628
15420
  * life (a track may be reclassified, e.g. person→vehicle). Absent on
14629
15421
  * legacy rows written before class accumulation shipped. */
@@ -16726,6 +17518,23 @@ var CameraRecordingStatusSchema = object({
16726
17518
  active: boolean(),
16727
17519
  storageBytes: number()
16728
17520
  });
17521
+ /** One stage of the fan-out that could NOT be read, and how long it cost. */
17522
+ var CameraStatusDegradationSchema = object({
17523
+ stage: _enum([
17524
+ "source",
17525
+ "broker",
17526
+ "detection",
17527
+ "recording",
17528
+ "switches"
17529
+ ]),
17530
+ reason: _enum([
17531
+ "timeout",
17532
+ "error",
17533
+ "partial"
17534
+ ]),
17535
+ /** Wall-clock ms spent on the stage before it was abandoned. */
17536
+ elapsedMs: number()
17537
+ });
16729
17538
  /**
16730
17539
  * Aggregated per-camera pipeline status — server-composed, single call.
16731
17540
  *
@@ -16756,9 +17565,28 @@ var CameraStatusSchema = object({
16756
17565
  * differently — a quiet camera that looks identical to a dead one is the
16757
17566
  * silence-reads-as-never-happened trap this repo keeps paying for.
16758
17567
  *
16759
- * Empty when nothing is off. Never contains a switch no provider offers.
17568
+ * Empty when nothing is off, and never contains a switch no provider offers
17569
+ * — but an empty list is only a POSITIVE claim when `degraded` does not name
17570
+ * `'switches'`. When it does, the switch set could not be read and nothing
17571
+ * here may be rendered as "the operator turned nothing off": that is the
17572
+ * D62 failure (a camera we could not read painted as broken) in the very
17573
+ * field that exists to prevent it.
16760
17574
  */
16761
17575
  switchedOff: array(CameraSwitchIdSchema).readonly(),
17576
+ /**
17577
+ * Stages of the bounded fan-out that were CUT SHORT — a timeout or a
17578
+ * rejection — and whose block is therefore `null` because we could not
17579
+ * READ it, not because there is nothing there.
17580
+ *
17581
+ * Without this, three different facts arrive as the same `null`: "the stage
17582
+ * timed out", "the stage failed", and "this camera legitimately has no
17583
+ * decoder / no recording". Every surface that draws a conclusion from a null
17584
+ * block (or from an empty `switchedOff`) must consult this first; a stage
17585
+ * named here supports no conclusion at all, only "unknown".
17586
+ *
17587
+ * Empty on a clean read — the overwhelmingly common case.
17588
+ */
17589
+ degraded: array(CameraStatusDegradationSchema).readonly(),
16762
17590
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
16763
17591
  fetchedAt: number()
16764
17592
  });
@@ -17276,6 +18104,37 @@ DeviceType.Camera, method(object({
17276
18104
  lastCapturedAt: number().nullable(),
17277
18105
  cacheAgeMs: number().nullable(),
17278
18106
  etag: string().nullable()
18107
+ }))), systemMethod(object({
18108
+ /** The tiles a surface is actually rendering. One entry per (device,
18109
+ * width) the caller will paint — the width is snapped to the server's
18110
+ * ladder and becomes part of the link's SIGNED identity. */
18111
+ targets: array(object({
18112
+ deviceId: number(),
18113
+ /** Target width in px. Omit for the frame as captured — correct
18114
+ * for a full-bleed surface, wrong (and expensive) for a grid. */
18115
+ width: number().int().positive().optional()
18116
+ })).min(1).max(200) }), array(object({
18117
+ deviceId: number(),
18118
+ /** Root-relative signed path, or null when the link plane is not
18119
+ * served (no data-plane facility). Present even for a device that has
18120
+ * never captured — the request is what triggers the first one (D94). */
18121
+ url: string().nullable(),
18122
+ /** Epoch ms of the frame this link serves. Null = never captured.
18123
+ * THE honest age: the tRPC path carried none before this. */
18124
+ capturedAt: number().nullable(),
18125
+ /** Age of that frame at the moment the answer was built. */
18126
+ ageMs: number().nullable(),
18127
+ /** Epoch ms after which `url` stops verifying. */
18128
+ expiresAt: number().nullable(),
18129
+ /** Ladder rung the bytes are at; null = the frame as captured. */
18130
+ width: number().nullable(),
18131
+ /** The device has never produced a frame. An empty state, not a
18132
+ * failure — and never a reason to withhold the link (D94). */
18133
+ neverCaptured: boolean(),
18134
+ /** A sleeping battery camera: the frame is deliberately stale and will
18135
+ * NOT refresh in the background. A surface should say so rather than
18136
+ * present it as current. */
18137
+ sleeping: boolean()
17279
18138
  })));
17280
18139
  /**
17281
18140
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
@@ -19940,7 +20799,29 @@ var FaceInfoSchema = object({
19940
20799
  recognizedIdentityId: string().optional(),
19941
20800
  identityName: string().optional(),
19942
20801
  assigned: boolean(),
20802
+ /**
20803
+ * The crop, inline, base64.
20804
+ *
20805
+ * **Prefer {@link cropUrl}.** At the 500 rows the Faces view asks for this
20806
+ * field alone is ~2.87 MiB, re-sent in full on every operator assign and
20807
+ * every 30 s poll, base64-inflated over the msgpack socket and held in the
20808
+ * query heap. It stays for callers that have not migrated; `includeCrops:
20809
+ * false` turns it off once they have.
20810
+ */
19943
20811
  base64: string().optional(),
20812
+ /**
20813
+ * Same crop, as a data-plane URL for `<img src>` — the move the admin
20814
+ * snapshot surfaces made on 2026-08-08.
20815
+ *
20816
+ * Served by the `event-media` plane, which resolves a raw MediaStore key and
20817
+ * is `access: 'authenticated'`: a bare `<img>` carries the `camstack_session`
20818
+ * cookie, so no header plumbing is needed. The bytes then ride the browser's
20819
+ * HTTP cache with an ETag and `immutable`, instead of the WebSocket.
20820
+ *
20821
+ * Absent when the face has no stored crop, or when the addon has no data
20822
+ * plane — callers fall back to {@link base64}.
20823
+ */
20824
+ cropUrl: string().optional(),
19944
20825
  /** Design B: the face bbox (pixel space) on the key frame — lets a detail
19945
20826
  * view draw the box over the native `keyFrameMediaKey` frame. Absent on
19946
20827
  * legacy rows written before design B. */
@@ -20005,7 +20886,23 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
20005
20886
  auth: "admin"
20006
20887
  }), method(object({
20007
20888
  limit: number().int().positive().optional(),
20008
- filter: FaceFilterEnum.optional()
20889
+ filter: FaceFilterEnum.optional(),
20890
+ /**
20891
+ * Inline the base64 crop on every row. Default `true` — the existing
20892
+ * behaviour, kept so no caller breaks.
20893
+ *
20894
+ * Set `false` once the caller renders {@link FaceInfo.cropUrl}: that
20895
+ * drops ~2.87 MiB per 500-row page to a few KiB of metadata and lets
20896
+ * the browser cache the images.
20897
+ *
20898
+ * **This is an INPUT field, so it does not reach the addon until the
20899
+ * next train.** The hub router validates cap inputs against its own
20900
+ * compiled Zod, which strips a key it does not know — verified today
20901
+ * on the OUTPUT side, where an additive field DOES arrive immediately
20902
+ * (`Track.hasFace`). Until the train ships, sending `false` is
20903
+ * harmless and simply keeps the crops inline.
20904
+ */
20905
+ includeCrops: boolean().optional()
20009
20906
  }).optional(), array(FaceInfoSchema).readonly()), method(object({
20010
20907
  deviceId: number().int(),
20011
20908
  trackId: string()
@@ -25733,987 +26630,358 @@ var BaseDevice = class {
25733
26630
  * to reshape before `config.setAll`.
25734
26631
  */
25735
26632
  async applySettingsPatch(patch) {
25736
- await this.config.setAll(patch);
25737
- }
25738
- /**
25739
- * Phase 3 — populate device-scoped state needed by downstream phases
25740
- * (accessory reconciliation, public `features` array, optional cap
25741
- * registration). Called ONCE per construction, after register but
25742
- * before `getAccessoryChildren()`.
25743
- *
25744
- * Drivers write the `feature-probe` runtime-state slice via
25745
- * `this.runtimeState.setCapState('feature-probe', {...})` — flag bag
25746
- * is open (Reolink writes `hasPtz/hasIntercom`, Hikvision writes
25747
- * `hasSupplementalLight/hasAlarmIo`, etc).
25748
- *
25749
- * Default: nothing to probe → mark the device PROBED (set `lastProbedAt`) so
25750
- * the kernel treats it as ready immediately. A device that derives its shape
25751
- * from a spec (a container, or an accessory sensor) rather than from a
25752
- * hardware probe has no probe to "complete"; without stamping `lastProbedAt`
25753
- * it would look perpetually un-probed — logging "Initial probe did not
25754
- * complete" on every boot and spinning a pointless retry chain. Drivers that
25755
- * DO probe override this and write their own `feature-probe` slice (including
25756
- * `lastProbedAt`) once their probe actually succeeds.
25757
- */
25758
- async onProbe() {
25759
- const base = this.runtimeState.getCapState("feature-probe") ?? {
25760
- flags: {},
25761
- deviceType: null,
25762
- model: null,
25763
- channelCount: null,
25764
- lastProbedAt: 0,
25765
- lastFetchedAt: 0
25766
- };
25767
- this.runtimeState.setCapState("feature-probe", {
25768
- ...base,
25769
- lastProbedAt: Date.now()
25770
- });
25771
- }
25772
- /**
25773
- * Phase 5 — fired after the device + its accessories are registered.
25774
- * Drivers publish streams to the broker, kick off background tasks,
25775
- * or subscribe to lib events that need a fully-registered device id.
25776
- *
25777
- * Default: no-op.
25778
- *
25779
- * RENAMED FROM `onCreated` (which still exists for back-compat in this
25780
- * pass). The new name reflects the post-probe, post-accessory contract.
25781
- */
25782
- async onActivate() {}
25783
- /**
25784
- * Re-run the probe + reconcile accessories + refresh features meta.
25785
- * Drivers call this when device-side state changes (battery cam wakes,
25786
- * firmware update, manual operator trigger).
25787
- *
25788
- * The kernel injects `_kernelReprobe` on registration so this method
25789
- * delegates to the same orchestrator that runs the boot-time phase
25790
- * 3 + 4 sequence. Drivers should NOT override this — they override
25791
- * `onProbe()` instead.
25792
- */
25793
- async reprobe() {
25794
- if (this._kernelReprobe) await this._kernelReprobe();
25795
- else await this.onProbe();
25796
- }
25797
- /**
25798
- * Kernel-injected callback that runs the full post-probe orchestration
25799
- * (onProbe → registerDevice meta refresh → accessory reconciliation).
25800
- * Set by `device-cap-proxy.register()`. Drivers should not touch this
25801
- * directly — call `reprobe()` instead.
25802
- */
25803
- _kernelReprobe;
25804
- /**
25805
- * Declare accessory child devices the kernel should auto-spawn
25806
- * after `onProbe()` resolves. Each spec fully describes one child
25807
- * — stableId suffix (deterministic per kind for restore-safety),
25808
- * meta (type / name / location), config (initial blob the child
25809
- * self-hydrates), and a factory that constructs the concrete
25810
- * class with whatever closure-captured refs it needs (typically
25811
- * `this` for the parent reference).
25812
- *
25813
- * The kernel handles the rest: allocateDeviceId, persistInitialConfig
25814
- * (skipped on restore when the row already exists),
25815
- * persistInitialMeta, createContext, factory invocation, register,
25816
- * and recursive lifecycle (probe + accessories + activate).
25817
- *
25818
- * Implementations should derive children from
25819
- * `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
25820
- * Drivers can use the `getProbeFlags()` helper to read the flag bag
25821
- * with a typed cast.
25822
- *
25823
- * Default: no children.
25824
- */
25825
- getAccessoryChildren() {
25826
- return [];
25827
- }
25828
- /**
25829
- * Read the current feature-probe flag bag with a typed cast. Helper
25830
- * for `getAccessoryChildren()` and `features` getters that derive
25831
- * outputs from the probe results.
25832
- */
25833
- getProbeFlags() {
25834
- return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
25835
- }
25836
- /**
25837
- * Returns true once `onProbe` has completed at least once
25838
- * (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
25839
- * to avoid spawning stale accessories on a fresh device whose probe
25840
- * hasn't landed yet.
25841
- */
25842
- hasProbed() {
25843
- return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
25844
- }
25845
- };
25846
- /**
25847
- * Convert an IDevice to the flat DeviceSummary shape expected by the
25848
- * device-provider cap router. Shared across all providers.
25849
- */
25850
- function toDeviceSummary(device, addonId) {
25851
- const config = {};
25852
- for (const entry of device.config.entries()) config[entry.key] = entry.value;
25853
- return {
25854
- id: device.id,
25855
- stableId: device.stableId,
25856
- addonId,
25857
- type: String(device.type),
25858
- name: device.name,
25859
- parentDeviceId: device.parentDeviceId,
25860
- online: device.online,
25861
- features: [...device.features],
25862
- config,
25863
- sourceInfo: device.sourceInfo
25864
- };
25865
- }
25866
- /**
25867
- * Base class for device-provider addons (rtsp, onvif, frigate).
25868
- *
25869
- * Provides default implementations for the common device-provider cap
25870
- * methods (`start`, `stop`, `getStatus`, `getDevices`, `supportsDiscovery`,
25871
- * `supportsManualCreation`, `toDeviceSummary`). Subclasses override the
25872
- * methods that differ per provider.
25873
- *
25874
- * @example
25875
- * ```ts
25876
- * class RtspProvider extends BaseDeviceProvider {
25877
- * protected readonly addonId = 'provider-rtsp'
25878
- * protected readonly providerName = 'RTSP'
25879
- *
25880
- * protected async onCreateDevice(input) { ... }
25881
- * protected async onGetCreationSchema(type) { ... }
25882
- * protected async onRestoreDevices(saved) { ... }
25883
- * }
25884
- * ```
25885
- */
25886
- var BaseDeviceProvider = class extends BaseAddon {
25887
- async onInitialize() {
25888
- this.ctx.logger.info(`${this.providerName} Provider initialized`);
25889
- return [{
25890
- capability: deviceProviderCapability,
25891
- provider: this
25892
- }];
25893
- }
25894
- async onShutdown() {
25895
- const devices = await this.ctx.kernel.devices?.getAll() ?? [];
25896
- for (const device of devices) try {
25897
- await this.ctx.kernel.devices?.decommission(device.id);
25898
- } catch (err) {
25899
- this.ctx.logger.warn(`${this.providerName}: decommission failed`, {
25900
- tags: {
25901
- deviceId: device.id,
25902
- stableId: device.stableId
25903
- },
25904
- meta: { error: err instanceof Error ? err.message : String(err) }
25905
- });
25906
- }
25907
- this.ctx.logger.info(`${this.providerName} Provider shut down`, { meta: { decommissionedCount: devices.length } });
25908
- }
25909
- async start() {}
25910
- async stop() {}
25911
- async getStatus() {
25912
- return {
25913
- connected: true,
25914
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
25915
- };
25916
- }
25917
- async getDevices() {
25918
- return (await this.ctx.kernel.devices?.getAll() ?? []).map((d) => ({
25919
- id: d.stableId,
25920
- name: d.name,
25921
- type: String(d.type)
25922
- }));
25923
- }
25924
- async supportsDiscovery() {
25925
- return false;
25926
- }
25927
- async discoverDevices(_input) {
25928
- return [];
25929
- }
25930
- /** Extra per-scan input form (e.g. a broadcast address for another subnet).
25931
- * Null = no extra params. Override in providers that support scoped scans. */
25932
- async getDiscoveryParamsSchema() {
25933
- return null;
25934
- }
25935
- /**
25936
- * The DeviceType this provider creates via manual add — derived from the
25937
- * `deviceClasses` map (first registered type). `null` when manual creation is
25938
- * unsupported. Lets the Add-Device dialog pick the right type per provider.
25939
- */
25940
- async getManualCreationType() {
25941
- if (!await this.supportsManualCreation()) return { deviceType: null };
25942
- return { deviceType: Object.values(DeviceType).find((t) => this.deviceClasses[t] !== void 0) ?? null };
25943
- }
25944
- async adoptDiscoveredDevice(_input) {
25945
- throw new Error(`${this.providerName} provider does not support discovery-based adoption`);
25946
- }
25947
- async supportsManualCreation() {
25948
- return true;
25949
- }
25950
- async getChildCreationSchema(input) {
25951
- return this.onGetCreationSchema(input.type);
25952
- }
25953
- /**
25954
- * Default kernel-orchestrated `createDevice` implementation. The
25955
- * subclass's `onCreateDevice` returns a declarative
25956
- * `CreateDeviceSpec` (`{meta, config}`) — this method handles
25957
- * stableId generation, class lookup, kernel.devices.create
25958
- * dispatch, and DeviceSummary mapping. Subclasses should NOT
25959
- * override this method; override `onCreateDevice` and
25960
- * `deviceClasses` instead.
25961
- */
25962
- async createDevice(input) {
25963
- const spec = await this.onCreateDevice(input.type, input.config);
25964
- const Class = this.deviceClasses[spec.meta.type];
25965
- if (!Class) throw new Error(`${this.providerName} provider: no device class registered for type "${spec.meta.type}" — add it to the deviceClasses map`);
25966
- const stableId = this.generateStableId(spec.meta.type, spec.config);
25967
- const device = await this.ctx.kernel.devices.create(stableId, Class, spec.config, null, spec.meta);
25968
- if (spec.onAfterCreate) try {
25969
- await spec.onAfterCreate(device);
25970
- } catch (err) {
25971
- this.ctx.logger.warn("createDevice: onAfterCreate hook threw — device is already registered", {
25972
- tags: {
25973
- deviceId: device.id,
25974
- stableId
25975
- },
25976
- meta: { error: err instanceof Error ? err.message : String(err) }
25977
- });
25978
- }
25979
- return this.toSummary(device);
26633
+ await this.config.setAll(patch);
25980
26634
  }
25981
26635
  /**
25982
- * Generate a stableId for a newly-created device. Default uses the
25983
- * `${addonId}-${Date.now()}` pattern as a unique-but-opaque
25984
- * fallback; any provider that has access to durable hardware
25985
- * identity (UID, MAC, serial) should override and derive from it
25986
- * so re-adding the same physical device reuses its persisted row.
26636
+ * Phase 3 — populate device-scoped state needed by downstream phases
26637
+ * (accessory reconciliation, public `features` array, optional cap
26638
+ * registration). Called ONCE per construction, after register but
26639
+ * before `getAccessoryChildren()`.
25987
26640
  *
25988
- * `config` is the parsed CreateDeviceSpec.config the subclass
25989
- * returned from `onCreateDevice` — the override has access to
25990
- * every operator-supplied + autodetect-resolved field. Optional
25991
- * for back-compat: existing overrides that take only `type`
25992
- * keep working unchanged.
26641
+ * Drivers write the `feature-probe` runtime-state slice via
26642
+ * `this.runtimeState.setCapState('feature-probe', {...})` — flag bag
26643
+ * is open (Reolink writes `hasPtz/hasIntercom`, Hikvision writes
26644
+ * `hasSupplementalLight/hasAlarmIo`, etc).
26645
+ *
26646
+ * Default: nothing to probe → mark the device PROBED (set `lastProbedAt`) so
26647
+ * the kernel treats it as ready immediately. A device that derives its shape
26648
+ * from a spec (a container, or an accessory sensor) rather than from a
26649
+ * hardware probe has no probe to "complete"; without stamping `lastProbedAt`
26650
+ * it would look perpetually un-probed — logging "Initial probe did not
26651
+ * complete" on every boot and spinning a pointless retry chain. Drivers that
26652
+ * DO probe override this and write their own `feature-probe` slice (including
26653
+ * `lastProbedAt`) once their probe actually succeeds.
25993
26654
  */
25994
- generateStableId(_type, _config) {
25995
- return `${this.addonId}-${Date.now()}`;
25996
- }
25997
- async testCreationField(_input) {
25998
- return {
25999
- status: "ok",
26000
- labels: ["probe not implemented"]
26655
+ async onProbe() {
26656
+ const base = this.runtimeState.getCapState("feature-probe") ?? {
26657
+ flags: {},
26658
+ deviceType: null,
26659
+ model: null,
26660
+ channelCount: null,
26661
+ lastProbedAt: 0,
26662
+ lastFetchedAt: 0
26001
26663
  };
26002
- }
26003
- async restoreDevices(savedDevices) {
26004
- await this.onRestoreDevices(savedDevices);
26005
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
26664
+ this.runtimeState.setCapState("feature-probe", {
26665
+ ...base,
26666
+ lastProbedAt: Date.now()
26667
+ });
26006
26668
  }
26007
26669
  /**
26008
- * Restore devices from persisted state. Two-pass:
26670
+ * Phase 5 — fired after the device + its accessories are registered.
26671
+ * Drivers publish streams to the broker, kick off background tasks,
26672
+ * or subscribe to lib events that need a fully-registered device id.
26009
26673
  *
26010
- * 1. **Top-level pass** — invokes `kernel.devices.create()` for every
26011
- * `parentDeviceId === null` row using the `deviceClasses` map.
26012
- * The kernel's register flow handles `getAccessoryChildren()` for
26013
- * each parent (siren / floodlight / PIR / etc).
26674
+ * Default: no-op.
26014
26675
  *
26015
- * 2. **Hub-adopted children pass** — for rows with
26016
- * `parentDeviceId !== null` whose `type` IS in `deviceClasses`
26017
- * (e.g. Reolink hub-adopted cameras under an NVR), spawn them
26018
- * explicitly with the persisted `parentDeviceId`. These are
26019
- * NOT accessory children — they're first-class adopted devices
26020
- * that just happen to have a parent. Without this pass, every
26021
- * server restart would lose hub-adopted cameras (their type is
26022
- * in `deviceClasses` but parent's `getAccessoryChildren` doesn't
26023
- * spawn them — that callback is only for purpose-built
26024
- * accessory roles).
26676
+ * RENAMED FROM `onCreated` (which still exists for back-compat in this
26677
+ * pass). The new name reflects the post-probe, post-accessory contract.
26678
+ */
26679
+ async onActivate() {}
26680
+ /**
26681
+ * Re-run the probe + reconcile accessories + refresh features meta.
26682
+ * Drivers call this when device-side state changes (battery cam wakes,
26683
+ * firmware update, manual operator trigger).
26025
26684
  *
26026
- * Rows whose `type` is NOT in `deviceClasses` are skipped — those
26027
- * are accessory children (siren/light/sensor) that the kernel's
26028
- * accessory-spawn flow handles via the parent's
26029
- * `getAccessoryChildren()`. Override only when the default doesn't
26030
- * fit.
26685
+ * The kernel injects `_kernelReprobe` on registration so this method
26686
+ * delegates to the same orchestrator that runs the boot-time phase
26687
+ * 3 + 4 sequence. Drivers should NOT override this — they override
26688
+ * `onProbe()` instead.
26031
26689
  */
26032
- async onRestoreDevices(savedDevices) {
26033
- const restored = /* @__PURE__ */ new Set();
26034
- for (const saved of savedDevices) {
26035
- if (saved.parentDeviceId !== null) continue;
26036
- const Class = this.deviceClasses[saved.type];
26037
- if (!Class) {
26038
- this.ctx.logger.warn("No device class registered for restored type — skipping", {
26039
- tags: { stableId: saved.stableId },
26040
- meta: { type: saved.type }
26041
- });
26042
- continue;
26043
- }
26044
- try {
26045
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
26046
- restored.add(saved.id);
26047
- } catch (err) {
26048
- this.ctx.logger.warn("Failed to restore device", {
26049
- tags: { stableId: saved.stableId },
26050
- meta: {
26051
- type: saved.type,
26052
- error: err instanceof Error ? err.message : String(err)
26053
- }
26054
- });
26055
- }
26056
- }
26057
- const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
26058
- for (const saved of childRows) {
26059
- const Class = this.deviceClasses[saved.type];
26060
- if (!Class) continue;
26061
- if (saved.parentDeviceId === null) continue;
26062
- if (!restored.has(saved.parentDeviceId)) continue;
26063
- try {
26064
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
26065
- restored.add(saved.id);
26066
- } catch (err) {
26067
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
26068
- tags: {
26069
- stableId: saved.stableId,
26070
- parentDeviceId: saved.parentDeviceId
26071
- },
26072
- meta: {
26073
- type: saved.type,
26074
- error: err instanceof Error ? err.message : String(err)
26075
- }
26076
- });
26077
- }
26078
- }
26079
- }
26080
- /** Convert an IDevice to the flat DeviceSummary for the cap router. */
26081
- toSummary(device) {
26082
- return toDeviceSummary(device, this.addonId);
26083
- }
26084
- };
26085
- DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
26086
- new Set(Object.values(DeviceType));
26087
- DeviceFeature.BatteryOperated;
26088
- /**
26089
- * Error types for the safe expression engine. Two distinct classes so callers
26090
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
26091
- * failure — both are non-fatal to the host: read paths degrade to "skip link".
26092
- */
26093
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
26094
- * the failure is anchored to a character (author-facing inline feedback). */
26095
- var ExpressionParseError = class extends Error {
26096
- position;
26097
- constructor(message, position) {
26098
- super(message);
26099
- this.name = "ExpressionParseError";
26100
- this.position = position;
26101
- }
26102
- };
26103
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
26104
- * result, unknown builtin, step-budget exceeded). */
26105
- var ExpressionEvalError = class extends Error {
26106
- constructor(message) {
26107
- super(message);
26108
- this.name = "ExpressionEvalError";
26690
+ async reprobe() {
26691
+ if (this._kernelReprobe) await this._kernelReprobe();
26692
+ else await this.onProbe();
26109
26693
  }
26110
- };
26111
- /**
26112
- * Frozen, null-prototype builtin function table for the expression engine
26113
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
26114
- * parser rejects any callee not in it, and the evaluator gates each call on an
26115
- * own-property check against it.
26116
- *
26117
- * Because the object has a NULL prototype AND is `Object.freeze`d:
26118
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
26119
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
26120
- * (there is no `Object.prototype` in the chain), so those names are not
26121
- * callable — they are simply "unknown function" at parse time.
26122
- *
26123
- * Every numeric argument is validated as a finite number and every numeric
26124
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
26125
- * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
26126
- * closed rather than emitting a garbage value.
26127
- */
26128
- function asFiniteNumber(value, name, index) {
26129
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
26130
- return value;
26131
- }
26132
- function asString$1(value, name, index) {
26133
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
26134
- return value;
26135
- }
26136
- function finiteResult(value, name) {
26137
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
26138
- return value;
26139
- }
26140
- function allFiniteNumbers(args, name) {
26141
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
26142
- }
26143
- var INF = Number.POSITIVE_INFINITY;
26144
- var table = {
26145
- min: {
26146
- minArgs: 1,
26147
- maxArgs: INF,
26148
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
26149
- },
26150
- max: {
26151
- minArgs: 1,
26152
- maxArgs: INF,
26153
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
26154
- },
26155
- abs: {
26156
- minArgs: 1,
26157
- maxArgs: 1,
26158
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
26159
- },
26160
- floor: {
26161
- minArgs: 1,
26162
- maxArgs: 1,
26163
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
26164
- },
26165
- ceil: {
26166
- minArgs: 1,
26167
- maxArgs: 1,
26168
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
26169
- },
26170
- sqrt: {
26171
- minArgs: 1,
26172
- maxArgs: 1,
26173
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
26174
- },
26175
- round: {
26176
- minArgs: 1,
26177
- maxArgs: 2,
26178
- apply: (args) => {
26179
- const x = asFiniteNumber(args[0], "round", 0);
26180
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
26181
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
26182
- const factor = 10 ** digits;
26183
- return finiteResult(Math.round(x * factor) / factor, "round");
26184
- }
26185
- },
26186
- pow: {
26187
- minArgs: 2,
26188
- maxArgs: 2,
26189
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
26190
- },
26191
- clamp: {
26192
- minArgs: 3,
26193
- maxArgs: 3,
26194
- apply: (args) => {
26195
- const x = asFiniteNumber(args[0], "clamp", 0);
26196
- const lo = asFiniteNumber(args[1], "clamp", 1);
26197
- const hi = asFiniteNumber(args[2], "clamp", 2);
26198
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
26199
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
26200
- }
26201
- },
26202
- avg: {
26203
- minArgs: 1,
26204
- maxArgs: INF,
26205
- apply: (args) => {
26206
- const nums = allFiniteNumbers(args, "avg");
26207
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
26208
- }
26209
- },
26210
- sum: {
26211
- minArgs: 1,
26212
- maxArgs: INF,
26213
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
26214
- },
26215
- coalesce: {
26216
- minArgs: 1,
26217
- maxArgs: INF,
26218
- apply: (args) => {
26219
- for (const a of args) if (a !== null) return a;
26220
- return null;
26221
- }
26222
- },
26223
- age: {
26224
- minArgs: 2,
26225
- maxArgs: 2,
26226
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
26227
- },
26228
- convert: {
26229
- minArgs: 3,
26230
- maxArgs: 3,
26231
- apply: (args, hooks) => {
26232
- const x = asFiniteNumber(args[0], "convert", 0);
26233
- const from = asString$1(args[1], "convert", 1).trim();
26234
- const to = asString$1(args[2], "convert", 2).trim();
26235
- if (hooks.convert) {
26236
- const out = hooks.convert(x, from, to);
26237
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
26238
- return finiteResult(out, "convert");
26239
- }
26240
- if (from === to) return x;
26241
- throw new ExpressionEvalError("convert: unit conversion table not installed");
26242
- }
26694
+ /**
26695
+ * Kernel-injected callback that runs the full post-probe orchestration
26696
+ * (onProbe → registerDevice meta refresh → accessory reconciliation).
26697
+ * Set by `device-cap-proxy.register()`. Drivers should not touch this
26698
+ * directly — call `reprobe()` instead.
26699
+ */
26700
+ _kernelReprobe;
26701
+ /**
26702
+ * Declare accessory child devices the kernel should auto-spawn
26703
+ * after `onProbe()` resolves. Each spec fully describes one child
26704
+ * — stableId suffix (deterministic per kind for restore-safety),
26705
+ * meta (type / name / location), config (initial blob the child
26706
+ * self-hydrates), and a factory that constructs the concrete
26707
+ * class with whatever closure-captured refs it needs (typically
26708
+ * `this` for the parent reference).
26709
+ *
26710
+ * The kernel handles the rest: allocateDeviceId, persistInitialConfig
26711
+ * (skipped on restore when the row already exists),
26712
+ * persistInitialMeta, createContext, factory invocation, register,
26713
+ * and recursive lifecycle (probe + accessories + activate).
26714
+ *
26715
+ * Implementations should derive children from
26716
+ * `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
26717
+ * Drivers can use the `getProbeFlags()` helper to read the flag bag
26718
+ * with a typed cast.
26719
+ *
26720
+ * Default: no children.
26721
+ */
26722
+ getAccessoryChildren() {
26723
+ return [];
26243
26724
  }
26244
- };
26245
- Object.freeze(Object.assign(Object.create(null), table));
26246
- /** The set of valid builtin names — used by the parser to reject unknown
26247
- * callees at parse time (immediate author feedback). */
26248
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
26249
- /**
26250
- * Resource-bound constants for the safe expression engine.
26251
- *
26252
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
26253
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
26254
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
26255
- * work a single author-supplied expression can request, so a hostile or
26256
- * accidental pathological string can never spend unbounded CPU/memory.
26257
- */
26258
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
26259
- * rejected without allocation. */
26260
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
26261
- /** A legal binding / identifier name. */
26262
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
26263
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
26264
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
26265
- var RESERVED_BINDING_NAMES = new Set([
26266
- "now",
26267
- "true",
26268
- "false",
26269
- "null"
26270
- ]);
26271
- /**
26272
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
26273
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
26274
- * single/double-quoted strings with a tiny escape set, identifiers, the three
26275
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
26276
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
26277
- * is a parse error with a source position, so member access / assignment /
26278
- * template literals are lexically impossible.
26279
- */
26280
- var KEYWORDS = new Set([
26281
- "true",
26282
- "false",
26283
- "null"
26284
- ]);
26285
- function isDigit(ch) {
26286
- return ch >= "0" && ch <= "9";
26287
- }
26288
- function isIdentStart(ch) {
26289
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
26290
- }
26291
- function isIdentPart(ch) {
26292
- return isIdentStart(ch) || isDigit(ch);
26293
- }
26294
- function isWhitespace(ch) {
26295
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
26296
- }
26297
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
26298
- * Throws `ExpressionParseError` on any illegal character or unterminated
26299
- * string. */
26300
- function tokenize(source) {
26301
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
26302
- const tokens = [];
26303
- let i = 0;
26304
- const n = source.length;
26305
- while (i < n) {
26306
- const ch = source[i];
26307
- if (isWhitespace(ch)) {
26308
- i += 1;
26309
- continue;
26310
- }
26311
- if (isDigit(ch)) {
26312
- const start = i;
26313
- while (i < n && isDigit(source[i])) i += 1;
26314
- if (i < n && source[i] === ".") {
26315
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
26316
- i += 1;
26317
- while (i < n && isDigit(source[i])) i += 1;
26318
- }
26319
- const text = source.slice(start, i);
26320
- const value = Number(text);
26321
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
26322
- tokens.push({
26323
- type: "number",
26324
- value,
26325
- pos: start
26326
- });
26327
- continue;
26328
- }
26329
- if (ch === "'" || ch === "\"") {
26330
- const quote = ch;
26331
- const start = i;
26332
- i += 1;
26333
- let out = "";
26334
- let closed = false;
26335
- while (i < n) {
26336
- const c = source[i];
26337
- if (c === "\\") {
26338
- const next = i + 1 < n ? source[i + 1] : "";
26339
- if (next === "\\" || next === "'" || next === "\"") {
26340
- out += next;
26341
- i += 2;
26342
- continue;
26343
- }
26344
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
26345
- }
26346
- if (c === quote) {
26347
- closed = true;
26348
- i += 1;
26349
- break;
26350
- }
26351
- out += c;
26352
- i += 1;
26353
- }
26354
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
26355
- tokens.push({
26356
- type: "string",
26357
- value: out,
26358
- pos: start
26359
- });
26360
- continue;
26361
- }
26362
- if (isIdentStart(ch)) {
26363
- const start = i;
26364
- while (i < n && isIdentPart(source[i])) i += 1;
26365
- const text = source.slice(start, i);
26366
- if (KEYWORDS.has(text)) tokens.push({
26367
- type: "keyword",
26368
- keyword: keywordOf(text),
26369
- pos: start
26370
- });
26371
- else tokens.push({
26372
- type: "identifier",
26373
- name: text,
26374
- pos: start
26375
- });
26376
- continue;
26377
- }
26378
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
26379
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
26380
- tokens.push({
26381
- type: "punct",
26382
- punct: two,
26383
- pos: i
26384
- });
26385
- i += 2;
26386
- continue;
26387
- }
26388
- if (isSinglePunct(ch)) {
26389
- tokens.push({
26390
- type: "punct",
26391
- punct: ch,
26392
- pos: i
26393
- });
26394
- i += 1;
26395
- continue;
26396
- }
26397
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
26725
+ /**
26726
+ * Read the current feature-probe flag bag with a typed cast. Helper
26727
+ * for `getAccessoryChildren()` and `features` getters that derive
26728
+ * outputs from the probe results.
26729
+ */
26730
+ getProbeFlags() {
26731
+ return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
26398
26732
  }
26399
- tokens.push({
26400
- type: "eof",
26401
- pos: n
26402
- });
26403
- return tokens;
26404
- }
26405
- function keywordOf(text) {
26406
- if (text === "true") return "true";
26407
- if (text === "false") return "false";
26408
- return "null";
26409
- }
26410
- function isSinglePunct(ch) {
26411
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
26733
+ /**
26734
+ * Returns true once `onProbe` has completed at least once
26735
+ * (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
26736
+ * to avoid spawning stale accessories on a fresh device whose probe
26737
+ * hasn't landed yet.
26738
+ */
26739
+ hasProbed() {
26740
+ return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
26741
+ }
26742
+ };
26743
+ /**
26744
+ * Convert an IDevice to the flat DeviceSummary shape expected by the
26745
+ * device-provider cap router. Shared across all providers.
26746
+ */
26747
+ function toDeviceSummary(device, addonId) {
26748
+ const config = {};
26749
+ for (const entry of device.config.entries()) config[entry.key] = entry.value;
26750
+ return {
26751
+ id: device.id,
26752
+ stableId: device.stableId,
26753
+ addonId,
26754
+ type: String(device.type),
26755
+ name: device.name,
26756
+ parentDeviceId: device.parentDeviceId,
26757
+ online: device.online,
26758
+ features: [...device.features],
26759
+ config,
26760
+ sourceInfo: device.sourceInfo
26761
+ };
26412
26762
  }
26413
26763
  /**
26414
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
26764
+ * Base class for device-provider addons (rtsp, onvif, frigate).
26415
26765
  *
26416
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
26417
- * → relational → additive → multiplicative → unary `! -` → call / primary.
26418
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
26419
- * string validated against the builtin table at parse time, so an unknown
26420
- * function is rejected immediately (author feedback) and a persisted expression
26421
- * that references a since-removed builtin degrades at read.
26766
+ * Provides default implementations for the common device-provider cap
26767
+ * methods (`start`, `stop`, `getStatus`, `getDevices`, `supportsDiscovery`,
26768
+ * `supportsManualCreation`, `toDeviceSummary`). Subclasses override the
26769
+ * methods that differ per provider.
26422
26770
  *
26423
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
26424
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
26771
+ * @example
26772
+ * ```ts
26773
+ * class RtspProvider extends BaseDeviceProvider {
26774
+ * protected readonly addonId = 'provider-rtsp'
26775
+ * protected readonly providerName = 'RTSP'
26776
+ *
26777
+ * protected async onCreateDevice(input) { ... }
26778
+ * protected async onGetCreationSchema(type) { ... }
26779
+ * protected async onRestoreDevices(saved) { ... }
26780
+ * }
26781
+ * ```
26425
26782
  */
26426
- /** Binary/logical operator precedence (higher binds tighter). */
26427
- var BINARY_PRECEDENCE = {
26428
- "||": 1,
26429
- "&&": 2,
26430
- "==": 3,
26431
- "!=": 3,
26432
- "<": 4,
26433
- "<=": 4,
26434
- ">": 4,
26435
- ">=": 4,
26436
- "+": 5,
26437
- "-": 5,
26438
- "*": 6,
26439
- "/": 6,
26440
- "%": 6
26441
- };
26442
- function isLogicalOp(op) {
26443
- return op === "&&" || op === "||";
26444
- }
26445
- function isBinaryOp(op) {
26446
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
26447
- }
26448
- var Parser = class {
26449
- tokens;
26450
- pos = 0;
26451
- nodeCount = 0;
26452
- identifiers = /* @__PURE__ */ new Set();
26453
- callees = /* @__PURE__ */ new Set();
26454
- constructor(tokens) {
26455
- this.tokens = tokens;
26783
+ var BaseDeviceProvider = class extends BaseAddon {
26784
+ async onInitialize() {
26785
+ this.ctx.logger.info(`${this.providerName} Provider initialized`);
26786
+ return [{
26787
+ capability: deviceProviderCapability,
26788
+ provider: this
26789
+ }];
26456
26790
  }
26457
- parse() {
26458
- const ast = this.parseTernary();
26459
- const tok = this.peek();
26460
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
26791
+ async onShutdown() {
26792
+ const devices = await this.ctx.kernel.devices?.getAll() ?? [];
26793
+ for (const device of devices) try {
26794
+ await this.ctx.kernel.devices?.decommission(device.id);
26795
+ } catch (err) {
26796
+ this.ctx.logger.warn(`${this.providerName}: decommission failed`, {
26797
+ tags: {
26798
+ deviceId: device.id,
26799
+ stableId: device.stableId
26800
+ },
26801
+ meta: { error: err instanceof Error ? err.message : String(err) }
26802
+ });
26803
+ }
26804
+ this.ctx.logger.info(`${this.providerName} Provider shut down`, { meta: { decommissionedCount: devices.length } });
26805
+ }
26806
+ async start() {}
26807
+ async stop() {}
26808
+ async getStatus() {
26461
26809
  return {
26462
- ast,
26463
- identifiers: this.identifiers,
26464
- callees: this.callees,
26465
- nodeCount: this.nodeCount
26810
+ connected: true,
26811
+ deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
26466
26812
  };
26467
26813
  }
26468
- peek() {
26469
- return this.tokens[this.pos];
26470
- }
26471
- next() {
26472
- return this.tokens[this.pos++];
26473
- }
26474
- /** Consume a punctuator token, erroring if the next token isn't it. */
26475
- expectPunct(punct) {
26476
- const tok = this.peek();
26477
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
26478
- this.pos += 1;
26814
+ async getDevices() {
26815
+ return (await this.ctx.kernel.devices?.getAll() ?? []).map((d) => ({
26816
+ id: d.stableId,
26817
+ name: d.name,
26818
+ type: String(d.type)
26819
+ }));
26479
26820
  }
26480
- matchPunct(punct) {
26481
- const tok = this.peek();
26482
- if (tok.type === "punct" && tok.punct === punct) {
26483
- this.pos += 1;
26484
- return true;
26485
- }
26821
+ async supportsDiscovery() {
26486
26822
  return false;
26487
26823
  }
26488
- countNode() {
26489
- this.nodeCount += 1;
26490
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
26824
+ async discoverDevices(_input) {
26825
+ return [];
26491
26826
  }
26492
- parseTernary() {
26493
- const test = this.parseBinary(1);
26494
- if (this.matchPunct("?")) {
26495
- const consequent = this.parseTernary();
26496
- this.expectPunct(":");
26497
- const alternate = this.parseTernary();
26498
- this.countNode();
26499
- return {
26500
- kind: "conditional",
26501
- test,
26502
- consequent,
26503
- alternate
26504
- };
26505
- }
26506
- return test;
26827
+ /** Extra per-scan input form (e.g. a broadcast address for another subnet).
26828
+ * Null = no extra params. Override in providers that support scoped scans. */
26829
+ async getDiscoveryParamsSchema() {
26830
+ return null;
26507
26831
  }
26508
- parseBinary(minPrec) {
26509
- let left = this.parseUnary();
26510
- for (;;) {
26511
- const tok = this.peek();
26512
- if (tok.type !== "punct") break;
26513
- const prec = BINARY_PRECEDENCE[tok.punct];
26514
- if (prec === void 0 || prec < minPrec) break;
26515
- const op = tok.punct;
26516
- this.pos += 1;
26517
- const right = this.parseBinary(prec + 1);
26518
- this.countNode();
26519
- if (isLogicalOp(op)) left = {
26520
- kind: "logical",
26521
- op,
26522
- left,
26523
- right
26524
- };
26525
- else if (isBinaryOp(op)) left = {
26526
- kind: "binary",
26527
- op,
26528
- left,
26529
- right
26530
- };
26531
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
26532
- }
26533
- return left;
26832
+ /**
26833
+ * The DeviceType this provider creates via manual add — derived from the
26834
+ * `deviceClasses` map (first registered type). `null` when manual creation is
26835
+ * unsupported. Lets the Add-Device dialog pick the right type per provider.
26836
+ */
26837
+ async getManualCreationType() {
26838
+ if (!await this.supportsManualCreation()) return { deviceType: null };
26839
+ return { deviceType: Object.values(DeviceType).find((t) => this.deviceClasses[t] !== void 0) ?? null };
26534
26840
  }
26535
- parseUnary() {
26536
- const tok = this.peek();
26537
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
26538
- const op = tok.punct;
26539
- this.pos += 1;
26540
- const operand = this.parseUnary();
26541
- this.countNode();
26542
- return {
26543
- kind: "unary",
26544
- op,
26545
- operand
26546
- };
26547
- }
26548
- return this.parsePrimary();
26841
+ async adoptDiscoveredDevice(_input) {
26842
+ throw new Error(`${this.providerName} provider does not support discovery-based adoption`);
26549
26843
  }
26550
- parsePrimary() {
26551
- const tok = this.next();
26552
- switch (tok.type) {
26553
- case "number":
26554
- this.countNode();
26555
- return {
26556
- kind: "literal",
26557
- value: tok.value
26558
- };
26559
- case "string":
26560
- this.countNode();
26561
- return {
26562
- kind: "literal",
26563
- value: tok.value
26564
- };
26565
- case "keyword":
26566
- this.countNode();
26567
- return {
26568
- kind: "literal",
26569
- value: tok.keyword === "null" ? null : tok.keyword === "true"
26570
- };
26571
- case "identifier": {
26572
- const nextTok = this.peek();
26573
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
26574
- this.identifiers.add(tok.name);
26575
- this.countNode();
26576
- return {
26577
- kind: "identifier",
26578
- name: tok.name
26579
- };
26580
- }
26581
- case "punct":
26582
- if (tok.punct === "(") {
26583
- const inner = this.parseTernary();
26584
- this.expectPunct(")");
26585
- return inner;
26586
- }
26587
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
26588
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
26589
- }
26844
+ async supportsManualCreation() {
26845
+ return true;
26590
26846
  }
26591
- parseCall(callee, pos) {
26592
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
26593
- this.expectPunct("(");
26594
- const args = [];
26595
- if (!this.matchPunct(")")) for (;;) {
26596
- args.push(this.parseTernary());
26597
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
26598
- if (this.matchPunct(",")) continue;
26599
- this.expectPunct(")");
26600
- break;
26847
+ async getChildCreationSchema(input) {
26848
+ return this.onGetCreationSchema(input.type);
26849
+ }
26850
+ /**
26851
+ * Default kernel-orchestrated `createDevice` implementation. The
26852
+ * subclass's `onCreateDevice` returns a declarative
26853
+ * `CreateDeviceSpec` (`{meta, config}`) — this method handles
26854
+ * stableId generation, class lookup, kernel.devices.create
26855
+ * dispatch, and DeviceSummary mapping. Subclasses should NOT
26856
+ * override this method; override `onCreateDevice` and
26857
+ * `deviceClasses` instead.
26858
+ */
26859
+ async createDevice(input) {
26860
+ const spec = await this.onCreateDevice(input.type, input.config);
26861
+ const Class = this.deviceClasses[spec.meta.type];
26862
+ if (!Class) throw new Error(`${this.providerName} provider: no device class registered for type "${spec.meta.type}" — add it to the deviceClasses map`);
26863
+ const stableId = this.generateStableId(spec.meta.type, spec.config);
26864
+ const device = await this.ctx.kernel.devices.create(stableId, Class, spec.config, null, spec.meta);
26865
+ if (spec.onAfterCreate) try {
26866
+ await spec.onAfterCreate(device);
26867
+ } catch (err) {
26868
+ this.ctx.logger.warn("createDevice: onAfterCreate hook threw — device is already registered", {
26869
+ tags: {
26870
+ deviceId: device.id,
26871
+ stableId
26872
+ },
26873
+ meta: { error: err instanceof Error ? err.message : String(err) }
26874
+ });
26601
26875
  }
26602
- this.callees.add(callee);
26603
- this.countNode();
26604
- return {
26605
- kind: "call",
26606
- callee,
26607
- args
26608
- };
26876
+ return this.toSummary(device);
26609
26877
  }
26610
- };
26611
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
26612
- * `ExpressionParseError` on any lexical or grammatical failure. */
26613
- function parseExpression(source) {
26614
- return new Parser(tokenize(source)).parse();
26615
- }
26616
- /**
26617
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
26618
- * by expr"). The cache stores BOTH successes and failures (negative caching),
26619
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
26620
- * one per read on a hot resolve path.
26621
- *
26622
- * The cache is a module-level singleton: entries are pure, content-addressed
26623
- * ASTs keyed by the raw source string, so sharing one instance across all
26624
- * callers is safe and maximises hit rate.
26625
- */
26626
- var cache = /* @__PURE__ */ new Map();
26627
- function getCached(source) {
26628
- const hit = cache.get(source);
26629
- if (hit !== void 0) {
26630
- cache.delete(source);
26631
- cache.set(source, hit);
26632
- return hit;
26878
+ /**
26879
+ * Generate a stableId for a newly-created device. Default uses the
26880
+ * `${addonId}-${Date.now()}` pattern as a unique-but-opaque
26881
+ * fallback; any provider that has access to durable hardware
26882
+ * identity (UID, MAC, serial) should override and derive from it
26883
+ * so re-adding the same physical device reuses its persisted row.
26884
+ *
26885
+ * `config` is the parsed CreateDeviceSpec.config the subclass
26886
+ * returned from `onCreateDevice` — the override has access to
26887
+ * every operator-supplied + autodetect-resolved field. Optional
26888
+ * for back-compat: existing overrides that take only `type`
26889
+ * keep working unchanged.
26890
+ */
26891
+ generateStableId(_type, _config) {
26892
+ return `${this.addonId}-${Date.now()}`;
26633
26893
  }
26634
- let result;
26635
- try {
26636
- result = {
26637
- ok: true,
26638
- parsed: parseExpression(source)
26639
- };
26640
- } catch (err) {
26641
- result = {
26642
- ok: false,
26643
- error: err instanceof ExpressionParseError ? err.message : String(err)
26894
+ async testCreationField(_input) {
26895
+ return {
26896
+ status: "ok",
26897
+ labels: ["probe not implemented"]
26644
26898
  };
26645
26899
  }
26646
- cache.set(source, result);
26647
- if (cache.size > 256) {
26648
- const oldest = cache.keys().next().value;
26649
- if (oldest !== void 0) cache.delete(oldest);
26900
+ async restoreDevices(savedDevices) {
26901
+ await this.onRestoreDevices(savedDevices);
26902
+ if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
26650
26903
  }
26651
- return result;
26652
- }
26653
- /** Compile `source`, returning a discriminated result instead of throwing.
26654
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
26655
- function compileExpressionSafe(source) {
26656
- return getCached(source);
26657
- }
26658
- Object.freeze({});
26659
- /**
26660
- * Author-time validation. Returns `null` when the source is valid, else a
26661
- * human-readable error message. Checks: the expression compiles; binding count
26662
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
26663
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
26664
- * FREE identifier of the AST is covered by a binding or the injected `now`.
26665
- */
26666
- function validateExpressionSource(src) {
26667
- const names = Object.keys(src.bindings);
26668
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
26669
- for (const name of names) {
26670
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
26671
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
26672
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
26904
+ /**
26905
+ * Restore devices from persisted state. Two-pass:
26906
+ *
26907
+ * 1. **Top-level pass** — invokes `kernel.devices.create()` for every
26908
+ * `parentDeviceId === null` row using the `deviceClasses` map.
26909
+ * The kernel's register flow handles `getAccessoryChildren()` for
26910
+ * each parent (siren / floodlight / PIR / etc).
26911
+ *
26912
+ * 2. **Hub-adopted children pass** — for rows with
26913
+ * `parentDeviceId !== null` whose `type` IS in `deviceClasses`
26914
+ * (e.g. Reolink hub-adopted cameras under an NVR), spawn them
26915
+ * explicitly with the persisted `parentDeviceId`. These are
26916
+ * NOT accessory children — they're first-class adopted devices
26917
+ * that just happen to have a parent. Without this pass, every
26918
+ * server restart would lose hub-adopted cameras (their type is
26919
+ * in `deviceClasses` but parent's `getAccessoryChildren` doesn't
26920
+ * spawn them — that callback is only for purpose-built
26921
+ * accessory roles).
26922
+ *
26923
+ * Rows whose `type` is NOT in `deviceClasses` are skipped — those
26924
+ * are accessory children (siren/light/sensor) that the kernel's
26925
+ * accessory-spawn flow handles via the parent's
26926
+ * `getAccessoryChildren()`. Override only when the default doesn't
26927
+ * fit.
26928
+ */
26929
+ async onRestoreDevices(savedDevices) {
26930
+ const restored = /* @__PURE__ */ new Set();
26931
+ for (const saved of savedDevices) {
26932
+ if (saved.parentDeviceId !== null) continue;
26933
+ const Class = this.deviceClasses[saved.type];
26934
+ if (!Class) {
26935
+ this.ctx.logger.warn("No device class registered for restored type — skipping", {
26936
+ tags: { stableId: saved.stableId },
26937
+ meta: { type: saved.type }
26938
+ });
26939
+ continue;
26940
+ }
26941
+ try {
26942
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {});
26943
+ restored.add(saved.id);
26944
+ } catch (err) {
26945
+ this.ctx.logger.warn("Failed to restore device", {
26946
+ tags: { stableId: saved.stableId },
26947
+ meta: {
26948
+ type: saved.type,
26949
+ error: err instanceof Error ? err.message : String(err)
26950
+ }
26951
+ });
26952
+ }
26953
+ }
26954
+ const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
26955
+ for (const saved of childRows) {
26956
+ const Class = this.deviceClasses[saved.type];
26957
+ if (!Class) continue;
26958
+ if (saved.parentDeviceId === null) continue;
26959
+ if (!restored.has(saved.parentDeviceId)) continue;
26960
+ try {
26961
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
26962
+ restored.add(saved.id);
26963
+ } catch (err) {
26964
+ this.ctx.logger.warn("Failed to restore hub-adopted child", {
26965
+ tags: {
26966
+ stableId: saved.stableId,
26967
+ parentDeviceId: saved.parentDeviceId
26968
+ },
26969
+ meta: {
26970
+ type: saved.type,
26971
+ error: err instanceof Error ? err.message : String(err)
26972
+ }
26973
+ });
26974
+ }
26975
+ }
26673
26976
  }
26674
- const compiled = compileExpressionSafe(src.expr);
26675
- if (!compiled.ok) return compiled.error;
26676
- const bound = new Set(names);
26677
- for (const id of compiled.parsed.identifiers) {
26678
- if (id === "now") continue;
26679
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
26977
+ /** Convert an IDevice to the flat DeviceSummary for the cap router. */
26978
+ toSummary(device) {
26979
+ return toDeviceSummary(device, this.addonId);
26680
26980
  }
26681
- return null;
26682
- }
26683
- var ExpressionBindingSourceSchema = union([
26684
- object({
26685
- kind: literal("field").optional(),
26686
- sourceKey: string(),
26687
- cap: string(),
26688
- fieldPath: string()
26689
- }),
26690
- object({
26691
- kind: literal("literal"),
26692
- value: union([
26693
- string(),
26694
- number(),
26695
- boolean(),
26696
- _null()
26697
- ])
26698
- }),
26699
- object({
26700
- kind: literal("global"),
26701
- sourceStableId: string(),
26702
- cap: string(),
26703
- fieldPath: string()
26704
- })
26705
- ]);
26706
- object({
26707
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
26708
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
26709
- }).superRefine((src, ctx) => {
26710
- const err = validateExpressionSource(src);
26711
- if (err !== null) ctx.addIssue({
26712
- code: "custom",
26713
- message: err,
26714
- path: ["expr"]
26715
- });
26716
- });
26981
+ };
26982
+ DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
26983
+ new Set(Object.values(DeviceType));
26984
+ DeviceFeature.BatteryOperated;
26717
26985
  Object.freeze({
26718
26986
  "accessories.setChildHidden": {
26719
26987
  capName: "accessories",
@@ -31047,6 +31315,12 @@ Object.freeze({
31047
31315
  addonId: null,
31048
31316
  access: "view"
31049
31317
  },
31318
+ "snapshot.getSnapshotLinks": {
31319
+ capName: "snapshot",
31320
+ capScope: "device",
31321
+ addonId: null,
31322
+ access: "view"
31323
+ },
31050
31324
  "snapshot.getSnapshotOverview": {
31051
31325
  capName: "snapshot",
31052
31326
  capScope: "device",
@@ -56797,8 +57071,9 @@ var HmChildDevice = class extends BaseDevice {
56797
57071
  }
56798
57072
  /**
56799
57073
  * Attach a facade `valueChanged` listener filtered to this child's device
56800
- * address. The subclass-supplied `handle` runs only for matching events.
56801
- * No-ops when no facade is present (the child reconnects on next reconcile).
57074
+ * address AND — when the child owns one — to its channel. The
57075
+ * subclass-supplied `handle` runs only for matching events. No-ops when no
57076
+ * facade is present (the child reconnects on next reconcile).
56802
57077
  */
56803
57078
  attachValueChangedListener(handle) {
56804
57079
  this.valueHandler = handle;
@@ -56806,12 +57081,41 @@ var HmChildDevice = class extends BaseDevice {
56806
57081
  if (!facade) return;
56807
57082
  this.valueChangedUnsub = facade.on("valueChanged", (event) => {
56808
57083
  if (event.device !== this.address) return;
57084
+ if (!this.ownsEventChannel(event)) return;
56809
57085
  handle(event);
56810
57086
  });
56811
57087
  this.seedTriggerUnsubs = [facade.on("ready", () => this.seedCurrentValues()), facade.on("connection", () => this.seedCurrentValues())];
56812
57088
  this.seedCurrentValues();
56813
57089
  }
56814
57090
  /**
57091
+ * Whether an inbound value event belongs to THIS child's channel.
57092
+ *
57093
+ * One CCU device address can host SEVERAL independent functional entities —
57094
+ * an HmIP-BS2 carries two unrelated relays, on `<addr>:4` and `<addr>:8`, and
57095
+ * BOTH emit `STATE`. Filtering on the device address alone let either relay's
57096
+ * event write the other's slice: actuating one made both report ON while only
57097
+ * one was physically on. The seed path (`seedCurrentValues`) has always been
57098
+ * channel-scoped; this makes the live path agree with it.
57099
+ *
57100
+ * Channel-less children (event-emitter, firmware update) span the whole
57101
+ * device by design and accept every channel.
57102
+ */
57103
+ ownsEventChannel(event) {
57104
+ const owned = this.channel;
57105
+ if (owned === void 0) return true;
57106
+ const channel = event.channel;
57107
+ if (typeof channel === "string" && channel.length > 0) return channel === owned;
57108
+ this.ctx.logger.warn("Homematic value event without a channel — dropped", {
57109
+ tags: { deviceId: this.ctx.id },
57110
+ meta: {
57111
+ address: this.address,
57112
+ ownedChannel: owned,
57113
+ parameter: event.parameter
57114
+ }
57115
+ });
57116
+ return false;
57117
+ }
57118
+ /**
56815
57119
  * Replay this child's CURRENT cached datapoint values through the registered
56816
57120
  * value handler. Reads the live `HmDevice` snapshot (the library backfills the
56817
57121
  * seeded values onto its datapoints) and feeds each non-null value on THIS