@camstack/addon-post-analysis 1.2.52 → 1.2.53

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.
@@ -9652,12 +9652,34 @@ method(object({
9652
9652
  kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
9653
9653
  /** GIF geometry. The video keeps the source's own. */
9654
9654
  gifMaxWidth: number().int().min(120).max(1280).default(640),
9655
- gifFps: number().int().min(1).max(15).default(8),
9656
9655
  /**
9657
- * Playback rate, applied to EVERY container so they stay one clip.
9658
- * `1` is real time and is what allows the copy branch.
9656
+ * The gif's own PLAYBACK rate in frames per second — what the finished
9657
+ * gif runs at, not how many source frames feed it. The decimation that
9658
+ * feeds it samples `gifFps / gifSpeed` source frames per second, so at
9659
+ * the defaults a 12 fps gif is built out of 3 source frames a second.
9659
9660
  */
9660
- speed: number().min(1).max(8).default(1)
9661
+ gifFps: number().int().min(1).max(15).default(12),
9662
+ /**
9663
+ * How fast the GIF plays against real time, independent of `speed`.
9664
+ *
9665
+ * 4× by default, by operator request: a notification gif is glanced at
9666
+ * on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
9667
+ * a separate knob from `speed` even though both now default to 4 —
9668
+ * a caller wanting a real-time video and a fast gif must not have to
9669
+ * choose.
9670
+ */
9671
+ gifSpeed: number().min(1).max(8).default(4),
9672
+ /**
9673
+ * Playback rate of the VIDEO. Also 4× by default, by operator decision.
9674
+ *
9675
+ * `1` is real time and is the ONLY value that allows the copy branch —
9676
+ * anything else forces `libx264` over the window. That was priced
9677
+ * before it was chosen: a per-event burst measured at 0.23 s and 254 KB
9678
+ * on a real 615 720p cut, against 922 KB for the copy it replaces. A
9679
+ * re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
9680
+ * once the decode is forced the width stops being free.
9681
+ */
9682
+ speed: number().min(1).max(8).default(4)
9661
9683
  }), EventMediaProductionSchema, {
9662
9684
  kind: "mutation",
9663
9685
  auth: "admin"
@@ -9959,25 +9981,6 @@ var cameraStreamsCapability = {
9959
9981
  function kebabToCamel(s) {
9960
9982
  return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
9961
9983
  }
9962
- /**
9963
- * core-blocks — user-authored TypeScript, stored in the kernel and executed in
9964
- * its own process.
9965
- *
9966
- * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
9967
- *
9968
- * The first use is **owning devices without being a device provider**: a block
9969
- * declares devices under a system or custom integration and drives their state,
9970
- * with the same `ctx` an addon gets. Automations come later; nothing here
9971
- * models a trigger.
9972
- *
9973
- * **Stated plainly, because it does not change by being true:** a block has an
9974
- * addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
9975
- * with no review step. What makes that survivable is not a sandbox, it is
9976
- * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
9977
- * so a block that throws or never returns is marked `failed` and visible
9978
- * instead of taking the hub with it (D6). Every method here is admin-only, and
9979
- * must stay so.
9980
- */
9981
9984
  /** Where a block runs. The operator chooses — a block driving a device on an
9982
9985
  * agent is the reason placement is not fixed to the hub. */
9983
9986
  var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
@@ -10049,6 +10052,9 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
10049
10052
  }), object({ block: CoreBlockSchema }), {
10050
10053
  kind: "mutation",
10051
10054
  auth: "admin"
10055
+ }), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
10056
+ kind: "mutation",
10057
+ auth: "admin"
10052
10058
  }), method(object({ code: string() }), CoreBlockCompileResultSchema, {
10053
10059
  kind: "mutation",
10054
10060
  auth: "admin"
@@ -10849,713 +10855,118 @@ var ExposeInputSchema = object({
10849
10855
  });
10850
10856
  var UnexposeInputSchema = object({ deviceId: string() });
10851
10857
  method(_void(), DeviceExportStatusSchema), method(_void(), array(DeviceKindSchema)), method(_void(), array(ExposedDeviceSchema)), method(ExposeInputSchema, _void(), { kind: "mutation" }), method(UnexposeInputSchema, _void(), { kind: "mutation" });
10858
+ var ProviderStatusSchema = object({
10859
+ connected: boolean(),
10860
+ deviceCount: number(),
10861
+ error: string().optional()
10862
+ });
10863
+ object({
10864
+ externalId: string(),
10865
+ name: string(),
10866
+ type: string(),
10867
+ metadata: record(string(), unknown()).optional()
10868
+ });
10852
10869
  /**
10853
- * Resource-bound constants for the safe expression engine.
10854
- *
10855
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
10856
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
10857
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
10858
- * work a single author-supplied expression can request, so a hostile or
10859
- * accidental pathological string can never spend unbounded CPU/memory.
10870
+ * Candidate handed back from discovery and accepted by
10871
+ * `adoptDiscoveredDevice`. Shape mirrors the in-process
10872
+ * `DiscoveredDevice` interface used by `DeviceDiscovery`.
10860
10873
  */
10861
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
10862
- * rejected without allocation. */
10863
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
10864
- /** A legal binding / identifier name. */
10865
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
10866
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
10867
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
10868
- var RESERVED_BINDING_NAMES = new Set([
10869
- "now",
10870
- "true",
10871
- "false",
10872
- "null"
10873
- ]);
10874
+ var DiscoveryCandidateSchema = object({
10875
+ stableId: string(),
10876
+ type: _enum(DeviceType),
10877
+ suggestedName: string(),
10878
+ prefilledConfig: record(string(), unknown()),
10879
+ /**
10880
+ * Optional upstream-system identity (HA entity_id, vendor MAC, …).
10881
+ * Discovery pre-populates this for systems that know the upstream
10882
+ * identity ahead of adoption. Rendering metadata (unit, precision)
10883
+ * flows live through the cap STATUS SLICE after adoption.
10884
+ */
10885
+ sourceInfo: SourceInfoSchema.optional()
10886
+ });
10874
10887
  /**
10875
- * Error types for the safe expression engine. Two distinct classes so callers
10876
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
10877
- * failure — both are non-fatal to the host: read paths degrade to "skip link".
10888
+ * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
10889
+ * Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
10890
+ * tRPC layer can pass it through without reshaping.
10878
10891
  */
10879
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
10880
- * the failure is anchored to a character (author-facing inline feedback). */
10881
- var ExpressionParseError = class extends Error {
10882
- position;
10883
- constructor(message, position) {
10884
- super(message);
10885
- this.name = "ExpressionParseError";
10886
- this.position = position;
10887
- }
10888
- };
10889
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
10890
- * result, unknown builtin, step-budget exceeded). */
10891
- var ExpressionEvalError = class extends Error {
10892
- constructor(message) {
10893
- super(message);
10894
- this.name = "ExpressionEvalError";
10895
- }
10896
- };
10892
+ var DeviceSummarySchema = object({
10893
+ id: number(),
10894
+ stableId: string(),
10895
+ addonId: string(),
10896
+ type: string(),
10897
+ name: string(),
10898
+ parentDeviceId: number().nullable(),
10899
+ online: boolean(),
10900
+ features: array(string()),
10901
+ config: record(string(), unknown()),
10902
+ /** Optional upstream-system identity (dispatch key + system tag).
10903
+ * See `SourceInfo`. Present when the device has a non-synthetic
10904
+ * source identifier (HA entities, vendor MAC, …); omitted when the
10905
+ * synthetic backfill is in effect. */
10906
+ sourceInfo: SourceInfoSchema.optional()
10907
+ });
10897
10908
  /**
10898
- * Frozen, null-prototype builtin function table for the expression engine
10899
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
10900
- * parser rejects any callee not in it, and the evaluator gates each call on an
10901
- * own-property check against it.
10909
+ * Result of a live field test (e.g. probing an RTSP URL during device
10910
+ * creation). Matches the UI-side `FieldProbeResult` in
10911
+ * `interfaces/config-ui.ts` — the admin `FormBuilder` renders the
10912
+ * returned `labels` as chips next to the input.
10913
+ */
10914
+ var FieldProbeResultSchema = object({
10915
+ status: _enum(["ok", "error"]),
10916
+ labels: array(string()).optional(),
10917
+ error: string().optional()
10918
+ });
10919
+ /**
10920
+ * The output of `getChildCreationSchema` is a UI schema tree. We store
10921
+ * it as `unknown` at the capability layer — the router just passes it
10922
+ * through and the admin UI renders it via `FormBuilder`. The actual
10923
+ * type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
10924
+ * but we deliberately avoid a Zod mirror because the union is large and
10925
+ * not meant for runtime validation at this seam.
10926
+ */
10927
+ var CreationSchemaOutputSchema = unknown();
10928
+ method(_void(), _void(), { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), ProviderStatusSchema), method(_void(), array(object({
10929
+ id: string(),
10930
+ name: string(),
10931
+ type: string()
10932
+ }))), method(object({}), boolean()), method(object({ params: record(string(), unknown()).optional() }), array(DiscoveryCandidateSchema), {
10933
+ kind: "mutation",
10934
+ auth: "admin"
10935
+ }), method(object({}), CreationSchemaOutputSchema), method(object({}), object({ deviceType: _enum(DeviceType).nullable() })), method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
10936
+ kind: "mutation",
10937
+ auth: "admin"
10938
+ }), method(object({}), boolean()), method(object({ type: _enum(DeviceType) }), CreationSchemaOutputSchema), method(object({
10939
+ type: _enum(DeviceType),
10940
+ config: record(string(), unknown())
10941
+ }), DeviceSummarySchema, {
10942
+ kind: "mutation",
10943
+ auth: "admin"
10944
+ }), method(object({
10945
+ type: _enum(DeviceType),
10946
+ key: string(),
10947
+ value: unknown(),
10948
+ formValues: record(string(), unknown()).optional()
10949
+ }), FieldProbeResultSchema, {
10950
+ kind: "mutation",
10951
+ auth: "admin"
10952
+ });
10953
+ /**
10954
+ * Device Manager capability — hub-side singleton that unifies device persistence,
10955
+ * live registry access, and all management operations into a single tRPC surface.
10902
10956
  *
10903
- * Because the object has a NULL prototype AND is `Object.freeze`d:
10904
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
10905
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
10906
- * (there is no `Object.prototype` in the chain), so those names are not
10907
- * callable — they are simply "unknown function" at parse time.
10957
+ * Replaces:
10958
+ * - `device-persistence` capability (persistence methods absorbed here)
10959
+ * - `device-management.router.ts` (deleted in Phase 2)
10960
+ * - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
10908
10961
  *
10909
- * Every numeric argument is validated as a finite number and every numeric
10910
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
10911
- * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
10912
- * closed rather than emitting a garbage value.
10913
- */
10914
- function asFiniteNumber(value, name, index) {
10915
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
10916
- return value;
10917
- }
10918
- function asString$1(value, name, index) {
10919
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
10920
- return value;
10921
- }
10922
- function finiteResult(value, name) {
10923
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
10924
- return value;
10925
- }
10926
- function allFiniteNumbers(args, name) {
10927
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
10928
- }
10929
- var INF = Number.POSITIVE_INFINITY;
10930
- var table = {
10931
- min: {
10932
- minArgs: 1,
10933
- maxArgs: INF,
10934
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
10935
- },
10936
- max: {
10937
- minArgs: 1,
10938
- maxArgs: INF,
10939
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
10940
- },
10941
- abs: {
10942
- minArgs: 1,
10943
- maxArgs: 1,
10944
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
10945
- },
10946
- floor: {
10947
- minArgs: 1,
10948
- maxArgs: 1,
10949
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
10950
- },
10951
- ceil: {
10952
- minArgs: 1,
10953
- maxArgs: 1,
10954
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
10955
- },
10956
- sqrt: {
10957
- minArgs: 1,
10958
- maxArgs: 1,
10959
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
10960
- },
10961
- round: {
10962
- minArgs: 1,
10963
- maxArgs: 2,
10964
- apply: (args) => {
10965
- const x = asFiniteNumber(args[0], "round", 0);
10966
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
10967
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
10968
- const factor = 10 ** digits;
10969
- return finiteResult(Math.round(x * factor) / factor, "round");
10970
- }
10971
- },
10972
- pow: {
10973
- minArgs: 2,
10974
- maxArgs: 2,
10975
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
10976
- },
10977
- clamp: {
10978
- minArgs: 3,
10979
- maxArgs: 3,
10980
- apply: (args) => {
10981
- const x = asFiniteNumber(args[0], "clamp", 0);
10982
- const lo = asFiniteNumber(args[1], "clamp", 1);
10983
- const hi = asFiniteNumber(args[2], "clamp", 2);
10984
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
10985
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
10986
- }
10987
- },
10988
- avg: {
10989
- minArgs: 1,
10990
- maxArgs: INF,
10991
- apply: (args) => {
10992
- const nums = allFiniteNumbers(args, "avg");
10993
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
10994
- }
10995
- },
10996
- sum: {
10997
- minArgs: 1,
10998
- maxArgs: INF,
10999
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
11000
- },
11001
- coalesce: {
11002
- minArgs: 1,
11003
- maxArgs: INF,
11004
- apply: (args) => {
11005
- for (const a of args) if (a !== null) return a;
11006
- return null;
11007
- }
11008
- },
11009
- age: {
11010
- minArgs: 2,
11011
- maxArgs: 2,
11012
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
11013
- },
11014
- convert: {
11015
- minArgs: 3,
11016
- maxArgs: 3,
11017
- apply: (args, hooks) => {
11018
- const x = asFiniteNumber(args[0], "convert", 0);
11019
- const from = asString$1(args[1], "convert", 1).trim();
11020
- const to = asString$1(args[2], "convert", 2).trim();
11021
- if (hooks.convert) {
11022
- const out = hooks.convert(x, from, to);
11023
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
11024
- return finiteResult(out, "convert");
11025
- }
11026
- if (from === to) return x;
11027
- throw new ExpressionEvalError("convert: unit conversion table not installed");
11028
- }
11029
- }
11030
- };
11031
- Object.freeze(Object.assign(Object.create(null), table));
11032
- /** The set of valid builtin names — used by the parser to reject unknown
11033
- * callees at parse time (immediate author feedback). */
11034
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
11035
- /**
11036
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
11037
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
11038
- * single/double-quoted strings with a tiny escape set, identifiers, the three
11039
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
11040
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
11041
- * is a parse error with a source position, so member access / assignment /
11042
- * template literals are lexically impossible.
11043
- */
11044
- var KEYWORDS = new Set([
11045
- "true",
11046
- "false",
11047
- "null"
11048
- ]);
11049
- function isDigit(ch) {
11050
- return ch >= "0" && ch <= "9";
11051
- }
11052
- function isIdentStart(ch) {
11053
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
11054
- }
11055
- function isIdentPart(ch) {
11056
- return isIdentStart(ch) || isDigit(ch);
11057
- }
11058
- function isWhitespace(ch) {
11059
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
11060
- }
11061
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
11062
- * Throws `ExpressionParseError` on any illegal character or unterminated
11063
- * string. */
11064
- function tokenize(source) {
11065
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
11066
- const tokens = [];
11067
- let i = 0;
11068
- const n = source.length;
11069
- while (i < n) {
11070
- const ch = source[i];
11071
- if (isWhitespace(ch)) {
11072
- i += 1;
11073
- continue;
11074
- }
11075
- if (isDigit(ch)) {
11076
- const start = i;
11077
- while (i < n && isDigit(source[i])) i += 1;
11078
- if (i < n && source[i] === ".") {
11079
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
11080
- i += 1;
11081
- while (i < n && isDigit(source[i])) i += 1;
11082
- }
11083
- const text = source.slice(start, i);
11084
- const value = Number(text);
11085
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
11086
- tokens.push({
11087
- type: "number",
11088
- value,
11089
- pos: start
11090
- });
11091
- continue;
11092
- }
11093
- if (ch === "'" || ch === "\"") {
11094
- const quote = ch;
11095
- const start = i;
11096
- i += 1;
11097
- let out = "";
11098
- let closed = false;
11099
- while (i < n) {
11100
- const c = source[i];
11101
- if (c === "\\") {
11102
- const next = i + 1 < n ? source[i + 1] : "";
11103
- if (next === "\\" || next === "'" || next === "\"") {
11104
- out += next;
11105
- i += 2;
11106
- continue;
11107
- }
11108
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
11109
- }
11110
- if (c === quote) {
11111
- closed = true;
11112
- i += 1;
11113
- break;
11114
- }
11115
- out += c;
11116
- i += 1;
11117
- }
11118
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
11119
- tokens.push({
11120
- type: "string",
11121
- value: out,
11122
- pos: start
11123
- });
11124
- continue;
11125
- }
11126
- if (isIdentStart(ch)) {
11127
- const start = i;
11128
- while (i < n && isIdentPart(source[i])) i += 1;
11129
- const text = source.slice(start, i);
11130
- if (KEYWORDS.has(text)) tokens.push({
11131
- type: "keyword",
11132
- keyword: keywordOf(text),
11133
- pos: start
11134
- });
11135
- else tokens.push({
11136
- type: "identifier",
11137
- name: text,
11138
- pos: start
11139
- });
11140
- continue;
11141
- }
11142
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
11143
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
11144
- tokens.push({
11145
- type: "punct",
11146
- punct: two,
11147
- pos: i
11148
- });
11149
- i += 2;
11150
- continue;
11151
- }
11152
- if (isSinglePunct(ch)) {
11153
- tokens.push({
11154
- type: "punct",
11155
- punct: ch,
11156
- pos: i
11157
- });
11158
- i += 1;
11159
- continue;
11160
- }
11161
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
11162
- }
11163
- tokens.push({
11164
- type: "eof",
11165
- pos: n
11166
- });
11167
- return tokens;
11168
- }
11169
- function keywordOf(text) {
11170
- if (text === "true") return "true";
11171
- if (text === "false") return "false";
11172
- return "null";
11173
- }
11174
- function isSinglePunct(ch) {
11175
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
11176
- }
11177
- /**
11178
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
11179
- *
11180
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
11181
- * → relational → additive → multiplicative → unary `! -` → call / primary.
11182
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
11183
- * string validated against the builtin table at parse time, so an unknown
11184
- * function is rejected immediately (author feedback) and a persisted expression
11185
- * that references a since-removed builtin degrades at read.
11186
- *
11187
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
11188
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
11189
- */
11190
- /** Binary/logical operator precedence (higher binds tighter). */
11191
- var BINARY_PRECEDENCE = {
11192
- "||": 1,
11193
- "&&": 2,
11194
- "==": 3,
11195
- "!=": 3,
11196
- "<": 4,
11197
- "<=": 4,
11198
- ">": 4,
11199
- ">=": 4,
11200
- "+": 5,
11201
- "-": 5,
11202
- "*": 6,
11203
- "/": 6,
11204
- "%": 6
11205
- };
11206
- function isLogicalOp(op) {
11207
- return op === "&&" || op === "||";
11208
- }
11209
- function isBinaryOp(op) {
11210
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
11211
- }
11212
- var Parser = class {
11213
- tokens;
11214
- pos = 0;
11215
- nodeCount = 0;
11216
- identifiers = /* @__PURE__ */ new Set();
11217
- callees = /* @__PURE__ */ new Set();
11218
- constructor(tokens) {
11219
- this.tokens = tokens;
11220
- }
11221
- parse() {
11222
- const ast = this.parseTernary();
11223
- const tok = this.peek();
11224
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
11225
- return {
11226
- ast,
11227
- identifiers: this.identifiers,
11228
- callees: this.callees,
11229
- nodeCount: this.nodeCount
11230
- };
11231
- }
11232
- peek() {
11233
- return this.tokens[this.pos];
11234
- }
11235
- next() {
11236
- return this.tokens[this.pos++];
11237
- }
11238
- /** Consume a punctuator token, erroring if the next token isn't it. */
11239
- expectPunct(punct) {
11240
- const tok = this.peek();
11241
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
11242
- this.pos += 1;
11243
- }
11244
- matchPunct(punct) {
11245
- const tok = this.peek();
11246
- if (tok.type === "punct" && tok.punct === punct) {
11247
- this.pos += 1;
11248
- return true;
11249
- }
11250
- return false;
11251
- }
11252
- countNode() {
11253
- this.nodeCount += 1;
11254
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
11255
- }
11256
- parseTernary() {
11257
- const test = this.parseBinary(1);
11258
- if (this.matchPunct("?")) {
11259
- const consequent = this.parseTernary();
11260
- this.expectPunct(":");
11261
- const alternate = this.parseTernary();
11262
- this.countNode();
11263
- return {
11264
- kind: "conditional",
11265
- test,
11266
- consequent,
11267
- alternate
11268
- };
11269
- }
11270
- return test;
11271
- }
11272
- parseBinary(minPrec) {
11273
- let left = this.parseUnary();
11274
- for (;;) {
11275
- const tok = this.peek();
11276
- if (tok.type !== "punct") break;
11277
- const prec = BINARY_PRECEDENCE[tok.punct];
11278
- if (prec === void 0 || prec < minPrec) break;
11279
- const op = tok.punct;
11280
- this.pos += 1;
11281
- const right = this.parseBinary(prec + 1);
11282
- this.countNode();
11283
- if (isLogicalOp(op)) left = {
11284
- kind: "logical",
11285
- op,
11286
- left,
11287
- right
11288
- };
11289
- else if (isBinaryOp(op)) left = {
11290
- kind: "binary",
11291
- op,
11292
- left,
11293
- right
11294
- };
11295
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
11296
- }
11297
- return left;
11298
- }
11299
- parseUnary() {
11300
- const tok = this.peek();
11301
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
11302
- const op = tok.punct;
11303
- this.pos += 1;
11304
- const operand = this.parseUnary();
11305
- this.countNode();
11306
- return {
11307
- kind: "unary",
11308
- op,
11309
- operand
11310
- };
11311
- }
11312
- return this.parsePrimary();
11313
- }
11314
- parsePrimary() {
11315
- const tok = this.next();
11316
- switch (tok.type) {
11317
- case "number":
11318
- this.countNode();
11319
- return {
11320
- kind: "literal",
11321
- value: tok.value
11322
- };
11323
- case "string":
11324
- this.countNode();
11325
- return {
11326
- kind: "literal",
11327
- value: tok.value
11328
- };
11329
- case "keyword":
11330
- this.countNode();
11331
- return {
11332
- kind: "literal",
11333
- value: tok.keyword === "null" ? null : tok.keyword === "true"
11334
- };
11335
- case "identifier": {
11336
- const nextTok = this.peek();
11337
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
11338
- this.identifiers.add(tok.name);
11339
- this.countNode();
11340
- return {
11341
- kind: "identifier",
11342
- name: tok.name
11343
- };
11344
- }
11345
- case "punct":
11346
- if (tok.punct === "(") {
11347
- const inner = this.parseTernary();
11348
- this.expectPunct(")");
11349
- return inner;
11350
- }
11351
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
11352
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
11353
- }
11354
- }
11355
- parseCall(callee, pos) {
11356
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
11357
- this.expectPunct("(");
11358
- const args = [];
11359
- if (!this.matchPunct(")")) for (;;) {
11360
- args.push(this.parseTernary());
11361
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
11362
- if (this.matchPunct(",")) continue;
11363
- this.expectPunct(")");
11364
- break;
11365
- }
11366
- this.callees.add(callee);
11367
- this.countNode();
11368
- return {
11369
- kind: "call",
11370
- callee,
11371
- args
11372
- };
11373
- }
11374
- };
11375
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
11376
- * `ExpressionParseError` on any lexical or grammatical failure. */
11377
- function parseExpression(source) {
11378
- return new Parser(tokenize(source)).parse();
11379
- }
11380
- /**
11381
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
11382
- * by expr"). The cache stores BOTH successes and failures (negative caching),
11383
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
11384
- * one per read on a hot resolve path.
11385
- *
11386
- * The cache is a module-level singleton: entries are pure, content-addressed
11387
- * ASTs keyed by the raw source string, so sharing one instance across all
11388
- * callers is safe and maximises hit rate.
11389
- */
11390
- var cache = /* @__PURE__ */ new Map();
11391
- function getCached(source) {
11392
- const hit = cache.get(source);
11393
- if (hit !== void 0) {
11394
- cache.delete(source);
11395
- cache.set(source, hit);
11396
- return hit;
11397
- }
11398
- let result;
11399
- try {
11400
- result = {
11401
- ok: true,
11402
- parsed: parseExpression(source)
11403
- };
11404
- } catch (err) {
11405
- result = {
11406
- ok: false,
11407
- error: err instanceof ExpressionParseError ? err.message : String(err)
11408
- };
11409
- }
11410
- cache.set(source, result);
11411
- if (cache.size > 256) {
11412
- const oldest = cache.keys().next().value;
11413
- if (oldest !== void 0) cache.delete(oldest);
11414
- }
11415
- return result;
11416
- }
11417
- /** Compile `source`, returning a discriminated result instead of throwing.
11418
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
11419
- function compileExpressionSafe(source) {
11420
- return getCached(source);
11421
- }
11422
- Object.freeze({});
11423
- /**
11424
- * Author-time validation. Returns `null` when the source is valid, else a
11425
- * human-readable error message. Checks: the expression compiles; binding count
11426
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
11427
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
11428
- * FREE identifier of the AST is covered by a binding or the injected `now`.
11429
- */
11430
- function validateExpressionSource(src) {
11431
- const names = Object.keys(src.bindings);
11432
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
11433
- for (const name of names) {
11434
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
11435
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
11436
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
11437
- }
11438
- const compiled = compileExpressionSafe(src.expr);
11439
- if (!compiled.ok) return compiled.error;
11440
- const bound = new Set(names);
11441
- for (const id of compiled.parsed.identifiers) {
11442
- if (id === "now") continue;
11443
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
11444
- }
11445
- return null;
11446
- }
11447
- var ProviderStatusSchema = object({
11448
- connected: boolean(),
11449
- deviceCount: number(),
11450
- error: string().optional()
11451
- });
11452
- object({
11453
- externalId: string(),
11454
- name: string(),
11455
- type: string(),
11456
- metadata: record(string(), unknown()).optional()
11457
- });
11458
- /**
11459
- * Candidate handed back from discovery and accepted by
11460
- * `adoptDiscoveredDevice`. Shape mirrors the in-process
11461
- * `DiscoveredDevice` interface used by `DeviceDiscovery`.
11462
- */
11463
- var DiscoveryCandidateSchema = object({
11464
- stableId: string(),
11465
- type: _enum(DeviceType),
11466
- suggestedName: string(),
11467
- prefilledConfig: record(string(), unknown()),
11468
- /**
11469
- * Optional upstream-system identity (HA entity_id, vendor MAC, …).
11470
- * Discovery pre-populates this for systems that know the upstream
11471
- * identity ahead of adoption. Rendering metadata (unit, precision)
11472
- * flows live through the cap STATUS SLICE after adoption.
11473
- */
11474
- sourceInfo: SourceInfoSchema.optional()
11475
- });
11476
- /**
11477
- * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
11478
- * Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
11479
- * tRPC layer can pass it through without reshaping.
11480
- */
11481
- var DeviceSummarySchema = object({
11482
- id: number(),
11483
- stableId: string(),
11484
- addonId: string(),
11485
- type: string(),
11486
- name: string(),
11487
- parentDeviceId: number().nullable(),
11488
- online: boolean(),
11489
- features: array(string()),
11490
- config: record(string(), unknown()),
11491
- /** Optional upstream-system identity (dispatch key + system tag).
11492
- * See `SourceInfo`. Present when the device has a non-synthetic
11493
- * source identifier (HA entities, vendor MAC, …); omitted when the
11494
- * synthetic backfill is in effect. */
11495
- sourceInfo: SourceInfoSchema.optional()
11496
- });
11497
- /**
11498
- * Result of a live field test (e.g. probing an RTSP URL during device
11499
- * creation). Matches the UI-side `FieldProbeResult` in
11500
- * `interfaces/config-ui.ts` — the admin `FormBuilder` renders the
11501
- * returned `labels` as chips next to the input.
11502
- */
11503
- var FieldProbeResultSchema = object({
11504
- status: _enum(["ok", "error"]),
11505
- labels: array(string()).optional(),
11506
- error: string().optional()
11507
- });
11508
- /**
11509
- * The output of `getChildCreationSchema` is a UI schema tree. We store
11510
- * it as `unknown` at the capability layer — the router just passes it
11511
- * through and the admin UI renders it via `FormBuilder`. The actual
11512
- * type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
11513
- * but we deliberately avoid a Zod mirror because the union is large and
11514
- * not meant for runtime validation at this seam.
11515
- */
11516
- var CreationSchemaOutputSchema = unknown();
11517
- method(_void(), _void(), { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), ProviderStatusSchema), method(_void(), array(object({
11518
- id: string(),
11519
- name: string(),
11520
- type: string()
11521
- }))), method(object({}), boolean()), method(object({ params: record(string(), unknown()).optional() }), array(DiscoveryCandidateSchema), {
11522
- kind: "mutation",
11523
- auth: "admin"
11524
- }), method(object({}), CreationSchemaOutputSchema), method(object({}), object({ deviceType: _enum(DeviceType).nullable() })), method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
11525
- kind: "mutation",
11526
- auth: "admin"
11527
- }), method(object({}), boolean()), method(object({ type: _enum(DeviceType) }), CreationSchemaOutputSchema), method(object({
11528
- type: _enum(DeviceType),
11529
- config: record(string(), unknown())
11530
- }), DeviceSummarySchema, {
11531
- kind: "mutation",
11532
- auth: "admin"
11533
- }), method(object({
11534
- type: _enum(DeviceType),
11535
- key: string(),
11536
- value: unknown(),
11537
- formValues: record(string(), unknown()).optional()
11538
- }), FieldProbeResultSchema, {
11539
- kind: "mutation",
11540
- auth: "admin"
11541
- });
11542
- /**
11543
- * Device Manager capability — hub-side singleton that unifies device persistence,
11544
- * live registry access, and all management operations into a single tRPC surface.
11545
- *
11546
- * Replaces:
11547
- * - `device-persistence` capability (persistence methods absorbed here)
11548
- * - `device-management.router.ts` (deleted in Phase 2)
11549
- * - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
11550
- *
11551
- * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
11552
- * fork into separate processes but never run on remote cluster agents. Therefore:
11553
- * - No nodeId routing needed — this is a pure hub singleton.
11554
- * - The hub's DeviceRegistry is the single source of truth for all live devices.
11555
- * - No shadow registry or cross-node aggregation required.
11556
- *
11557
- * Forked workers register devices back to the hub via `ctx.devices`
11558
- * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
10962
+ * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
10963
+ * fork into separate processes but never run on remote cluster agents. Therefore:
10964
+ * - No nodeId routing needed — this is a pure hub singleton.
10965
+ * - The hub's DeviceRegistry is the single source of truth for all live devices.
10966
+ * - No shadow registry or cross-node aggregation required.
10967
+ *
10968
+ * Forked workers register devices back to the hub via `ctx.devices`
10969
+ * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
11559
10970
  */
11560
10971
  /** One child-placement directive on a container's `childLayout`. Structurally
11561
10972
  * identical to `ChildLayoutEntry` in `device-management.ts` — the cap wire
@@ -11568,92 +10979,6 @@ var ChildLayoutEntrySchema = object({
11568
10979
  order: number().optional(),
11569
10980
  collapsed: boolean().optional()
11570
10981
  });
11571
- /** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
11572
- * `device-management.ts`. Source is a union: a FIELD source copies a sibling
11573
- * accessory's status field (`kind` optional/absent for wire compat); a
11574
- * LITERAL source carries a per-device constant (no sibling is read); a
11575
- * GLOBAL source (P2e) copies ANY device's status field, addressed by the
11576
- * source device's full re-sync-stable `stableId`. */
11577
- var DeviceLinkFieldSourceSchema = object({
11578
- kind: literal("field").optional(),
11579
- sourceKey: string(),
11580
- cap: string(),
11581
- fieldPath: string()
11582
- });
11583
- var DeviceLinkLiteralSourceSchema = object({
11584
- kind: literal("literal"),
11585
- value: union([
11586
- string(),
11587
- number(),
11588
- boolean(),
11589
- _null()
11590
- ])
11591
- });
11592
- var DeviceLinkGlobalSourceSchema = object({
11593
- kind: literal("global"),
11594
- sourceStableId: string(),
11595
- cap: string(),
11596
- fieldPath: string()
11597
- });
11598
- /** Expression source (Stage X): compute the target field from N named bindings
11599
- * via the safe expression engine. Bindings are field | literal | global — never
11600
- * another expression (no nesting). The `superRefine` runs the SAME author-time
11601
- * validation as `validateExpressionSource` (compiles the expr, checks binding
11602
- * names + identifier coverage) so every wire boundary that parses a DeviceLink
11603
- * (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
11604
- * Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
11605
- var DeviceLinkExpressionSourceSchema = object({
11606
- kind: literal("expression"),
11607
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
11608
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), union([
11609
- DeviceLinkFieldSourceSchema,
11610
- DeviceLinkLiteralSourceSchema,
11611
- DeviceLinkGlobalSourceSchema
11612
- ]))
11613
- }).superRefine((src, ctx) => {
11614
- const err = validateExpressionSource(src);
11615
- if (err !== null) ctx.addIssue({
11616
- code: "custom",
11617
- message: err,
11618
- path: ["expr"]
11619
- });
11620
- });
11621
- var DeviceLinkSchema = object({
11622
- id: string(),
11623
- source: union([
11624
- DeviceLinkFieldSourceSchema,
11625
- DeviceLinkLiteralSourceSchema,
11626
- DeviceLinkGlobalSourceSchema,
11627
- DeviceLinkExpressionSourceSchema
11628
- ]),
11629
- target: object({
11630
- cap: string(),
11631
- fieldPath: string(),
11632
- itemKey: string().optional()
11633
- }),
11634
- transform: discriminatedUnion("kind", [
11635
- object({ kind: literal("identity") }),
11636
- object({
11637
- kind: literal("enum-map"),
11638
- mapping: record(string(), union([
11639
- string(),
11640
- number(),
11641
- boolean()
11642
- ])),
11643
- fallback: union([
11644
- string(),
11645
- number(),
11646
- boolean()
11647
- ]).optional()
11648
- }),
11649
- object({
11650
- kind: literal("linear"),
11651
- scale: number(),
11652
- offset: number(),
11653
- clamp: tuple([number(), number()]).readonly().optional()
11654
- })
11655
- ]).optional()
11656
- });
11657
10982
  /** Cap-wire shape of a per-cap display refinement — mirrors
11658
10983
  * `DeviceCapDisplayOverride` in `device-management.ts`. */
11659
10984
  var DeviceCapDisplayOverrideSchema = object({
@@ -11733,8 +11058,6 @@ var DeviceInfoSchema = object({
11733
11058
  * named accordion sections (with optional intra-section order). See
11734
11059
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
11735
11060
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
11736
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
11737
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
11738
11061
  /** Operator-authored per-device display override. See `DeviceMeta.display`. */
11739
11062
  display: DeviceDisplayOverrideSchema.optional()
11740
11063
  });
@@ -11743,7 +11066,7 @@ var ConfigEntrySchema = object({
11743
11066
  value: unknown(),
11744
11067
  description: string().optional()
11745
11068
  });
11746
- var DeviceLinkModeSchema = _enum(["auto", "manual"]);
11069
+ var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
11747
11070
  /** One resolved linked device — the compact projection consumers need. */
11748
11071
  var LinkedDeviceSchema = object({
11749
11072
  deviceId: number(),
@@ -11806,8 +11129,6 @@ var DeviceMetaSchema = object({
11806
11129
  * accordion sections (with optional intra-section order). See
11807
11130
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
11808
11131
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
11809
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
11810
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
11811
11132
  /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
11812
11133
  * Optional: only present for accessory children that carry a known role. */
11813
11134
  role: string().nullable().optional(),
@@ -11900,12 +11221,6 @@ method(object({
11900
11221
  }), _void(), {
11901
11222
  kind: "mutation",
11902
11223
  auth: "admin"
11903
- }), method(object({
11904
- deviceId: number(),
11905
- deviceLinks: array(DeviceLinkSchema).readonly()
11906
- }), _void(), {
11907
- kind: "mutation",
11908
- auth: "admin"
11909
11224
  }), method(object({
11910
11225
  deviceId: number(),
11911
11226
  display: DeviceDisplayOverrideSchema.nullable()
@@ -11987,7 +11302,7 @@ method(object({
11987
11302
  * shipping 293 rows to find 12. */
11988
11303
  isCamera: boolean().optional()
11989
11304
  }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
11990
- mode: DeviceLinkModeSchema,
11305
+ mode: LinkedDevicesModeSchema,
11991
11306
  devices: array(LinkedDeviceSchema)
11992
11307
  })), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
11993
11308
  deviceId: number(),
@@ -12020,11 +11335,7 @@ method(object({
12020
11335
  deviceId: number(),
12021
11336
  entries: array(object({
12022
11337
  capName: string(),
12023
- kind: _enum([
12024
- "native",
12025
- "wrapped",
12026
- "linked"
12027
- ]),
11338
+ kind: _enum(["native", "wrapped"]),
12028
11339
  providerAddonId: string(),
12029
11340
  providerNodeId: string(),
12030
11341
  nativeAddonId: string()
@@ -12033,11 +11344,7 @@ method(object({
12033
11344
  deviceId: number(),
12034
11345
  entries: array(object({
12035
11346
  capName: string(),
12036
- kind: _enum([
12037
- "native",
12038
- "wrapped",
12039
- "linked"
12040
- ]),
11347
+ kind: _enum(["native", "wrapped"]),
12041
11348
  providerAddonId: string(),
12042
11349
  providerNodeId: string(),
12043
11350
  nativeAddonId: string()
@@ -12850,7 +12157,7 @@ var MotionAnalysisResultSchema = object({
12850
12157
  frameHeight: number(),
12851
12158
  analysisMs: number()
12852
12159
  });
12853
- method(object({
12160
+ DeviceType.Camera, method(object({
12854
12161
  deviceId: number(),
12855
12162
  frame: FrameInputSchema.optional(),
12856
12163
  frameHandle: FrameHandleSchema.optional()
@@ -15522,6 +14829,18 @@ var OauthIntegrationDescriptorSchema = object({
15522
14829
  * redirect_uri that does not start with one of these. Required —
15523
14830
  * an empty list means the integration can never complete linking. */
15524
14831
  allowedRedirectPrefixes: array(string()).min(1),
14832
+ /** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
14833
+ * RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
14834
+ * `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
14835
+ * whose address the hub cannot know in advance (a Home Assistant at
14836
+ * `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
14837
+ * exactly; a public host never satisfies this branch, so it is not a
14838
+ * wildcard prefix by another name. */
14839
+ allowedPrivateHostPaths: array(string()).optional(),
14840
+ /** When true this is a PUBLIC client (source is published, no secret can be
14841
+ * protected) and PKCE is mandatory: `/authorize` refuses without an S256
14842
+ * `code_challenge`, `/token` refuses without the matching `code_verifier`. */
14843
+ requiresPkce: boolean().optional(),
15525
14844
  /** Optional public origin (no trailing slash) that this integration's
15526
14845
  * issued codes/tokens should carry as the `hubUrl` claim — typically the
15527
14846
  * operator-selected external-access endpoint resolved by the addon. When
@@ -15672,7 +14991,7 @@ var TrackEnvelopeSchema = object({
15672
14991
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
15673
14992
  * keeps every scalar the list surfaces actually render (ids, class(es),
15674
14993
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
15675
- * zonesVisited, bestEventId, envelope) and returns `positions` /
14994
+ * zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
15676
14995
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
15677
14996
  * `getTrack`. Mirrors the event-store `projection` convention
15678
14997
  * (`getObjectEvents` et al.).
@@ -15909,6 +15228,24 @@ var TrackSchema = object({
15909
15228
  * Populated from the persisted envelope columns on historical reads;
15910
15229
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
15911
15230
  envelope: TrackEnvelopeSchema.optional(),
15231
+ /**
15232
+ * A face DETECTOR found a face on this track — nothing more. It says the
15233
+ * detail plane produced a `face` detail; it does NOT say the face was
15234
+ * embedded, matched, above `minFacePx`, or that the recognizer was even
15235
+ * enabled. Set once and never cleared.
15236
+ *
15237
+ * **This exists so "face present but not recognised" is expressible.** A
15238
+ * recognised identity lands in `subLabel` (attributed to the face chain via
15239
+ * `subLabelMeta.stepId`), so before this field a track with an unmatched face
15240
+ * and a track with no face at all were byte-identical on the wire and no
15241
+ * surface could tell them apart. The read is `hasFace === true && subLabel
15242
+ * === undefined`.
15243
+ *
15244
+ * **Absent ≠ false.** Every row written before the column existed omits it,
15245
+ * and so does every server that predates the field — a consumer must test
15246
+ * `=== true` and render nothing otherwise, never infer "no face".
15247
+ */
15248
+ hasFace: boolean().optional(),
15912
15249
  ...TrackFlagFields,
15913
15250
  ...TrackRetrainFields
15914
15251
  });
@@ -18977,6 +18314,10 @@ var SsoBridgeClaimsSchema = object({
18977
18314
  integrationId: string().optional(),
18978
18315
  /** JWT ID — unique per issued code; consumed-set enforces single-use. */
18979
18316
  jti: string().optional(),
18317
+ /** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
18318
+ * client. Its PRESENCE is what makes the verifier mandatory at exchange,
18319
+ * so the requirement travels with the code and not with mutable config. */
18320
+ codeChallenge: string().optional(),
18980
18321
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
18981
18322
  * tokens so the verify path can check the session is not revoked. */
18982
18323
  sessionId: string().optional()
@@ -19535,6 +18876,10 @@ var videoclipsCapability = {
19535
18876
  mode: "singleton",
19536
18877
  kind: "wrapper",
19537
18878
  defaultActive: true,
18879
+ /** A clip is a window over a camera's footage — the cap is meaningless on a
18880
+ * sensor, a button or an event emitter, and the `defaultActive` auto-bind
18881
+ * reads this to decide which devices it may claim. */
18882
+ deviceTypes: [DeviceType.Camera],
19538
18883
  methods: {
19539
18884
  listClips: method(object({
19540
18885
  deviceId: number(),
@@ -26131,13 +25476,18 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
26131
25476
  username: string(),
26132
25477
  scopes: array(TokenScopeSchema),
26133
25478
  redirectUri: string(),
26134
- hubUrl: string()
25479
+ hubUrl: string(),
25480
+ /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
25481
+ * that carries one can ONLY be exchanged with the matching verifier. */
25482
+ codeChallenge: string().optional()
26135
25483
  }), object({ code: string() }), {
26136
25484
  kind: "mutation",
26137
25485
  access: "create"
26138
25486
  }), method(object({
26139
25487
  code: string(),
26140
- redirectUri: string()
25488
+ redirectUri: string(),
25489
+ /** PKCE verifier. REQUIRED when the code carries a challenge. */
25490
+ codeVerifier: string().optional()
26141
25491
  }), object({
26142
25492
  accessToken: string(),
26143
25493
  refreshToken: string(),
@@ -26616,1013 +25966,1850 @@ var HistoryPointSchema = object({
26616
25966
  * outside-any-zone). Crossing them with `className?` gives the full
26617
25967
  * combinatorial coverage the operator UI requested.
26618
25968
  */
26619
- var zoneAnalyticsCapability = {
26620
- name: "zone-analytics",
25969
+ var zoneAnalyticsCapability = {
25970
+ name: "zone-analytics",
25971
+ scope: "device",
25972
+ mode: "singleton",
25973
+ deviceTypes: [DeviceType.Camera],
25974
+ methods: {
25975
+ /** Latest computed occupancy snapshot for this camera. Null when
25976
+ * the analytics pipeline hasn't seen a frame for this device yet
25977
+ * (no inference result emitted since boot or since binding was
25978
+ * activated). */
25979
+ getCurrentSnapshot: method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()),
25980
+ /** Time-series object count inside one zone. `className` optional —
25981
+ * omit to count every class in the zone. */
25982
+ getZoneHistory: method(object({
25983
+ deviceId: number(),
25984
+ zoneId: string(),
25985
+ className: string().optional()
25986
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()),
25987
+ /** Time-series frame-wide object count (everywhere). */
25988
+ getCameraHistory: method(object({
25989
+ deviceId: number(),
25990
+ className: string().optional()
25991
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()),
25992
+ /** Time-series count of objects outside every zone. */
25993
+ getUnzonedHistory: method(object({
25994
+ deviceId: number(),
25995
+ className: string().optional()
25996
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly())
25997
+ },
25998
+ /**
25999
+ * Runtime-state slice — the latest occupancy snapshot mirrored by
26000
+ * the analytics frame processor on every inference result. Consumers
26001
+ * read via `device.state.zoneAnalytics.value` and stay in sync
26002
+ * automatically; the explicit `getCurrentSnapshot` cap method is
26003
+ * still useful for one-off polls without a subscription.
26004
+ */
26005
+ runtimeState: CameraOccupancySnapshotSchema
26006
+ };
26007
+ /**
26008
+ * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
26009
+ * cap so a single CRUD surface backs every consumer; each stage has
26010
+ * its own dev-state mirror slice (`motion-zone-rules`,
26011
+ * `detection-zone-rules`, …) so consumer addons subscribe independently.
26012
+ *
26013
+ * Extend the enum here when a new gating consumer comes online (audio
26014
+ * gating, alert filtering, …) — no other surface needs to change.
26015
+ */
26016
+ var ZoneRuleStageEnum = _enum([
26017
+ "motion",
26018
+ "detection",
26019
+ "package"
26020
+ ]);
26021
+ /**
26022
+ * Zone rules capability — per-camera CRUD over the {@link ZoneRule}
26023
+ * arrays that decide how each pipeline stage uses the polygon zones.
26024
+ *
26025
+ * Hosted by `addon-pipeline-orchestrator` alongside the zones provider
26026
+ * so the operator has a single hub-side source of truth for both
26027
+ * geometry and behaviour. Per-stage rules are stored under the
26028
+ * `zoneRules.<stage>` key in the orchestrator's per-device store and
26029
+ * mirrored to the device-state slice `<stage>-zone-rules` on every
26030
+ * mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
26031
+ * subscribe to that slice and refresh their gating without
26032
+ * round-tripping the cap.
26033
+ *
26034
+ * Sets are bulk-replace — the operator UI sends the new rule list
26035
+ * wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
26036
+ * naturally in the rule editor without per-rule mutation chatter.
26037
+ */
26038
+ var zoneRulesCapability = {
26039
+ name: "zone-rules",
26621
26040
  scope: "device",
26622
26041
  mode: "singleton",
26623
26042
  deviceTypes: [DeviceType.Camera],
26624
26043
  methods: {
26625
- /** Latest computed occupancy snapshot for this camera. Null when
26626
- * the analytics pipeline hasn't seen a frame for this device yet
26627
- * (no inference result emitted since boot or since binding was
26628
- * activated). */
26629
- getCurrentSnapshot: method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()),
26630
- /** Time-series object count inside one zone. `className` optional —
26631
- * omit to count every class in the zone. */
26632
- getZoneHistory: method(object({
26633
- deviceId: number(),
26634
- zoneId: string(),
26635
- className: string().optional()
26636
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()),
26637
- /** Time-series frame-wide object count (everywhere). */
26638
- getCameraHistory: method(object({
26044
+ /** Read the full rule list for a given stage (empty when no rules
26045
+ * are defined yet). */
26046
+ listRules: method(object({
26639
26047
  deviceId: number(),
26640
- className: string().optional()
26641
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()),
26642
- /** Time-series count of objects outside every zone. */
26643
- getUnzonedHistory: method(object({
26048
+ stage: ZoneRuleStageEnum
26049
+ }), array(ZoneRuleSchema).readonly()),
26050
+ /** Bulk-replace the rule list for one stage. The provider validates
26051
+ * each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
26052
+ * thresholds in range) and rejects the whole patch if any entry
26053
+ * is invalid — partial writes are a configuration footgun. */
26054
+ setRules: method(object({
26644
26055
  deviceId: number(),
26645
- className: string().optional()
26646
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly())
26056
+ stage: ZoneRuleStageEnum,
26057
+ rules: array(ZoneRuleSchema).readonly()
26058
+ }), _void(), {
26059
+ kind: "mutation",
26060
+ auth: "admin"
26061
+ })
26647
26062
  },
26648
26063
  /**
26649
- * Runtime-state slice — the latest occupancy snapshot mirrored by
26650
- * the analytics frame processor on every inference result. Consumers
26651
- * read via `device.state.zoneAnalytics.value` and stay in sync
26652
- * automatically; the explicit `getCurrentSnapshot` cap method is
26653
- * still useful for one-off polls without a subscription.
26064
+ * Runtime-state slice — every stage mirrored together so consumers
26065
+ * see one reactive handle (`device.state.zoneRules.value`) instead
26066
+ * of one per stage. Bulk-replace mutations on any stage write the full
26067
+ * `{motion, detection, package}` shape, so subscribers always get the
26068
+ * complete current set. Consumers that only care about one stage
26069
+ * just read the matching property.
26070
+ *
26071
+ * `package` backs the package-drop detector — a package zone is a
26072
+ * `ZoneRule` on the `'package'` stage referencing drawn polygons
26073
+ * (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
26074
+ * The orchestrator provider writes this stage as a first-class slice
26075
+ * (Phase 4): every mutation mirrors the full `{motion, detection,
26076
+ * package}` shape, so consumers read the current package rules directly
26077
+ * off `device.state.zoneRules.value.package`.
26654
26078
  */
26655
- runtimeState: CameraOccupancySnapshotSchema
26079
+ runtimeState: object({
26080
+ motion: array(ZoneRuleSchema).readonly(),
26081
+ detection: array(ZoneRuleSchema).readonly(),
26082
+ package: array(ZoneRuleSchema).readonly()
26083
+ })
26084
+ };
26085
+ /**
26086
+ * Most specific first. Extending this list is how a new device kind becomes
26087
+ * gateable; nothing else needs to change.
26088
+ */
26089
+ var DEVICE_STATE_READERS = [
26090
+ {
26091
+ cap: "alarm-panel",
26092
+ field: "state"
26093
+ },
26094
+ {
26095
+ cap: "cover",
26096
+ field: "state"
26097
+ },
26098
+ {
26099
+ cap: "presence",
26100
+ field: "state"
26101
+ },
26102
+ {
26103
+ cap: "lock",
26104
+ field: "locked",
26105
+ booleanWords: ["locked", "unlocked"]
26106
+ },
26107
+ {
26108
+ cap: "contact",
26109
+ field: "entryOpen",
26110
+ booleanWords: ["open", "closed"]
26111
+ },
26112
+ {
26113
+ cap: "switch",
26114
+ field: "on",
26115
+ booleanWords: ["on", "off"]
26116
+ },
26117
+ {
26118
+ cap: "binary",
26119
+ field: "on",
26120
+ booleanWords: ["on", "off"]
26121
+ }
26122
+ ];
26123
+ /**
26124
+ * Collapse a device's full runtime state to the one string a rule compares
26125
+ * against, or `undefined` when nothing in the table applies.
26126
+ *
26127
+ * `undefined` is the safe answer everywhere: the gate treats it as "does not
26128
+ * match", so a device whose kind we cannot read simply never arms a rule.
26129
+ */
26130
+ function readDeviceStateFrom(runtimeState) {
26131
+ for (const reader of DEVICE_STATE_READERS) {
26132
+ const slice = runtimeState[reader.cap];
26133
+ if (slice === null || typeof slice !== "object") continue;
26134
+ const value = slice[reader.field];
26135
+ if (typeof value === "string" && value.length > 0) return value;
26136
+ if (typeof value === "boolean" && reader.booleanWords !== void 0) return value ? reader.booleanWords[0] : reader.booleanWords[1];
26137
+ }
26138
+ }
26139
+ /**
26140
+ * Accessory device helpers — shared across drivers.
26141
+ *
26142
+ * Many vendor-specific drivers register accessory child devices on
26143
+ * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
26144
+ * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
26145
+ * driver picks the right `DeviceType` + `DeviceRole` explicitly when
26146
+ * spawning, builds a name derived from the parent, and produces a
26147
+ * stableId tied to the parent so boot-restore can reconstruct the
26148
+ * relationship.
26149
+ *
26150
+ * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
26151
+ * drivers may reasonably disagree on the right type for an accessory
26152
+ * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
26153
+ * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
26154
+ * one canonical mapping was over-prescriptive and added a layer of
26155
+ * indirection without saving meaningful code at call sites — the
26156
+ * driver knows its own hardware best.
26157
+ */
26158
+ /**
26159
+ * Subset of `DeviceRole` values that drivers register as child
26160
+ * accessories of a parent device. Sourced verbatim from `DeviceRole`
26161
+ * — `AccessoryKind` is the alias drivers use when building accessory
26162
+ * children, so the call site reads as
26163
+ * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
26164
+ * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
26165
+ * any role works, including non-accessory ones like Doorbell).
26166
+ */
26167
+ var AccessoryKind = {
26168
+ Siren: DeviceRole.Siren,
26169
+ Floodlight: DeviceRole.Floodlight,
26170
+ Spotlight: DeviceRole.Spotlight,
26171
+ PirSensor: DeviceRole.PirSensor,
26172
+ Chime: DeviceRole.Chime,
26173
+ Autotrack: DeviceRole.Autotrack,
26174
+ Nightvision: DeviceRole.Nightvision,
26175
+ PrivacyMask: DeviceRole.PrivacyMask
26176
+ };
26177
+ AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
26178
+ var DeviceConfig = class DeviceConfig {
26179
+ schema;
26180
+ data;
26181
+ persistFn;
26182
+ constructor(schema, data, persist) {
26183
+ this.schema = schema;
26184
+ this.data = data;
26185
+ this.persistFn = persist;
26186
+ }
26187
+ /**
26188
+ * Build a `DeviceConfig` from a persisted blob, with automatic
26189
+ * recovery from schema-validation failures. Boot must never be
26190
+ * blocked by stale persisted values: if Zod rejects the blob,
26191
+ * we drop every offending top-level field, retry, and persist
26192
+ * the cleaned blob so the bad value is healed in the DB on next
26193
+ * write. The most common trigger is a tightened range constraint
26194
+ * (e.g. `max(100) → max(50)`) on a field that already has an
26195
+ * out-of-range value persisted from the previous schema. Without
26196
+ * this safety net, the device would fail to instantiate and end
26197
+ * up with no caps registered — exactly the failure mode that
26198
+ * stranded device 15 when `motionSensitivity: 90` no longer fit
26199
+ * the new `1..50` schema.
26200
+ *
26201
+ * Recovery rules:
26202
+ * 1. Try `safeParse(initialData)`. If it succeeds, done.
26203
+ * 2. On failure, walk `error.issues`, collect the top-level path
26204
+ * of each issue, and drop those keys from `initialData`.
26205
+ * 3. Re-run `safeParse`. If the cleaned blob now passes (Zod
26206
+ * fills the missing keys with schema defaults / undefined for
26207
+ * `.optional()`), persist it via `persist()` so the bad
26208
+ * values disappear from the DB, and return the device.
26209
+ * 4. If the cleaned blob STILL fails (very rare — would require
26210
+ * a non-recoverable required field), fall back to
26211
+ * `schema.parse({})` so the device still boots with pure
26212
+ * schema defaults. Persist nothing in that path so the next
26213
+ * successful `setAll` still writes a coherent blob.
26214
+ */
26215
+ static fromSchema(schema, persist, initialData = {}, onRecover) {
26216
+ const first = schema.safeParse(initialData);
26217
+ if (first.success) return new DeviceConfig(schema, first.data, persist);
26218
+ const droppedKeys = /* @__PURE__ */ new Set();
26219
+ for (const issue of first.error.issues) {
26220
+ const top = issue.path[0];
26221
+ if (typeof top === "string") droppedKeys.add(top);
26222
+ }
26223
+ const cleaned = { ...initialData };
26224
+ for (const k of droppedKeys) delete cleaned[k];
26225
+ const second = schema.safeParse(cleaned);
26226
+ onRecover?.({
26227
+ droppedKeys: [...droppedKeys],
26228
+ issues: first.error.issues
26229
+ });
26230
+ if (second.success) {
26231
+ persist(second.data).catch(() => {});
26232
+ return new DeviceConfig(schema, second.data, persist);
26233
+ }
26234
+ return new DeviceConfig(schema, schema.parse({}), persist);
26235
+ }
26236
+ get values() {
26237
+ return this.data;
26238
+ }
26239
+ get(key) {
26240
+ return this.data[key];
26241
+ }
26242
+ async set(key, value) {
26243
+ const next = this.schema.parse({
26244
+ ...this.data,
26245
+ [key]: value
26246
+ });
26247
+ this.data = next;
26248
+ await this.persistFn(this.data);
26249
+ }
26250
+ /**
26251
+ * Merge an untyped patch onto the current config and persist. Accepts
26252
+ * `Record<string, unknown>` because the patch typically comes from the
26253
+ * UI form layer (a `ConfigField.key → value` map) where the caller
26254
+ * doesn't hold the Zod schema's static type. Runtime validation is
26255
+ * authoritative: `this.schema.parse` rejects unknown keys or invalid
26256
+ * shapes before touching storage.
26257
+ */
26258
+ async setAll(partial) {
26259
+ const next = this.schema.parse({
26260
+ ...this.data,
26261
+ ...partial
26262
+ });
26263
+ this.data = next;
26264
+ await this.persistFn(this.data);
26265
+ }
26266
+ async deleteKey(key) {
26267
+ const { [key]: _, ...rest } = this.data;
26268
+ const next = this.schema.parse(rest);
26269
+ this.data = next;
26270
+ await this.persistFn(this.data);
26271
+ }
26272
+ entries() {
26273
+ const shape = this.schema.shape;
26274
+ return Object.entries(shape).map(([key, fieldSchema]) => ({
26275
+ key,
26276
+ schema: fieldSchema,
26277
+ value: this.data[key],
26278
+ description: fieldSchema.description
26279
+ }));
26280
+ }
26656
26281
  };
26657
26282
  /**
26658
- * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
26659
- * cap so a single CRUD surface backs every consumer; each stage has
26660
- * its own dev-state mirror slice (`motion-zone-rules`,
26661
- * `detection-zone-rules`, …) so consumer addons subscribe independently.
26662
- *
26663
- * Extend the enum here when a new gating consumer comes online (audio
26664
- * gating, alert filtering, …) — no other surface needs to change.
26665
- */
26666
- var ZoneRuleStageEnum = _enum([
26667
- "motion",
26668
- "detection",
26669
- "package"
26670
- ]);
26671
- /**
26672
- * Zone rules capability — per-camera CRUD over the {@link ZoneRule}
26673
- * arrays that decide how each pipeline stage uses the polygon zones.
26283
+ * Concrete implementation. Routes every successful write through
26284
+ * `writer(capName, slice)` — the kernel hooks this up to
26285
+ * `device-state.setCapSlice`, the canonical cross-layer write
26286
+ * entrypoint, which handles disk persistence (debounced on the hub)
26287
+ * and mirror updates.
26674
26288
  *
26675
- * Hosted by `addon-pipeline-orchestrator` alongside the zones provider
26676
- * so the operator has a single hub-side source of truth for both
26677
- * geometry and behaviour. Per-stage rules are stored under the
26678
- * `zoneRules.<stage>` key in the orchestrator's per-device store and
26679
- * mirrored to the device-state slice `<stage>-zone-rules` on every
26680
- * mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
26681
- * subscribe to that slice and refresh their gating without
26682
- * round-tripping the cap.
26289
+ * Schema validation runs in-process before the writer is called —
26290
+ * the round-trip should never carry an invalid slice. `flush()`
26291
+ * awaits any in-flight writer promises so shutdown is lossless.
26683
26292
  *
26684
- * Sets are bulk-replace — the operator UI sends the new rule list
26685
- * wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
26686
- * naturally in the rule editor without per-rule mutation chatter.
26293
+ * `initial` is the persisted blob loaded at boot. Slices for caps
26294
+ * whose schema hasn't been installed yet are kept in-memory verbatim
26295
+ * and validated when the cap registers later.
26687
26296
  */
26688
- var zoneRulesCapability = {
26689
- name: "zone-rules",
26690
- scope: "device",
26691
- mode: "singleton",
26692
- deviceTypes: [DeviceType.Camera],
26693
- methods: {
26694
- /** Read the full rule list for a given stage (empty when no rules
26695
- * are defined yet). */
26696
- listRules: method(object({
26697
- deviceId: number(),
26698
- stage: ZoneRuleStageEnum
26699
- }), array(ZoneRuleSchema).readonly()),
26700
- /** Bulk-replace the rule list for one stage. The provider validates
26701
- * each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
26702
- * thresholds in range) and rejects the whole patch if any entry
26703
- * is invalid — partial writes are a configuration footgun. */
26704
- setRules: method(object({
26705
- deviceId: number(),
26706
- stage: ZoneRuleStageEnum,
26707
- rules: array(ZoneRuleSchema).readonly()
26708
- }), _void(), {
26709
- kind: "mutation",
26710
- auth: "admin"
26711
- })
26712
- },
26297
+ var DeviceRuntimeState = class DeviceRuntimeState {
26298
+ writer;
26299
+ /** In-flight writer promises tracked so `flush()` can await them. */
26300
+ pendingWrites = /* @__PURE__ */ new Set();
26301
+ /** Per-cap committed slice — after schema validation when known. */
26302
+ slices;
26303
+ /** Per-cap registered schema (set by `installCapSchema`). */
26304
+ schemas = /* @__PURE__ */ new Map();
26305
+ listeners = /* @__PURE__ */ new Set();
26306
+ capListeners = /* @__PURE__ */ new Map();
26307
+ constructor(initial, writer) {
26308
+ this.writer = writer;
26309
+ this.slices = /* @__PURE__ */ new Map();
26310
+ for (const [k, v] of Object.entries(initial)) if (v && typeof v === "object" && !Array.isArray(v)) this.slices.set(k, { ...v });
26311
+ }
26312
+ static fromInitial(initial, writer) {
26313
+ return new DeviceRuntimeState(initial, writer);
26314
+ }
26315
+ installCapSchema(capName, schema) {
26316
+ const existing = this.schemas.get(capName);
26317
+ if (existing) {
26318
+ if (existing !== schema) throw new Error(`[DeviceRuntimeState] capability "${capName}" registered a different runtime-state schema; each cap must declare ONE shape across every provider`);
26319
+ return;
26320
+ }
26321
+ this.schemas.set(capName, schema);
26322
+ const stored = this.slices.get(capName);
26323
+ if (stored) {
26324
+ const result = schema.safeParse(stored);
26325
+ if (result.success) this.slices.set(capName, result.data);
26326
+ else this.slices.delete(capName);
26327
+ }
26328
+ }
26329
+ getCapState(capName) {
26330
+ const slice = this.slices.get(capName);
26331
+ if (!slice) return void 0;
26332
+ return Object.freeze({ ...slice });
26333
+ }
26334
+ getCapField(capName, key) {
26335
+ return this.slices.get(capName)?.[key];
26336
+ }
26337
+ setCapState(capName, value) {
26338
+ this.applyCapWrite(capName, value, false);
26339
+ }
26340
+ patchCapState(capName, partial) {
26341
+ this.applyCapWrite(capName, partial, true);
26342
+ }
26713
26343
  /**
26714
- * Runtime-state slice — every stage mirrored together so consumers
26715
- * see one reactive handle (`device.state.zoneRules.value`) instead
26716
- * of one per stage. Bulk-replace mutations on any stage write the full
26717
- * `{motion, detection, package}` shape, so subscribers always get the
26718
- * complete current set. Consumers that only care about one stage
26719
- * just read the matching property.
26720
- *
26721
- * `package` backs the package-drop detector — a package zone is a
26722
- * `ZoneRule` on the `'package'` stage referencing drawn polygons
26723
- * (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
26724
- * The orchestrator provider writes this stage as a first-class slice
26725
- * (Phase 4): every mutation mirrors the full `{motion, detection,
26726
- * package}` shape, so consumers read the current package rules directly
26727
- * off `device.state.zoneRules.value.package`.
26344
+ * Internal worker. `merge` controls whether `value` replaces or
26345
+ * shallow-merges into the existing slice. Schema validation runs
26346
+ * on the FINAL composed object regardless.
26728
26347
  */
26729
- runtimeState: object({
26730
- motion: array(ZoneRuleSchema).readonly(),
26731
- detection: array(ZoneRuleSchema).readonly(),
26732
- package: array(ZoneRuleSchema).readonly()
26733
- })
26734
- };
26735
- /**
26736
- * Most specific first. Extending this list is how a new device kind becomes
26737
- * gateable; nothing else needs to change.
26738
- */
26739
- var DEVICE_STATE_READERS = [
26740
- {
26741
- cap: "alarm-panel",
26742
- field: "state"
26743
- },
26744
- {
26745
- cap: "cover",
26746
- field: "state"
26747
- },
26748
- {
26749
- cap: "presence",
26750
- field: "state"
26751
- },
26752
- {
26753
- cap: "lock",
26754
- field: "locked",
26755
- booleanWords: ["locked", "unlocked"]
26756
- },
26757
- {
26758
- cap: "contact",
26759
- field: "entryOpen",
26760
- booleanWords: ["open", "closed"]
26761
- },
26762
- {
26763
- cap: "switch",
26764
- field: "on",
26765
- booleanWords: ["on", "off"]
26766
- },
26767
- {
26768
- cap: "binary",
26769
- field: "on",
26770
- booleanWords: ["on", "off"]
26348
+ applyCapWrite(capName, value, merge) {
26349
+ const schema = this.schemas.get(capName);
26350
+ if (!schema) throw new Error(`[DeviceRuntimeState] no schema registered for cap "${capName}" — did the device register it via ctx.registerNativeCap before writing?`);
26351
+ const current = this.slices.get(capName) ?? {};
26352
+ const next = merge ? {
26353
+ ...current,
26354
+ ...value
26355
+ } : { ...value };
26356
+ const parsed = schema.parse(next);
26357
+ if (shallowEqual(current, parsed)) return;
26358
+ this.slices.set(capName, parsed);
26359
+ this.fireListeners([capName]);
26360
+ const writePromise = this.writer(capName, { ...parsed }).catch(() => {});
26361
+ this.pendingWrites.add(writePromise);
26362
+ writePromise.finally(() => {
26363
+ this.pendingWrites.delete(writePromise);
26364
+ });
26771
26365
  }
26772
- ];
26773
- /**
26774
- * Collapse a device's full runtime state to the one string a rule compares
26775
- * against, or `undefined` when nothing in the table applies.
26776
- *
26777
- * `undefined` is the safe answer everywhere: the gate treats it as "does not
26778
- * match", so a device whose kind we cannot read simply never arms a rule.
26779
- */
26780
- function readDeviceStateFrom(runtimeState) {
26781
- for (const reader of DEVICE_STATE_READERS) {
26782
- const slice = runtimeState[reader.cap];
26783
- if (slice === null || typeof slice !== "object") continue;
26784
- const value = slice[reader.field];
26785
- if (typeof value === "string" && value.length > 0) return value;
26786
- if (typeof value === "boolean" && reader.booleanWords !== void 0) return value ? reader.booleanWords[0] : reader.booleanWords[1];
26366
+ fireListeners(changed) {
26367
+ const snap = this.snapshot();
26368
+ for (const cb of this.listeners) try {
26369
+ cb(changed, snap);
26370
+ } catch {}
26371
+ for (const capName of changed) {
26372
+ const subs = this.capListeners.get(capName);
26373
+ if (!subs) continue;
26374
+ const slice = this.getCapState(capName);
26375
+ for (const cb of subs) try {
26376
+ cb(slice);
26377
+ } catch {}
26378
+ }
26379
+ }
26380
+ subscribe(cb) {
26381
+ this.listeners.add(cb);
26382
+ return () => {
26383
+ this.listeners.delete(cb);
26384
+ };
26385
+ }
26386
+ subscribeCap(capName, cb) {
26387
+ let subs = this.capListeners.get(capName);
26388
+ if (!subs) {
26389
+ subs = /* @__PURE__ */ new Set();
26390
+ this.capListeners.set(capName, subs);
26391
+ }
26392
+ const adapter = (slice) => {
26393
+ cb(slice);
26394
+ };
26395
+ subs.add(adapter);
26396
+ return () => {
26397
+ const set = this.capListeners.get(capName);
26398
+ if (!set) return;
26399
+ set.delete(adapter);
26400
+ if (set.size === 0) this.capListeners.delete(capName);
26401
+ };
26402
+ }
26403
+ snapshot() {
26404
+ const out = {};
26405
+ for (const [k, v] of this.slices) out[k] = Object.freeze({ ...v });
26406
+ return Object.freeze(out);
26787
26407
  }
26408
+ async flush() {
26409
+ if (this.pendingWrites.size === 0) return;
26410
+ const inflight = [...this.pendingWrites];
26411
+ await Promise.allSettled(inflight);
26412
+ }
26413
+ };
26414
+ function shallowEqual(a, b) {
26415
+ const ak = Object.keys(a);
26416
+ const bk = Object.keys(b);
26417
+ if (ak.length !== bk.length) return false;
26418
+ for (const k of ak) if (a[k] !== b[k]) return false;
26419
+ return true;
26788
26420
  }
26789
26421
  /**
26790
- * Accessory device helpers — shared across drivers.
26791
- *
26792
- * Many vendor-specific drivers register accessory child devices on
26793
- * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
26794
- * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
26795
- * driver picks the right `DeviceType` + `DeviceRole` explicitly when
26796
- * spawning, builds a name derived from the parent, and produces a
26797
- * stableId tied to the parent so boot-restore can reconstruct the
26798
- * relationship.
26799
- *
26800
- * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
26801
- * drivers may reasonably disagree on the right type for an accessory
26802
- * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
26803
- * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
26804
- * one canonical mapping was over-prescriptive and added a layer of
26805
- * indirection without saving meaningful code at call sites — the
26806
- * driver knows its own hardware best.
26807
- */
26808
- /**
26809
- * Subset of `DeviceRole` values that drivers register as child
26810
- * accessories of a parent device. Sourced verbatim from `DeviceRole`
26811
- * — `AccessoryKind` is the alias drivers use when building accessory
26812
- * children, so the call site reads as
26813
- * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
26814
- * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
26815
- * any role works, including non-accessory ones like Doorbell).
26422
+ * Runtime registry: cap-property-name → cap definition. `BaseDevice`'s
26423
+ * `state` getter looks up the cap definition here to construct a
26424
+ * `sliceProxy()` lazily on first access. Generated alongside the type
26425
+ * so type and runtime registry can never drift apart.
26816
26426
  */
26817
- var AccessoryKind = {
26818
- Siren: DeviceRole.Siren,
26819
- Floodlight: DeviceRole.Floodlight,
26820
- Spotlight: DeviceRole.Spotlight,
26821
- PirSensor: DeviceRole.PirSensor,
26822
- Chime: DeviceRole.Chime,
26823
- Autotrack: DeviceRole.Autotrack,
26824
- Nightvision: DeviceRole.Nightvision,
26825
- PrivacyMask: DeviceRole.PrivacyMask
26427
+ var DEVICE_LOCAL_STATE_CAPS = {
26428
+ airQualitySensor: airQualitySensorCapability,
26429
+ alarmPanel: alarmPanelCapability,
26430
+ ambientLightSensor: ambientLightSensorCapability,
26431
+ audioMetrics: audioMetricsCapability,
26432
+ automationControl: automationControlCapability,
26433
+ battery: batteryCapability,
26434
+ binary: binaryCapability,
26435
+ brightness: brightnessCapability,
26436
+ cameraStreams: cameraStreamsCapability,
26437
+ carbonMonoxide: carbonMonoxideCapability,
26438
+ climateControl: climateControlCapability,
26439
+ color: colorCapability,
26440
+ connectivity: connectivityCapability,
26441
+ consumables: consumablesCapability,
26442
+ contact: contactCapability,
26443
+ control: controlCapability,
26444
+ cover: coverCapability,
26445
+ dayNight: dayNightCapability,
26446
+ deviceDiscovery: deviceDiscoveryCapability,
26447
+ deviceStatus: deviceStatusCapability,
26448
+ doorbell: doorbellCapability,
26449
+ enumSensor: enumSensorCapability,
26450
+ eventEmitter: eventEmitterCapability,
26451
+ fanControl: fanControlCapability,
26452
+ featureProbe: featureProbeCapability,
26453
+ flood: floodCapability,
26454
+ gas: gasCapability,
26455
+ humidifier: humidifierCapability,
26456
+ humiditySensor: humiditySensorCapability,
26457
+ image: imageCapability,
26458
+ imageSettings: imageSettingsCapability,
26459
+ lawnMowerControl: lawnMowerControlCapability,
26460
+ lockControl: lockControlCapability,
26461
+ mediaPlayer: mediaPlayerCapability,
26462
+ motion: motionCapability,
26463
+ motionTrigger: motionTriggerCapability,
26464
+ motionZones: motionZonesCapability,
26465
+ nativeObjectDetection: nativeObjectDetectionCapability,
26466
+ notifier: notifierCapability,
26467
+ numericSensor: numericSensorCapability,
26468
+ petFeeder: petFeederCapability,
26469
+ powerMeter: powerMeterCapability,
26470
+ presence: presenceCapability,
26471
+ pressureSensor: pressureSensorCapability,
26472
+ privacyMask: privacyMaskCapability,
26473
+ ptzAutotrack: ptzAutotrackCapability,
26474
+ sceneMonitor: sceneMonitorCapability,
26475
+ scriptRunner: scriptRunnerCapability,
26476
+ smoke: smokeCapability,
26477
+ streamParams: streamParamsCapability,
26478
+ switch: switchCapability,
26479
+ tamper: tamperCapability,
26480
+ temperatureSensor: temperatureSensorCapability,
26481
+ update: updateCapability,
26482
+ vacuumControl: vacuumControlCapability,
26483
+ valve: valveCapability,
26484
+ vibration: vibrationCapability,
26485
+ waterHeater: waterHeaterCapability,
26486
+ weather: weatherCapability,
26487
+ zoneAnalytics: zoneAnalyticsCapability,
26488
+ zoneRules: zoneRulesCapability,
26489
+ zones: zonesCapability
26826
26490
  };
26827
- AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
26828
- var DeviceConfig = class DeviceConfig {
26829
- schema;
26830
- data;
26831
- persistFn;
26832
- constructor(schema, data, persist) {
26833
- this.schema = schema;
26834
- this.data = data;
26835
- this.persistFn = persist;
26491
+ var BaseDevice = class {
26492
+ id;
26493
+ stableId;
26494
+ type;
26495
+ name;
26496
+ parentDeviceId;
26497
+ role;
26498
+ /**
26499
+ * Cap-keyed runtime-state slice is the single source of truth for
26500
+ * `online`. Both getter and setter proxy to the slice — drivers can
26501
+ * write `this.online = true` ergonomically, and the cap event fires
26502
+ * automatically through the runtime-state writer. `markOnline()` is
26503
+ * kept as the explicit method form mandated by `IDevice`.
26504
+ */
26505
+ get online() {
26506
+ return this.runtimeState.getCapState("device-status")?.online ?? false;
26507
+ }
26508
+ set online(value) {
26509
+ this.markOnline(value);
26836
26510
  }
26837
26511
  /**
26838
- * Build a `DeviceConfig` from a persisted blob, with automatic
26839
- * recovery from schema-validation failures. Boot must never be
26840
- * blocked by stale persisted values: if Zod rejects the blob,
26841
- * we drop every offending top-level field, retry, and persist
26842
- * the cleaned blob so the bad value is healed in the DB on next
26843
- * write. The most common trigger is a tightened range constraint
26844
- * (e.g. `max(100) → max(50)`) on a field that already has an
26845
- * out-of-range value persisted from the previous schema. Without
26846
- * this safety net, the device would fail to instantiate and end
26847
- * up with no caps registered — exactly the failure mode that
26848
- * stranded device 15 when `motionSensitivity: 90` no longer fit
26849
- * the new `1..50` schema.
26512
+ * Generic per-cap runtime-state namespace. One entry per cap with
26513
+ * `runtimeState:` declared, auto-generated by codegen — see
26514
+ * `device-local-state.ts`. Drivers access via:
26850
26515
  *
26851
- * Recovery rules:
26852
- * 1. Try `safeParse(initialData)`. If it succeeds, done.
26853
- * 2. On failure, walk `error.issues`, collect the top-level path
26854
- * of each issue, and drop those keys from `initialData`.
26855
- * 3. Re-run `safeParse`. If the cleaned blob now passes (Zod
26856
- * fills the missing keys with schema defaults / undefined for
26857
- * `.optional()`), persist it via `persist()` so the bad
26858
- * values disappear from the DB, and return the device.
26859
- * 4. If the cleaned blob STILL fails (very rare — would require
26860
- * a non-recoverable required field), fall back to
26861
- * `schema.parse({})` so the device still boots with pure
26862
- * schema defaults. Persist nothing in that path so the next
26863
- * successful `setAll` still writes a coherent blob.
26516
+ * `this.state.battery.sleeping = true` // patches the battery slice
26517
+ * `const pct = this.state.battery.percentage` // reads the battery slice
26518
+ * `this.state.deviceStatus.online = true` // mirrors `markOnline(true)`
26519
+ *
26520
+ * Adding a new cap with `runtimeState:` automatically extends this
26521
+ * namespace — drivers don't have to declare proxies. Reads return
26522
+ * `undefined` when the slice hasn't been seeded; writes patch via
26523
+ * `runtimeState.patchCapState` and validate against the cap's schema
26524
+ * (so partial writes need the slice to be seeded with the required
26525
+ * fields first — drivers do this on cap registration).
26526
+ *
26527
+ * For caps not exposed in `DeviceLocalState`, drivers can build their
26528
+ * own typed proxy via `this.sliceProxy(cap)`.
26864
26529
  */
26865
- static fromSchema(schema, persist, initialData = {}, onRecover) {
26866
- const first = schema.safeParse(initialData);
26867
- if (first.success) return new DeviceConfig(schema, first.data, persist);
26868
- const droppedKeys = /* @__PURE__ */ new Set();
26869
- for (const issue of first.error.issues) {
26870
- const top = issue.path[0];
26871
- if (typeof top === "string") droppedKeys.add(top);
26530
+ get state() {
26531
+ if (!this._stateProxyCache) {
26532
+ const cache = {};
26533
+ const handler = { get: (_target, key) => {
26534
+ const k = key;
26535
+ if (k in cache) return cache[k];
26536
+ const cap = DEVICE_LOCAL_STATE_CAPS[k];
26537
+ if (!cap) return void 0;
26538
+ const proxy = this.sliceProxy(cap);
26539
+ cache[k] = proxy;
26540
+ return proxy;
26541
+ } };
26542
+ this._stateProxyCache = new Proxy(cache, handler);
26872
26543
  }
26873
- const cleaned = { ...initialData };
26874
- for (const k of droppedKeys) delete cleaned[k];
26875
- const second = schema.safeParse(cleaned);
26876
- onRecover?.({
26877
- droppedKeys: [...droppedKeys],
26878
- issues: first.error.issues
26544
+ return this._stateProxyCache;
26545
+ }
26546
+ _stateProxyCache;
26547
+ config;
26548
+ /**
26549
+ * Per-device runtime state, cap-keyed. Always installed — slices
26550
+ * for individual caps materialise as those caps register their
26551
+ * native providers (`ctx.registerNativeCap`). The cap's own
26552
+ * `runtimeState` schema is the source of truth for the slice
26553
+ * shape; drivers don't redeclare it, they just write through.
26554
+ *
26555
+ * Read: `this.runtimeState.getCapState('battery')` →
26556
+ * `{percentage, charging, sleeping, lastUpdated}` for any
26557
+ * provider that registers `batteryCapability`.
26558
+ * Write: `this.runtimeState.setCapState('battery', { … })`.
26559
+ *
26560
+ * Cross-process consumers reach this state through the
26561
+ * `deviceState` cap router (or via cap-specific events the driver
26562
+ * emits — e.g. `battery.onStatusChanged`). The local handle is
26563
+ * accessed in-process by the driver to avoid roundtrips.
26564
+ */
26565
+ runtimeState;
26566
+ ctx;
26567
+ /**
26568
+ * Operator-organisational location label (room / area / zone).
26569
+ * Read from `ctx.deviceMeta.location`; mutated via
26570
+ * `kernel.devices.setLocation(id, value)`. Free-text — providers
26571
+ * don't interpret it; the UI groups devices by this for filters
26572
+ * like "show me all cameras in Kitchen". `null` when unset.
26573
+ */
26574
+ location;
26575
+ /**
26576
+ * Soft-disabled flag. When `true`, the device class is still
26577
+ * instantiated and visible in the UI (so the operator can flip
26578
+ * back on without re-adding) but lifecycle hooks (publishToBroker,
26579
+ * alarm-stream subscribe, …) MUST be gated by the driver to skip
26580
+ * work. The `BaseDevice` enforces this by exposing the flag here;
26581
+ * it does NOT mutate cap behaviour automatically — drivers consult
26582
+ * `this.disabled` at the top of their lifecycle methods. Read from
26583
+ * `ctx.deviceMeta.disabled`; mutated via
26584
+ * `kernel.devices.setDisabled(id, value)`.
26585
+ */
26586
+ disabled;
26587
+ /**
26588
+ * Cached materialised `SourceInfo` — either the value persisted under
26589
+ * `metadata.sourceInfo` at construction time, or a synthetic
26590
+ * `{ id: stableId, system: addonId }` for providers that haven't
26591
+ * migrated yet. Lazily populated on first `sourceInfo` read so the
26592
+ * cost of Zod-parsing the meta blob is paid once per device boot.
26593
+ * Invalidated by `updateSourceInfo()` so providers see the new value
26594
+ * back through the getter without a re-fetch from the meta surface.
26595
+ */
26596
+ _sourceInfoCache = null;
26597
+ constructor(ctx, schema, options) {
26598
+ this.ctx = ctx;
26599
+ this.id = ctx.id;
26600
+ this.stableId = ctx.stableId;
26601
+ this.type = options.type;
26602
+ if (!ctx.deviceMeta) throw new Error(`BaseDevice constructor: ctx.deviceMeta is required (id=${ctx.id} stableId=${ctx.stableId})`);
26603
+ this.name = ctx.deviceMeta.name;
26604
+ this.location = ctx.deviceMeta.location;
26605
+ this.disabled = ctx.deviceMeta.disabled;
26606
+ this.role = options.role;
26607
+ this.parentDeviceId = ctx.parentDeviceId;
26608
+ const seedData = ctx.persistedConfig ?? {};
26609
+ this.config = DeviceConfig.fromSchema(schema, (data) => ctx.persistConfig(data), seedData, ({ droppedKeys, issues }) => {
26610
+ ctx.logger.warn("Device config recovery: dropping invalid persisted fields", {
26611
+ tags: {
26612
+ deviceId: ctx.id,
26613
+ stableId: ctx.stableId
26614
+ },
26615
+ meta: {
26616
+ droppedKeys: [...droppedKeys],
26617
+ firstIssue: issues[0]?.message ?? null
26618
+ }
26619
+ });
26620
+ });
26621
+ let cachedProxy = null;
26622
+ const writer = async (capName, slice) => {
26623
+ if (!cachedProxy) cachedProxy = ctx.fetchDevice(ctx.id);
26624
+ await (await cachedProxy).deviceState.setCapSlice({
26625
+ capName,
26626
+ slice
26627
+ });
26628
+ };
26629
+ const initial = ctx.initialRuntimeState ?? {};
26630
+ this.runtimeState = DeviceRuntimeState.fromInitial(initial, writer);
26631
+ ctx.bindRuntimeState?.(this.runtimeState);
26632
+ ctx.registerNativeCap?.(deviceStatusCapability, {});
26633
+ const seed = {
26634
+ online: true,
26635
+ lastChangedAt: Date.now()
26636
+ };
26637
+ this.runtimeState.setCapState("device-status", seed);
26638
+ ctx.registerNativeCap?.(featureProbeCapability, {});
26639
+ this.runtimeState.setCapState("feature-probe", {
26640
+ flags: {},
26641
+ deviceType: null,
26642
+ model: null,
26643
+ channelCount: null,
26644
+ lastProbedAt: 0,
26645
+ lastFetchedAt: 0
26879
26646
  });
26880
- if (second.success) {
26881
- persist(second.data).catch(() => {});
26882
- return new DeviceConfig(schema, second.data, persist);
26883
- }
26884
- return new DeviceConfig(schema, schema.parse({}), persist);
26885
26647
  }
26886
- get values() {
26887
- return this.data;
26648
+ deviceActions = /* @__PURE__ */ new Map();
26649
+ /** Declare a device custom action + its typed handler. Idempotent per name. */
26650
+ registerDeviceAction(name, spec, handler) {
26651
+ this.deviceActions.set(name, {
26652
+ spec,
26653
+ handler
26654
+ });
26888
26655
  }
26889
- get(key) {
26890
- return this.data[key];
26656
+ /** Invoke a registered device action. Validates input against the spec. */
26657
+ async runDeviceAction(action, input) {
26658
+ const entry = this.deviceActions.get(action);
26659
+ if (!entry) throw new Error(`unknown device action "${action}" on device ${this.id}`);
26660
+ const parsed = entry.spec.input.parse(input);
26661
+ return entry.handler(parsed);
26891
26662
  }
26892
- async set(key, value) {
26893
- const next = this.schema.parse({
26894
- ...this.data,
26895
- [key]: value
26896
- });
26897
- this.data = next;
26898
- await this.persistFn(this.data);
26663
+ async removeDevice() {}
26664
+ /**
26665
+ * Set the device's online flag. Called by `BaseDeviceProvider` after
26666
+ * aggregating per-profile stream-broker health, or directly by drivers
26667
+ * that have provider-side liveness signals (e.g. ONVIF heartbeats,
26668
+ * Reolink Baichuan firmware push events). Mirrors the new value into
26669
+ * the `device-status` runtime-state slice so cross-process consumers
26670
+ * pick it up via the standard cap-state channel. Subclasses can
26671
+ * override to gate side effects on the transition.
26672
+ */
26673
+ markOnline(online) {
26674
+ if (this.online === online) return;
26675
+ const next = {
26676
+ online,
26677
+ lastChangedAt: Date.now()
26678
+ };
26679
+ this.runtimeState.setCapState("device-status", next);
26899
26680
  }
26900
26681
  /**
26901
- * Merge an untyped patch onto the current config and persist. Accepts
26902
- * `Record<string, unknown>` because the patch typically comes from the
26903
- * UI form layer (a `ConfigField.key → value` map) where the caller
26904
- * doesn't hold the Zod schema's static type. Runtime validation is
26905
- * authoritative: `this.schema.parse` rejects unknown keys or invalid
26906
- * shapes before touching storage.
26682
+ * Upstream-system identity + rendering envelope for this device. See
26683
+ * `SourceInfo` for the field contract. Always returns a valid object:
26684
+ * if the persisted `metadata.sourceInfo` blob is absent or fails Zod
26685
+ * validation, falls back to a synthetic `{ id: stableId, system: addonId }`
26686
+ * so providers that haven't migrated keep working without code changes.
26687
+ *
26688
+ * The value is cached after the first read. `updateSourceInfo()`
26689
+ * invalidates the cache so subsequent reads see the new patch. The
26690
+ * returned object is frozen to prevent accidental in-place mutation —
26691
+ * use `updateSourceInfo({ patch })` to change fields.
26907
26692
  */
26908
- async setAll(partial) {
26909
- const next = this.schema.parse({
26910
- ...this.data,
26911
- ...partial
26693
+ get sourceInfo() {
26694
+ if (this._sourceInfoCache) return this._sourceInfoCache;
26695
+ const resolved = extractSourceInfoFromMetadata(this.ctx.deviceMeta.metadata) ?? synthesizeSourceInfo({
26696
+ stableId: this.stableId,
26697
+ addonId: this.ctx.deviceMeta.addonId
26912
26698
  });
26913
- this.data = next;
26914
- await this.persistFn(this.data);
26699
+ this._sourceInfoCache = Object.freeze({ ...resolved });
26700
+ return this._sourceInfoCache;
26915
26701
  }
26916
- async deleteKey(key) {
26917
- const { [key]: _, ...rest } = this.data;
26918
- const next = this.schema.parse(rest);
26919
- this.data = next;
26920
- await this.persistFn(this.data);
26702
+ /**
26703
+ * Convenience accessor for the upstream dispatch key. Equivalent to
26704
+ * `this.sourceInfo.id` — providers use this to keep a
26705
+ * `Map<sourceId, IDevice>` for routing inbound push events.
26706
+ */
26707
+ get sourceId() {
26708
+ return this.sourceInfo.id;
26921
26709
  }
26922
- entries() {
26923
- const shape = this.schema.shape;
26924
- return Object.entries(shape).map(([key, fieldSchema]) => ({
26925
- key,
26926
- schema: fieldSchema,
26927
- value: this.data[key],
26928
- description: fieldSchema.description
26710
+ /**
26711
+ * Patch the device's `SourceInfo`. Shallow-merges `patch` over the
26712
+ * current value, persists the merged result under
26713
+ * `metadata.sourceInfo` via the `device-manager.setMetadata` cap, and
26714
+ * emits `EventCategory.DeviceSourceInfoChanged` for live consumers.
26715
+ *
26716
+ * Safe to call from anywhere in the device's lifetime — the call is
26717
+ * idempotent for `undefined` patch values (ignored) and best-effort
26718
+ * for persistence (a transient device-manager error doesn't unwind
26719
+ * the local cache update, so subsequent reads still see the patch).
26720
+ *
26721
+ * Drivers populate this on adoption + on every metadata change push
26722
+ * from the upstream source. Subscribers (UI, export adapters) react
26723
+ * via the `DeviceSourceInfoChanged` event without polling.
26724
+ */
26725
+ async updateSourceInfo(patch) {
26726
+ const next = mergeSourceInfo(this.sourceInfo, patch);
26727
+ this._sourceInfoCache = Object.freeze({ ...next });
26728
+ const action = this.ctx.api?.deviceManager?.setMetadata;
26729
+ if (action) try {
26730
+ await action.mutate({
26731
+ deviceId: this.id,
26732
+ patch: { [SOURCE_INFO_METADATA_KEY]: next }
26733
+ });
26734
+ } catch {}
26735
+ this.ctx.eventBus.emit(createEvent("device.source-info-changed", {
26736
+ type: "device",
26737
+ id: this.stableId
26738
+ }, {
26739
+ deviceId: this.id,
26740
+ sourceInfo: next
26929
26741
  }));
26930
26742
  }
26931
- };
26932
- /**
26933
- * Concrete implementation. Routes every successful write through
26934
- * `writer(capName, slice)` — the kernel hooks this up to
26935
- * `device-state.setCapSlice`, the canonical cross-layer write
26936
- * entrypoint, which handles disk persistence (debounced on the hub)
26937
- * and mirror updates.
26938
- *
26939
- * Schema validation runs in-process before the writer is called —
26940
- * the round-trip should never carry an invalid slice. `flush()`
26941
- * awaits any in-flight writer promises so shutdown is lossless.
26942
- *
26943
- * `initial` is the persisted blob loaded at boot. Slices for caps
26944
- * whose schema hasn't been installed yet are kept in-memory verbatim
26945
- * and validated when the cap registers later.
26946
- */
26947
- var DeviceRuntimeState = class DeviceRuntimeState {
26948
- writer;
26949
- /** In-flight writer promises tracked so `flush()` can await them. */
26950
- pendingWrites = /* @__PURE__ */ new Set();
26951
- /** Per-cap committed slice — after schema validation when known. */
26952
- slices;
26953
- /** Per-cap registered schema (set by `installCapSchema`). */
26954
- schemas = /* @__PURE__ */ new Map();
26955
- listeners = /* @__PURE__ */ new Set();
26956
- capListeners = /* @__PURE__ */ new Map();
26957
- constructor(initial, writer) {
26958
- this.writer = writer;
26959
- this.slices = /* @__PURE__ */ new Map();
26960
- for (const [k, v] of Object.entries(initial)) if (v && typeof v === "object" && !Array.isArray(v)) this.slices.set(k, { ...v });
26961
- }
26962
- static fromInitial(initial, writer) {
26963
- return new DeviceRuntimeState(initial, writer);
26964
- }
26965
- installCapSchema(capName, schema) {
26966
- const existing = this.schemas.get(capName);
26967
- if (existing) {
26968
- if (existing !== schema) throw new Error(`[DeviceRuntimeState] capability "${capName}" registered a different runtime-state schema; each cap must declare ONE shape across every provider`);
26969
- return;
26970
- }
26971
- this.schemas.set(capName, schema);
26972
- const stored = this.slices.get(capName);
26973
- if (stored) {
26974
- const result = schema.safeParse(stored);
26975
- if (result.success) this.slices.set(capName, result.data);
26976
- else this.slices.delete(capName);
26977
- }
26978
- }
26979
- getCapState(capName) {
26980
- const slice = this.slices.get(capName);
26981
- if (!slice) return void 0;
26982
- return Object.freeze({ ...slice });
26983
- }
26984
- getCapField(capName, key) {
26985
- return this.slices.get(capName)?.[key];
26743
+ /**
26744
+ * Re-publish the device's current `features` array to the persisted
26745
+ * meta blob. Drivers call this after a probe finishes when the live
26746
+ * `features` getter has gained new flags (e.g. `hasIntercom` flips
26747
+ * to true → `DeviceFeature.TwoWayAudio` joins the list).
26748
+ *
26749
+ * Without this, only the construction-time snapshot is written —
26750
+ * `deviceManager.registerDevice` is invoked once per boot, so probe-
26751
+ * driven additions don't reach the persisted index until the next
26752
+ * server restart, and `getDevice` / `listAll` keep returning the
26753
+ * stale list for forked-worker devices (whose live IDevice instance
26754
+ * is invisible to the hub registry).
26755
+ *
26756
+ * Idempotent: re-calling with the same features just no-ops on the
26757
+ * persisted meta. Best-effort: lookup or write failures are logged
26758
+ * at debug and swallowed — the live `device.features` getter is
26759
+ * still authoritative within this process, so callers never block
26760
+ * device boot on a meta refresh.
26761
+ */
26762
+ async refreshFeatures() {
26763
+ const action = this.ctx.api?.deviceManager?.registerDevice;
26764
+ if (!action) return;
26765
+ try {
26766
+ await action.mutate({
26767
+ addonId: this.ctx.deviceMeta.addonId,
26768
+ stableId: this.stableId,
26769
+ id: this.id,
26770
+ type: this.type,
26771
+ name: this.name,
26772
+ parentDeviceId: this.parentDeviceId,
26773
+ features: [...this.features],
26774
+ config: {}
26775
+ });
26776
+ } catch (err) {}
26986
26777
  }
26987
- setCapState(capName, value) {
26988
- this.applyCapWrite(capName, value, false);
26778
+ /**
26779
+ * Typed read-through to a cap-keyed runtime-state slice. Drivers
26780
+ * call `this.getCapSlice(batteryCapability)` and the return type
26781
+ * is inferred from the cap's `runtimeState` Zod schema — no string
26782
+ * key, no manual generic. Returns `null` when the slice hasn't
26783
+ * been written yet (e.g. driver hasn't seeded battery yet).
26784
+ */
26785
+ getCapSlice(cap) {
26786
+ return this.runtimeState.getCapState(cap.name) ?? null;
26989
26787
  }
26990
- patchCapState(capName, partial) {
26991
- this.applyCapWrite(capName, partial, true);
26788
+ /**
26789
+ * Typed writer to a cap-keyed runtime-state slice. Routes through
26790
+ * the runtime-state writer (validate → persist → emit cap event).
26791
+ * Equivalent to `this.runtimeState.setCapState(cap.name, value)`
26792
+ * but with the cap's `runtimeState` schema enforcing the value
26793
+ * shape at compile time. Mirrors the symmetry of
26794
+ * `getCapSlice` / `setCapSlice` for cross-cap consistency.
26795
+ */
26796
+ setCapSlice(cap, value) {
26797
+ this.runtimeState.setCapState(cap.name, value);
26992
26798
  }
26993
26799
  /**
26994
- * Internal worker. `merge` controls whether `value` replaces or
26995
- * shallow-merges into the existing slice. Schema validation runs
26996
- * on the FINAL composed object regardless.
26800
+ * Field-level read/write proxy over a cap's runtime-state slice.
26801
+ * Drivers that want ergonomic per-field access declare:
26802
+ *
26803
+ * ```ts
26804
+ * protected battery = this.sliceProxy(batteryCapability)
26805
+ * // …
26806
+ * this.battery.sleeping = true // patches the slice
26807
+ * const charging = this.battery.charging // reads the slice
26808
+ * ```
26809
+ *
26810
+ * Reads return `undefined` when the slice hasn't been seeded yet
26811
+ * (cap not registered, or seeded but the field is absent). Writes
26812
+ * route through `runtimeState.patchCapState` so the cap's `runtimeState`
26813
+ * schema validates the merged result and the cap event fires.
26814
+ *
26815
+ * Pattern is generic — same shape works for `battery`, `device-status`,
26816
+ * `motion`, `doorbell`, anything with a `runtimeState:` schema. Drivers
26817
+ * declare one proxy per cap they read/write directly.
26997
26818
  */
26998
- applyCapWrite(capName, value, merge) {
26999
- const schema = this.schemas.get(capName);
27000
- if (!schema) throw new Error(`[DeviceRuntimeState] no schema registered for cap "${capName}" — did the device register it via ctx.registerNativeCap before writing?`);
27001
- const current = this.slices.get(capName) ?? {};
27002
- const next = merge ? {
27003
- ...current,
27004
- ...value
27005
- } : { ...value };
27006
- const parsed = schema.parse(next);
27007
- if (shallowEqual(current, parsed)) return;
27008
- this.slices.set(capName, parsed);
27009
- this.fireListeners([capName]);
27010
- const writePromise = this.writer(capName, { ...parsed }).catch(() => {});
27011
- this.pendingWrites.add(writePromise);
27012
- writePromise.finally(() => {
27013
- this.pendingWrites.delete(writePromise);
26819
+ sliceProxy(cap) {
26820
+ return new Proxy({}, {
26821
+ get: (_, key) => {
26822
+ return this.runtimeState.getCapState(cap.name)?.[key];
26823
+ },
26824
+ set: (_, key, value) => {
26825
+ this.runtimeState.patchCapState(cap.name, { [key]: value });
26826
+ return true;
26827
+ },
26828
+ has: (_, key) => {
26829
+ const slice = this.runtimeState.getCapState(cap.name);
26830
+ return slice ? key in slice : false;
26831
+ },
26832
+ ownKeys: () => {
26833
+ const slice = this.runtimeState.getCapState(cap.name);
26834
+ return slice ? Object.keys(slice) : [];
26835
+ },
26836
+ getOwnPropertyDescriptor: (_, key) => {
26837
+ const slice = this.runtimeState.getCapState(cap.name);
26838
+ if (!slice || !(key in slice)) return void 0;
26839
+ return {
26840
+ configurable: true,
26841
+ enumerable: true,
26842
+ value: slice[key]
26843
+ };
26844
+ }
27014
26845
  });
27015
26846
  }
27016
- fireListeners(changed) {
27017
- const snap = this.snapshot();
27018
- for (const cb of this.listeners) try {
27019
- cb(changed, snap);
27020
- } catch {}
27021
- for (const capName of changed) {
27022
- const subs = this.capListeners.get(capName);
27023
- if (!subs) continue;
27024
- const slice = this.getCapState(capName);
27025
- for (const cb of subs) try {
27026
- cb(slice);
27027
- } catch {}
27028
- }
26847
+ /**
26848
+ * Default empty settings UI. Drivers override this to expose an
26849
+ * editable form in the device-details page. Returning an empty sections
26850
+ * array signals "nothing to contribute" — the aggregator drops the
26851
+ * contribution entirely rather than rendering a blank panel.
26852
+ */
26853
+ getSettingsUISchema() {
26854
+ return { sections: [] };
27029
26855
  }
27030
- subscribe(cb) {
27031
- this.listeners.add(cb);
27032
- return () => {
27033
- this.listeners.delete(cb);
27034
- };
26856
+ /**
26857
+ * Default write path: forward the flat patch directly to storage.
26858
+ * Drivers that project a UI shape different from storage (e.g. `RtspCamera`
26859
+ * exposing `mainStreamUrl`/`subStreamUrl` over `streams[]`) override this
26860
+ * to reshape before `config.setAll`.
26861
+ */
26862
+ async applySettingsPatch(patch) {
26863
+ await this.config.setAll(patch);
27035
26864
  }
27036
- subscribeCap(capName, cb) {
27037
- let subs = this.capListeners.get(capName);
27038
- if (!subs) {
27039
- subs = /* @__PURE__ */ new Set();
27040
- this.capListeners.set(capName, subs);
27041
- }
27042
- const adapter = (slice) => {
27043
- cb(slice);
27044
- };
27045
- subs.add(adapter);
27046
- return () => {
27047
- const set = this.capListeners.get(capName);
27048
- if (!set) return;
27049
- set.delete(adapter);
27050
- if (set.size === 0) this.capListeners.delete(capName);
26865
+ /**
26866
+ * Phase 3 — populate device-scoped state needed by downstream phases
26867
+ * (accessory reconciliation, public `features` array, optional cap
26868
+ * registration). Called ONCE per construction, after register but
26869
+ * before `getAccessoryChildren()`.
26870
+ *
26871
+ * Drivers write the `feature-probe` runtime-state slice via
26872
+ * `this.runtimeState.setCapState('feature-probe', {...})` — flag bag
26873
+ * is open (Reolink writes `hasPtz/hasIntercom`, Hikvision writes
26874
+ * `hasSupplementalLight/hasAlarmIo`, etc).
26875
+ *
26876
+ * Default: nothing to probe → mark the device PROBED (set `lastProbedAt`) so
26877
+ * the kernel treats it as ready immediately. A device that derives its shape
26878
+ * from a spec (a container, or an accessory sensor) rather than from a
26879
+ * hardware probe has no probe to "complete"; without stamping `lastProbedAt`
26880
+ * it would look perpetually un-probed — logging "Initial probe did not
26881
+ * complete" on every boot and spinning a pointless retry chain. Drivers that
26882
+ * DO probe override this and write their own `feature-probe` slice (including
26883
+ * `lastProbedAt`) once their probe actually succeeds.
26884
+ */
26885
+ async onProbe() {
26886
+ const base = this.runtimeState.getCapState("feature-probe") ?? {
26887
+ flags: {},
26888
+ deviceType: null,
26889
+ model: null,
26890
+ channelCount: null,
26891
+ lastProbedAt: 0,
26892
+ lastFetchedAt: 0
27051
26893
  };
26894
+ this.runtimeState.setCapState("feature-probe", {
26895
+ ...base,
26896
+ lastProbedAt: Date.now()
26897
+ });
27052
26898
  }
27053
- snapshot() {
27054
- const out = {};
27055
- for (const [k, v] of this.slices) out[k] = Object.freeze({ ...v });
27056
- return Object.freeze(out);
26899
+ /**
26900
+ * Phase 5 — fired after the device + its accessories are registered.
26901
+ * Drivers publish streams to the broker, kick off background tasks,
26902
+ * or subscribe to lib events that need a fully-registered device id.
26903
+ *
26904
+ * Default: no-op.
26905
+ *
26906
+ * RENAMED FROM `onCreated` (which still exists for back-compat in this
26907
+ * pass). The new name reflects the post-probe, post-accessory contract.
26908
+ */
26909
+ async onActivate() {}
26910
+ /**
26911
+ * Re-run the probe + reconcile accessories + refresh features meta.
26912
+ * Drivers call this when device-side state changes (battery cam wakes,
26913
+ * firmware update, manual operator trigger).
26914
+ *
26915
+ * The kernel injects `_kernelReprobe` on registration so this method
26916
+ * delegates to the same orchestrator that runs the boot-time phase
26917
+ * 3 + 4 sequence. Drivers should NOT override this — they override
26918
+ * `onProbe()` instead.
26919
+ */
26920
+ async reprobe() {
26921
+ if (this._kernelReprobe) await this._kernelReprobe();
26922
+ else await this.onProbe();
27057
26923
  }
27058
- async flush() {
27059
- if (this.pendingWrites.size === 0) return;
27060
- const inflight = [...this.pendingWrites];
27061
- await Promise.allSettled(inflight);
26924
+ /**
26925
+ * Kernel-injected callback that runs the full post-probe orchestration
26926
+ * (onProbe → registerDevice meta refresh → accessory reconciliation).
26927
+ * Set by `device-cap-proxy.register()`. Drivers should not touch this
26928
+ * directly — call `reprobe()` instead.
26929
+ */
26930
+ _kernelReprobe;
26931
+ /**
26932
+ * Declare accessory child devices the kernel should auto-spawn
26933
+ * after `onProbe()` resolves. Each spec fully describes one child
26934
+ * — stableId suffix (deterministic per kind for restore-safety),
26935
+ * meta (type / name / location), config (initial blob the child
26936
+ * self-hydrates), and a factory that constructs the concrete
26937
+ * class with whatever closure-captured refs it needs (typically
26938
+ * `this` for the parent reference).
26939
+ *
26940
+ * The kernel handles the rest: allocateDeviceId, persistInitialConfig
26941
+ * (skipped on restore when the row already exists),
26942
+ * persistInitialMeta, createContext, factory invocation, register,
26943
+ * and recursive lifecycle (probe + accessories + activate).
26944
+ *
26945
+ * Implementations should derive children from
26946
+ * `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
26947
+ * Drivers can use the `getProbeFlags()` helper to read the flag bag
26948
+ * with a typed cast.
26949
+ *
26950
+ * Default: no children.
26951
+ */
26952
+ getAccessoryChildren() {
26953
+ return [];
27062
26954
  }
27063
- };
27064
- function shallowEqual(a, b) {
27065
- const ak = Object.keys(a);
27066
- const bk = Object.keys(b);
27067
- if (ak.length !== bk.length) return false;
27068
- for (const k of ak) if (a[k] !== b[k]) return false;
27069
- return true;
26955
+ /**
26956
+ * Read the current feature-probe flag bag with a typed cast. Helper
26957
+ * for `getAccessoryChildren()` and `features` getters that derive
26958
+ * outputs from the probe results.
26959
+ */
26960
+ getProbeFlags() {
26961
+ return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
26962
+ }
26963
+ /**
26964
+ * Returns true once `onProbe` has completed at least once
26965
+ * (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
26966
+ * to avoid spawning stale accessories on a fresh device whose probe
26967
+ * hasn't landed yet.
26968
+ */
26969
+ hasProbed() {
26970
+ return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
26971
+ }
26972
+ };
26973
+ /** Marker written to a declared integration's `info`. */
26974
+ var DECLARED_INTEGRATION_FIXED_KEY = "fixed";
26975
+ /**
26976
+ * Strip the `<node>/<addon>` suffix a forked child carries.
26977
+ *
26978
+ * Comparing `ctx.kernel.localNodeId` raw skipped EVERY node — including the one
26979
+ * that was supposed to act — because on the hub it reads `hub/<addon>`.
26980
+ */
26981
+ function declarationOwnerNodeId(localNodeId) {
26982
+ const raw = localNodeId ?? "hub";
26983
+ if (!raw.includes("/")) return raw;
26984
+ return raw.split("/")[0] ?? "hub";
27070
26985
  }
27071
26986
  /**
27072
- * Runtime registry: cap-property-name → cap definition. `BaseDevice`'s
27073
- * `state` getter looks up the cap definition here to construct a
27074
- * `sliceProxy()` lazily on first access. Generated alongside the type
27075
- * so type and runtime registry can never drift apart.
26987
+ * The one way an addon owns a device it declares.
26988
+ *
26989
+ * Construct once with the addon's ports, then call {@link reconcile} on boot and
26990
+ * on every convergence tick. There is no second get-or-create helper — a guard
26991
+ * in `scripts/` enforces that.
27076
26992
  */
27077
- var DEVICE_LOCAL_STATE_CAPS = {
27078
- airQualitySensor: airQualitySensorCapability,
27079
- alarmPanel: alarmPanelCapability,
27080
- ambientLightSensor: ambientLightSensorCapability,
27081
- audioMetrics: audioMetricsCapability,
27082
- automationControl: automationControlCapability,
27083
- battery: batteryCapability,
27084
- binary: binaryCapability,
27085
- brightness: brightnessCapability,
27086
- cameraStreams: cameraStreamsCapability,
27087
- carbonMonoxide: carbonMonoxideCapability,
27088
- climateControl: climateControlCapability,
27089
- color: colorCapability,
27090
- connectivity: connectivityCapability,
27091
- consumables: consumablesCapability,
27092
- contact: contactCapability,
27093
- control: controlCapability,
27094
- cover: coverCapability,
27095
- dayNight: dayNightCapability,
27096
- deviceDiscovery: deviceDiscoveryCapability,
27097
- deviceStatus: deviceStatusCapability,
27098
- doorbell: doorbellCapability,
27099
- enumSensor: enumSensorCapability,
27100
- eventEmitter: eventEmitterCapability,
27101
- fanControl: fanControlCapability,
27102
- featureProbe: featureProbeCapability,
27103
- flood: floodCapability,
27104
- gas: gasCapability,
27105
- humidifier: humidifierCapability,
27106
- humiditySensor: humiditySensorCapability,
27107
- image: imageCapability,
27108
- imageSettings: imageSettingsCapability,
27109
- lawnMowerControl: lawnMowerControlCapability,
27110
- lockControl: lockControlCapability,
27111
- mediaPlayer: mediaPlayerCapability,
27112
- motion: motionCapability,
27113
- motionTrigger: motionTriggerCapability,
27114
- motionZones: motionZonesCapability,
27115
- nativeObjectDetection: nativeObjectDetectionCapability,
27116
- notifier: notifierCapability,
27117
- numericSensor: numericSensorCapability,
27118
- petFeeder: petFeederCapability,
27119
- powerMeter: powerMeterCapability,
27120
- presence: presenceCapability,
27121
- pressureSensor: pressureSensorCapability,
27122
- privacyMask: privacyMaskCapability,
27123
- ptzAutotrack: ptzAutotrackCapability,
27124
- sceneMonitor: sceneMonitorCapability,
27125
- scriptRunner: scriptRunnerCapability,
27126
- smoke: smokeCapability,
27127
- streamParams: streamParamsCapability,
27128
- switch: switchCapability,
27129
- tamper: tamperCapability,
27130
- temperatureSensor: temperatureSensorCapability,
27131
- update: updateCapability,
27132
- vacuumControl: vacuumControlCapability,
27133
- valve: valveCapability,
27134
- vibration: vibrationCapability,
27135
- waterHeater: waterHeaterCapability,
27136
- weather: weatherCapability,
27137
- zoneAnalytics: zoneAnalyticsCapability,
27138
- zoneRules: zoneRulesCapability,
27139
- zones: zonesCapability
27140
- };
27141
- var BaseDevice = class {
27142
- id;
27143
- stableId;
27144
- type;
27145
- name;
27146
- parentDeviceId;
27147
- role;
26993
+ var DeclaredDevices = class {
26994
+ ports;
26995
+ constructor(ports) {
26996
+ this.ports = ports;
26997
+ }
27148
26998
  /**
27149
- * Cap-keyed runtime-state slice is the single source of truth for
27150
- * `online`. Both getter and setter proxy to the slice — drivers can
27151
- * write `this.online = true` ergonomically, and the cap event fires
27152
- * automatically through the runtime-state writer. `markOnline()` is
27153
- * kept as the explicit method form mandated by `IDevice`.
27154
- */
27155
- get online() {
27156
- return this.runtimeState.getCapState("device-status")?.online ?? false;
26999
+ * Converge the declared set. Idempotent, and safe to call repeatedly.
27000
+ *
27001
+ * Throws only what the ports throw on the FIRST index read; every other
27002
+ * failure is per-device and logged, so one bad declaration never takes the
27003
+ * others down.
27004
+ */
27005
+ async reconcile(spec) {
27006
+ if ((spec.placement ?? "hub") === "hub") {
27007
+ const nodeId = declarationOwnerNodeId(this.ports.localNodeId);
27008
+ if (nodeId !== "hub") {
27009
+ this.ports.logger.info("declared devices are hub-owned — skipping on this node", { meta: {
27010
+ nodeId,
27011
+ rawNodeId: this.ports.localNodeId ?? null
27012
+ } });
27013
+ return {
27014
+ integrationId: null,
27015
+ devices: [],
27016
+ removed: [],
27017
+ owned: false
27018
+ };
27019
+ }
27020
+ }
27021
+ const integrationId = await this.ensureIntegration(spec.integrationName);
27022
+ const index = await this.readIndex();
27023
+ const outcomes = [];
27024
+ for (const declaration of spec.devices) {
27025
+ const outcome = await this.applyDeclaration(declaration, integrationId, index);
27026
+ if (outcome !== null) outcomes.push(outcome);
27027
+ }
27028
+ return {
27029
+ integrationId,
27030
+ devices: outcomes,
27031
+ removed: await this.sweepWithdrawn(spec.devices, integrationId, index),
27032
+ owned: true
27033
+ };
27157
27034
  }
27158
- set online(value) {
27159
- this.markOnline(value);
27035
+ /**
27036
+ * Get-or-create the FIXED integration, and RE-ASSERT the flag every pass.
27037
+ *
27038
+ * The re-assertion is the fix for the defect the hand-rolled version shipped
27039
+ * with: writing `info.fixed` only on the create path left every pre-existing
27040
+ * install without it, and the kernel kept offering to delete an integration
27041
+ * the addon owns.
27042
+ */
27043
+ async ensureIntegration(integrationName) {
27044
+ const existing = await this.ports.getIntegration(this.ports.addonId);
27045
+ if (existing === null) {
27046
+ const created = await this.ports.createIntegration({
27047
+ addonId: this.ports.addonId,
27048
+ name: integrationName,
27049
+ info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
27050
+ });
27051
+ this.ports.logger.info("declared a fixed integration", { meta: {
27052
+ integrationId: created.id,
27053
+ name: integrationName
27054
+ } });
27055
+ return created.id;
27056
+ }
27057
+ if (existing.info?.["fixed"] !== true) {
27058
+ await this.ports.updateIntegration({
27059
+ id: existing.id,
27060
+ info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
27061
+ });
27062
+ this.ports.logger.info("re-asserted `fixed` on a declared integration", { meta: { integrationId: existing.id } });
27063
+ }
27064
+ return existing.id;
27065
+ }
27066
+ async readIndex() {
27067
+ const rows = await this.ports.listOwnDevices();
27068
+ return new Map(rows.map((row) => [row.stableId, row]));
27160
27069
  }
27161
27070
  /**
27162
- * Generic per-cap runtime-state namespace. One entry per cap with
27163
- * `runtimeState:` declared, auto-generated by codegen — see
27164
- * `device-local-state.ts`. Drivers access via:
27071
+ * One declaration: adopt what exists, create what does not.
27165
27072
  *
27166
- * `this.state.battery.sleeping = true` // patches the battery slice
27167
- * `const pct = this.state.battery.percentage` // reads the battery slice
27168
- * `this.state.deviceStatus.online = true` // mirrors `markOnline(true)`
27073
+ * The create branch is the destructive one — it seeds `initialMeta`, and
27074
+ * `initialMeta.name` lands as an unconditional `setName`. A transiently empty
27075
+ * index therefore looks exactly like a first boot and would silently re-stamp
27076
+ * the declared name over the operator's rename. D49: that branch needs a
27077
+ * second read to agree.
27078
+ */
27079
+ async applyDeclaration(declaration, integrationId, index) {
27080
+ try {
27081
+ let existing = index.get(declaration.stableId);
27082
+ if (existing === void 0) {
27083
+ existing = (await this.readIndex()).get(declaration.stableId);
27084
+ if (existing !== void 0) this.ports.logger.warn("device index disagreed with itself — adopting instead of re-creating", {
27085
+ tags: { deviceId: existing.id },
27086
+ meta: {
27087
+ stableId: declaration.stableId,
27088
+ addonId: this.ports.addonId
27089
+ }
27090
+ });
27091
+ }
27092
+ if (existing !== void 0) {
27093
+ const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, {}, null, void 0);
27094
+ this.ports.logger.info("declared device adopted", {
27095
+ tags: { deviceId: device.id },
27096
+ meta: {
27097
+ stableId: declaration.stableId,
27098
+ integrationId
27099
+ }
27100
+ });
27101
+ return {
27102
+ stableId: declaration.stableId,
27103
+ deviceId: device.id,
27104
+ device,
27105
+ created: false
27106
+ };
27107
+ }
27108
+ const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, declaration.config ?? {}, null, {
27109
+ type: declaration.type,
27110
+ name: declaration.name,
27111
+ integrationId,
27112
+ ...declaration.role === void 0 ? {} : { role: declaration.role }
27113
+ });
27114
+ this.ports.logger.info("declared device created", {
27115
+ tags: { deviceId: device.id },
27116
+ meta: {
27117
+ stableId: declaration.stableId,
27118
+ integrationId
27119
+ }
27120
+ });
27121
+ return {
27122
+ stableId: declaration.stableId,
27123
+ deviceId: device.id,
27124
+ device,
27125
+ created: true
27126
+ };
27127
+ } catch (err) {
27128
+ this.ports.logger.warn("a declared device could not be brought up", { meta: {
27129
+ stableId: declaration.stableId,
27130
+ error: err instanceof Error ? err.message : String(err)
27131
+ } });
27132
+ return null;
27133
+ }
27134
+ }
27135
+ /**
27136
+ * Remove rows under the addon's FIXED integration whose declaration is gone.
27169
27137
  *
27170
- * Adding a new cap with `runtimeState:` automatically extends this
27171
- * namespace — drivers don't have to declare proxies. Reads return
27172
- * `undefined` when the slice hasn't been seeded; writes patch via
27173
- * `runtimeState.patchCapState` and validate against the cap's schema
27174
- * (so partial writes need the slice to be seeded with the required
27175
- * fields first — drivers do this on cap registration).
27138
+ * Bounded to that integration: a declared integration has no operator
27139
+ * add-flow, so every row under it got there by declaration. Devices this
27140
+ * addon owns OUTSIDE it (a provider's adopted devices) are never candidates.
27176
27141
  *
27177
- * For caps not exposed in `DeviceLocalState`, drivers can build their
27178
- * own typed proxy via `this.sliceProxy(cap)`.
27179
- */
27180
- get state() {
27181
- if (!this._stateProxyCache) {
27182
- const cache = {};
27183
- const handler = { get: (_target, key) => {
27184
- const k = key;
27185
- if (k in cache) return cache[k];
27186
- const cap = DEVICE_LOCAL_STATE_CAPS[k];
27187
- if (!cap) return void 0;
27188
- const proxy = this.sliceProxy(cap);
27189
- cache[k] = proxy;
27190
- return proxy;
27191
- } };
27192
- this._stateProxyCache = new Proxy(cache, handler);
27142
+ * Bounded in count, and every deletion is logged with its `deviceId` — a
27143
+ * withdrawal that removes an operator-visible row silently is the failure
27144
+ * mode, not the removal itself.
27145
+ */
27146
+ async sweepWithdrawn(declarations, integrationId, index) {
27147
+ const declared = new Set(declarations.map((d) => d.stableId));
27148
+ const candidates = [...index.values()].filter((row) => row.integrationId === integrationId && !declared.has(row.stableId));
27149
+ if (candidates.length === 0) return [];
27150
+ if (candidates.length > 32) {
27151
+ this.ports.logger.warn("withdrawal sweep exceeded its bound — removing nothing", { meta: {
27152
+ integrationId,
27153
+ candidates: candidates.length,
27154
+ bound: 32
27155
+ } });
27156
+ return [];
27157
+ }
27158
+ const removed = [];
27159
+ for (const row of candidates) try {
27160
+ await this.ports.devices.remove(row.id);
27161
+ removed.push(row.id);
27162
+ this.ports.logger.info("declared device removed — its declaration was withdrawn", {
27163
+ tags: { deviceId: row.id },
27164
+ meta: {
27165
+ stableId: row.stableId,
27166
+ integrationId
27167
+ }
27168
+ });
27169
+ } catch (err) {
27170
+ this.ports.logger.warn("a withdrawn declared device could not be removed", {
27171
+ tags: { deviceId: row.id },
27172
+ meta: {
27173
+ stableId: row.stableId,
27174
+ error: err instanceof Error ? err.message : String(err)
27175
+ }
27176
+ });
27177
+ }
27178
+ return removed;
27179
+ }
27180
+ };
27181
+ 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;
27182
+ new Set(Object.values(DeviceType));
27183
+ DeviceFeature.BatteryOperated;
27184
+ /**
27185
+ * Error types for the safe expression engine. Two distinct classes so callers
27186
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
27187
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
27188
+ */
27189
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
27190
+ * the failure is anchored to a character (author-facing inline feedback). */
27191
+ var ExpressionParseError = class extends Error {
27192
+ position;
27193
+ constructor(message, position) {
27194
+ super(message);
27195
+ this.name = "ExpressionParseError";
27196
+ this.position = position;
27197
+ }
27198
+ };
27199
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
27200
+ * result, unknown builtin, step-budget exceeded). */
27201
+ var ExpressionEvalError = class extends Error {
27202
+ constructor(message) {
27203
+ super(message);
27204
+ this.name = "ExpressionEvalError";
27205
+ }
27206
+ };
27207
+ /**
27208
+ * Frozen, null-prototype builtin function table for the expression engine
27209
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
27210
+ * parser rejects any callee not in it, and the evaluator gates each call on an
27211
+ * own-property check against it.
27212
+ *
27213
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
27214
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
27215
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
27216
+ * (there is no `Object.prototype` in the chain), so those names are not
27217
+ * callable — they are simply "unknown function" at parse time.
27218
+ *
27219
+ * Every numeric argument is validated as a finite number and every numeric
27220
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
27221
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
27222
+ * closed rather than emitting a garbage value.
27223
+ */
27224
+ function asFiniteNumber(value, name, index) {
27225
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
27226
+ return value;
27227
+ }
27228
+ function asString$1(value, name, index) {
27229
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
27230
+ return value;
27231
+ }
27232
+ function finiteResult(value, name) {
27233
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
27234
+ return value;
27235
+ }
27236
+ function allFiniteNumbers(args, name) {
27237
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
27238
+ }
27239
+ var INF = Number.POSITIVE_INFINITY;
27240
+ var table = {
27241
+ min: {
27242
+ minArgs: 1,
27243
+ maxArgs: INF,
27244
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
27245
+ },
27246
+ max: {
27247
+ minArgs: 1,
27248
+ maxArgs: INF,
27249
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
27250
+ },
27251
+ abs: {
27252
+ minArgs: 1,
27253
+ maxArgs: 1,
27254
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
27255
+ },
27256
+ floor: {
27257
+ minArgs: 1,
27258
+ maxArgs: 1,
27259
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
27260
+ },
27261
+ ceil: {
27262
+ minArgs: 1,
27263
+ maxArgs: 1,
27264
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
27265
+ },
27266
+ sqrt: {
27267
+ minArgs: 1,
27268
+ maxArgs: 1,
27269
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
27270
+ },
27271
+ round: {
27272
+ minArgs: 1,
27273
+ maxArgs: 2,
27274
+ apply: (args) => {
27275
+ const x = asFiniteNumber(args[0], "round", 0);
27276
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
27277
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
27278
+ const factor = 10 ** digits;
27279
+ return finiteResult(Math.round(x * factor) / factor, "round");
27280
+ }
27281
+ },
27282
+ pow: {
27283
+ minArgs: 2,
27284
+ maxArgs: 2,
27285
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
27286
+ },
27287
+ clamp: {
27288
+ minArgs: 3,
27289
+ maxArgs: 3,
27290
+ apply: (args) => {
27291
+ const x = asFiniteNumber(args[0], "clamp", 0);
27292
+ const lo = asFiniteNumber(args[1], "clamp", 1);
27293
+ const hi = asFiniteNumber(args[2], "clamp", 2);
27294
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
27295
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
27296
+ }
27297
+ },
27298
+ avg: {
27299
+ minArgs: 1,
27300
+ maxArgs: INF,
27301
+ apply: (args) => {
27302
+ const nums = allFiniteNumbers(args, "avg");
27303
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
27304
+ }
27305
+ },
27306
+ sum: {
27307
+ minArgs: 1,
27308
+ maxArgs: INF,
27309
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
27310
+ },
27311
+ coalesce: {
27312
+ minArgs: 1,
27313
+ maxArgs: INF,
27314
+ apply: (args) => {
27315
+ for (const a of args) if (a !== null) return a;
27316
+ return null;
27317
+ }
27318
+ },
27319
+ age: {
27320
+ minArgs: 2,
27321
+ maxArgs: 2,
27322
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
27323
+ },
27324
+ convert: {
27325
+ minArgs: 3,
27326
+ maxArgs: 3,
27327
+ apply: (args, hooks) => {
27328
+ const x = asFiniteNumber(args[0], "convert", 0);
27329
+ const from = asString$1(args[1], "convert", 1).trim();
27330
+ const to = asString$1(args[2], "convert", 2).trim();
27331
+ if (hooks.convert) {
27332
+ const out = hooks.convert(x, from, to);
27333
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
27334
+ return finiteResult(out, "convert");
27335
+ }
27336
+ if (from === to) return x;
27337
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
27193
27338
  }
27194
- return this._stateProxyCache;
27195
27339
  }
27196
- _stateProxyCache;
27197
- config;
27198
- /**
27199
- * Per-device runtime state, cap-keyed. Always installed — slices
27200
- * for individual caps materialise as those caps register their
27201
- * native providers (`ctx.registerNativeCap`). The cap's own
27202
- * `runtimeState` schema is the source of truth for the slice
27203
- * shape; drivers don't redeclare it, they just write through.
27204
- *
27205
- * Read: `this.runtimeState.getCapState('battery')` →
27206
- * `{percentage, charging, sleeping, lastUpdated}` for any
27207
- * provider that registers `batteryCapability`.
27208
- * Write: `this.runtimeState.setCapState('battery', { … })`.
27209
- *
27210
- * Cross-process consumers reach this state through the
27211
- * `deviceState` cap router (or via cap-specific events the driver
27212
- * emits — e.g. `battery.onStatusChanged`). The local handle is
27213
- * accessed in-process by the driver to avoid roundtrips.
27214
- */
27215
- runtimeState;
27216
- ctx;
27217
- /**
27218
- * Operator-organisational location label (room / area / zone).
27219
- * Read from `ctx.deviceMeta.location`; mutated via
27220
- * `kernel.devices.setLocation(id, value)`. Free-text — providers
27221
- * don't interpret it; the UI groups devices by this for filters
27222
- * like "show me all cameras in Kitchen". `null` when unset.
27223
- */
27224
- location;
27225
- /**
27226
- * Soft-disabled flag. When `true`, the device class is still
27227
- * instantiated and visible in the UI (so the operator can flip
27228
- * back on without re-adding) but lifecycle hooks (publishToBroker,
27229
- * alarm-stream subscribe, …) MUST be gated by the driver to skip
27230
- * work. The `BaseDevice` enforces this by exposing the flag here;
27231
- * it does NOT mutate cap behaviour automatically — drivers consult
27232
- * `this.disabled` at the top of their lifecycle methods. Read from
27233
- * `ctx.deviceMeta.disabled`; mutated via
27234
- * `kernel.devices.setDisabled(id, value)`.
27235
- */
27236
- disabled;
27237
- /**
27238
- * Cached materialised `SourceInfo` — either the value persisted under
27239
- * `metadata.sourceInfo` at construction time, or a synthetic
27240
- * `{ id: stableId, system: addonId }` for providers that haven't
27241
- * migrated yet. Lazily populated on first `sourceInfo` read so the
27242
- * cost of Zod-parsing the meta blob is paid once per device boot.
27243
- * Invalidated by `updateSourceInfo()` so providers see the new value
27244
- * back through the getter without a re-fetch from the meta surface.
27245
- */
27246
- _sourceInfoCache = null;
27247
- constructor(ctx, schema, options) {
27248
- this.ctx = ctx;
27249
- this.id = ctx.id;
27250
- this.stableId = ctx.stableId;
27251
- this.type = options.type;
27252
- if (!ctx.deviceMeta) throw new Error(`BaseDevice constructor: ctx.deviceMeta is required (id=${ctx.id} stableId=${ctx.stableId})`);
27253
- this.name = ctx.deviceMeta.name;
27254
- this.location = ctx.deviceMeta.location;
27255
- this.disabled = ctx.deviceMeta.disabled;
27256
- this.role = options.role;
27257
- this.parentDeviceId = ctx.parentDeviceId;
27258
- const seedData = ctx.persistedConfig ?? {};
27259
- this.config = DeviceConfig.fromSchema(schema, (data) => ctx.persistConfig(data), seedData, ({ droppedKeys, issues }) => {
27260
- ctx.logger.warn("Device config recovery: dropping invalid persisted fields", {
27261
- tags: {
27262
- deviceId: ctx.id,
27263
- stableId: ctx.stableId
27264
- },
27265
- meta: {
27266
- droppedKeys: [...droppedKeys],
27267
- firstIssue: issues[0]?.message ?? null
27340
+ };
27341
+ Object.freeze(Object.assign(Object.create(null), table));
27342
+ /** The set of valid builtin names — used by the parser to reject unknown
27343
+ * callees at parse time (immediate author feedback). */
27344
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
27345
+ /**
27346
+ * Resource-bound constants for the safe expression engine.
27347
+ *
27348
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
27349
+ * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
27350
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
27351
+ * work a single author-supplied expression can request, so a hostile or
27352
+ * accidental pathological string can never spend unbounded CPU/memory.
27353
+ */
27354
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
27355
+ * rejected without allocation. */
27356
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
27357
+ /** A legal binding / identifier name. */
27358
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
27359
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
27360
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
27361
+ var RESERVED_BINDING_NAMES = new Set([
27362
+ "now",
27363
+ "true",
27364
+ "false",
27365
+ "null"
27366
+ ]);
27367
+ /**
27368
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
27369
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
27370
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
27371
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
27372
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
27373
+ * is a parse error with a source position, so member access / assignment /
27374
+ * template literals are lexically impossible.
27375
+ */
27376
+ var KEYWORDS = new Set([
27377
+ "true",
27378
+ "false",
27379
+ "null"
27380
+ ]);
27381
+ function isDigit(ch) {
27382
+ return ch >= "0" && ch <= "9";
27383
+ }
27384
+ function isIdentStart(ch) {
27385
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
27386
+ }
27387
+ function isIdentPart(ch) {
27388
+ return isIdentStart(ch) || isDigit(ch);
27389
+ }
27390
+ function isWhitespace(ch) {
27391
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
27392
+ }
27393
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
27394
+ * Throws `ExpressionParseError` on any illegal character or unterminated
27395
+ * string. */
27396
+ function tokenize(source) {
27397
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
27398
+ const tokens = [];
27399
+ let i = 0;
27400
+ const n = source.length;
27401
+ while (i < n) {
27402
+ const ch = source[i];
27403
+ if (isWhitespace(ch)) {
27404
+ i += 1;
27405
+ continue;
27406
+ }
27407
+ if (isDigit(ch)) {
27408
+ const start = i;
27409
+ while (i < n && isDigit(source[i])) i += 1;
27410
+ if (i < n && source[i] === ".") {
27411
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
27412
+ i += 1;
27413
+ while (i < n && isDigit(source[i])) i += 1;
27414
+ }
27415
+ const text = source.slice(start, i);
27416
+ const value = Number(text);
27417
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
27418
+ tokens.push({
27419
+ type: "number",
27420
+ value,
27421
+ pos: start
27422
+ });
27423
+ continue;
27424
+ }
27425
+ if (ch === "'" || ch === "\"") {
27426
+ const quote = ch;
27427
+ const start = i;
27428
+ i += 1;
27429
+ let out = "";
27430
+ let closed = false;
27431
+ while (i < n) {
27432
+ const c = source[i];
27433
+ if (c === "\\") {
27434
+ const next = i + 1 < n ? source[i + 1] : "";
27435
+ if (next === "\\" || next === "'" || next === "\"") {
27436
+ out += next;
27437
+ i += 2;
27438
+ continue;
27439
+ }
27440
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
27441
+ }
27442
+ if (c === quote) {
27443
+ closed = true;
27444
+ i += 1;
27445
+ break;
27268
27446
  }
27447
+ out += c;
27448
+ i += 1;
27449
+ }
27450
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
27451
+ tokens.push({
27452
+ type: "string",
27453
+ value: out,
27454
+ pos: start
27269
27455
  });
27270
- });
27271
- let cachedProxy = null;
27272
- const writer = async (capName, slice) => {
27273
- if (!cachedProxy) cachedProxy = ctx.fetchDevice(ctx.id);
27274
- await (await cachedProxy).deviceState.setCapSlice({
27275
- capName,
27276
- slice
27456
+ continue;
27457
+ }
27458
+ if (isIdentStart(ch)) {
27459
+ const start = i;
27460
+ while (i < n && isIdentPart(source[i])) i += 1;
27461
+ const text = source.slice(start, i);
27462
+ if (KEYWORDS.has(text)) tokens.push({
27463
+ type: "keyword",
27464
+ keyword: keywordOf(text),
27465
+ pos: start
27277
27466
  });
27278
- };
27279
- const initial = ctx.initialRuntimeState ?? {};
27280
- this.runtimeState = DeviceRuntimeState.fromInitial(initial, writer);
27281
- ctx.bindRuntimeState?.(this.runtimeState);
27282
- ctx.registerNativeCap?.(deviceStatusCapability, {});
27283
- const seed = {
27284
- online: true,
27285
- lastChangedAt: Date.now()
27286
- };
27287
- this.runtimeState.setCapState("device-status", seed);
27288
- ctx.registerNativeCap?.(featureProbeCapability, {});
27289
- this.runtimeState.setCapState("feature-probe", {
27290
- flags: {},
27291
- deviceType: null,
27292
- model: null,
27293
- channelCount: null,
27294
- lastProbedAt: 0,
27295
- lastFetchedAt: 0
27296
- });
27297
- }
27298
- deviceActions = /* @__PURE__ */ new Map();
27299
- /** Declare a device custom action + its typed handler. Idempotent per name. */
27300
- registerDeviceAction(name, spec, handler) {
27301
- this.deviceActions.set(name, {
27302
- spec,
27303
- handler
27304
- });
27467
+ else tokens.push({
27468
+ type: "identifier",
27469
+ name: text,
27470
+ pos: start
27471
+ });
27472
+ continue;
27473
+ }
27474
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
27475
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
27476
+ tokens.push({
27477
+ type: "punct",
27478
+ punct: two,
27479
+ pos: i
27480
+ });
27481
+ i += 2;
27482
+ continue;
27483
+ }
27484
+ if (isSinglePunct(ch)) {
27485
+ tokens.push({
27486
+ type: "punct",
27487
+ punct: ch,
27488
+ pos: i
27489
+ });
27490
+ i += 1;
27491
+ continue;
27492
+ }
27493
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
27305
27494
  }
27306
- /** Invoke a registered device action. Validates input against the spec. */
27307
- async runDeviceAction(action, input) {
27308
- const entry = this.deviceActions.get(action);
27309
- if (!entry) throw new Error(`unknown device action "${action}" on device ${this.id}`);
27310
- const parsed = entry.spec.input.parse(input);
27311
- return entry.handler(parsed);
27495
+ tokens.push({
27496
+ type: "eof",
27497
+ pos: n
27498
+ });
27499
+ return tokens;
27500
+ }
27501
+ function keywordOf(text) {
27502
+ if (text === "true") return "true";
27503
+ if (text === "false") return "false";
27504
+ return "null";
27505
+ }
27506
+ function isSinglePunct(ch) {
27507
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
27508
+ }
27509
+ /**
27510
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
27511
+ *
27512
+ * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
27513
+ * → relational → additive → multiplicative → unary `! -` → call / primary.
27514
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
27515
+ * string validated against the builtin table at parse time, so an unknown
27516
+ * function is rejected immediately (author feedback) and a persisted expression
27517
+ * that references a since-removed builtin degrades at read.
27518
+ *
27519
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
27520
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
27521
+ */
27522
+ /** Binary/logical operator precedence (higher binds tighter). */
27523
+ var BINARY_PRECEDENCE = {
27524
+ "||": 1,
27525
+ "&&": 2,
27526
+ "==": 3,
27527
+ "!=": 3,
27528
+ "<": 4,
27529
+ "<=": 4,
27530
+ ">": 4,
27531
+ ">=": 4,
27532
+ "+": 5,
27533
+ "-": 5,
27534
+ "*": 6,
27535
+ "/": 6,
27536
+ "%": 6
27537
+ };
27538
+ function isLogicalOp(op) {
27539
+ return op === "&&" || op === "||";
27540
+ }
27541
+ function isBinaryOp(op) {
27542
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
27543
+ }
27544
+ var Parser = class {
27545
+ tokens;
27546
+ pos = 0;
27547
+ nodeCount = 0;
27548
+ identifiers = /* @__PURE__ */ new Set();
27549
+ callees = /* @__PURE__ */ new Set();
27550
+ constructor(tokens) {
27551
+ this.tokens = tokens;
27312
27552
  }
27313
- async removeDevice() {}
27314
- /**
27315
- * Set the device's online flag. Called by `BaseDeviceProvider` after
27316
- * aggregating per-profile stream-broker health, or directly by drivers
27317
- * that have provider-side liveness signals (e.g. ONVIF heartbeats,
27318
- * Reolink Baichuan firmware push events). Mirrors the new value into
27319
- * the `device-status` runtime-state slice so cross-process consumers
27320
- * pick it up via the standard cap-state channel. Subclasses can
27321
- * override to gate side effects on the transition.
27322
- */
27323
- markOnline(online) {
27324
- if (this.online === online) return;
27325
- const next = {
27326
- online,
27327
- lastChangedAt: Date.now()
27553
+ parse() {
27554
+ const ast = this.parseTernary();
27555
+ const tok = this.peek();
27556
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
27557
+ return {
27558
+ ast,
27559
+ identifiers: this.identifiers,
27560
+ callees: this.callees,
27561
+ nodeCount: this.nodeCount
27328
27562
  };
27329
- this.runtimeState.setCapState("device-status", next);
27330
- }
27331
- /**
27332
- * Upstream-system identity + rendering envelope for this device. See
27333
- * `SourceInfo` for the field contract. Always returns a valid object:
27334
- * if the persisted `metadata.sourceInfo` blob is absent or fails Zod
27335
- * validation, falls back to a synthetic `{ id: stableId, system: addonId }`
27336
- * so providers that haven't migrated keep working without code changes.
27337
- *
27338
- * The value is cached after the first read. `updateSourceInfo()`
27339
- * invalidates the cache so subsequent reads see the new patch. The
27340
- * returned object is frozen to prevent accidental in-place mutation —
27341
- * use `updateSourceInfo({ patch })` to change fields.
27342
- */
27343
- get sourceInfo() {
27344
- if (this._sourceInfoCache) return this._sourceInfoCache;
27345
- const resolved = extractSourceInfoFromMetadata(this.ctx.deviceMeta.metadata) ?? synthesizeSourceInfo({
27346
- stableId: this.stableId,
27347
- addonId: this.ctx.deviceMeta.addonId
27348
- });
27349
- this._sourceInfoCache = Object.freeze({ ...resolved });
27350
- return this._sourceInfoCache;
27351
- }
27352
- /**
27353
- * Convenience accessor for the upstream dispatch key. Equivalent to
27354
- * `this.sourceInfo.id` — providers use this to keep a
27355
- * `Map<sourceId, IDevice>` for routing inbound push events.
27356
- */
27357
- get sourceId() {
27358
- return this.sourceInfo.id;
27359
- }
27360
- /**
27361
- * Patch the device's `SourceInfo`. Shallow-merges `patch` over the
27362
- * current value, persists the merged result under
27363
- * `metadata.sourceInfo` via the `device-manager.setMetadata` cap, and
27364
- * emits `EventCategory.DeviceSourceInfoChanged` for live consumers.
27365
- *
27366
- * Safe to call from anywhere in the device's lifetime — the call is
27367
- * idempotent for `undefined` patch values (ignored) and best-effort
27368
- * for persistence (a transient device-manager error doesn't unwind
27369
- * the local cache update, so subsequent reads still see the patch).
27370
- *
27371
- * Drivers populate this on adoption + on every metadata change push
27372
- * from the upstream source. Subscribers (UI, export adapters) react
27373
- * via the `DeviceSourceInfoChanged` event without polling.
27374
- */
27375
- async updateSourceInfo(patch) {
27376
- const next = mergeSourceInfo(this.sourceInfo, patch);
27377
- this._sourceInfoCache = Object.freeze({ ...next });
27378
- const action = this.ctx.api?.deviceManager?.setMetadata;
27379
- if (action) try {
27380
- await action.mutate({
27381
- deviceId: this.id,
27382
- patch: { [SOURCE_INFO_METADATA_KEY]: next }
27383
- });
27384
- } catch {}
27385
- this.ctx.eventBus.emit(createEvent("device.source-info-changed", {
27386
- type: "device",
27387
- id: this.stableId
27388
- }, {
27389
- deviceId: this.id,
27390
- sourceInfo: next
27391
- }));
27392
- }
27393
- /**
27394
- * Re-publish the device's current `features` array to the persisted
27395
- * meta blob. Drivers call this after a probe finishes when the live
27396
- * `features` getter has gained new flags (e.g. `hasIntercom` flips
27397
- * to true → `DeviceFeature.TwoWayAudio` joins the list).
27398
- *
27399
- * Without this, only the construction-time snapshot is written —
27400
- * `deviceManager.registerDevice` is invoked once per boot, so probe-
27401
- * driven additions don't reach the persisted index until the next
27402
- * server restart, and `getDevice` / `listAll` keep returning the
27403
- * stale list for forked-worker devices (whose live IDevice instance
27404
- * is invisible to the hub registry).
27405
- *
27406
- * Idempotent: re-calling with the same features just no-ops on the
27407
- * persisted meta. Best-effort: lookup or write failures are logged
27408
- * at debug and swallowed — the live `device.features` getter is
27409
- * still authoritative within this process, so callers never block
27410
- * device boot on a meta refresh.
27411
- */
27412
- async refreshFeatures() {
27413
- const action = this.ctx.api?.deviceManager?.registerDevice;
27414
- if (!action) return;
27415
- try {
27416
- await action.mutate({
27417
- addonId: this.ctx.deviceMeta.addonId,
27418
- stableId: this.stableId,
27419
- id: this.id,
27420
- type: this.type,
27421
- name: this.name,
27422
- parentDeviceId: this.parentDeviceId,
27423
- features: [...this.features],
27424
- config: {}
27425
- });
27426
- } catch (err) {}
27427
27563
  }
27428
- /**
27429
- * Typed read-through to a cap-keyed runtime-state slice. Drivers
27430
- * call `this.getCapSlice(batteryCapability)` and the return type
27431
- * is inferred from the cap's `runtimeState` Zod schema — no string
27432
- * key, no manual generic. Returns `null` when the slice hasn't
27433
- * been written yet (e.g. driver hasn't seeded battery yet).
27434
- */
27435
- getCapSlice(cap) {
27436
- return this.runtimeState.getCapState(cap.name) ?? null;
27564
+ peek() {
27565
+ return this.tokens[this.pos];
27437
27566
  }
27438
- /**
27439
- * Typed writer to a cap-keyed runtime-state slice. Routes through
27440
- * the runtime-state writer (validate → persist → emit cap event).
27441
- * Equivalent to `this.runtimeState.setCapState(cap.name, value)`
27442
- * but with the cap's `runtimeState` schema enforcing the value
27443
- * shape at compile time. Mirrors the symmetry of
27444
- * `getCapSlice` / `setCapSlice` for cross-cap consistency.
27445
- */
27446
- setCapSlice(cap, value) {
27447
- this.runtimeState.setCapState(cap.name, value);
27567
+ next() {
27568
+ return this.tokens[this.pos++];
27448
27569
  }
27449
- /**
27450
- * Field-level read/write proxy over a cap's runtime-state slice.
27451
- * Drivers that want ergonomic per-field access declare:
27452
- *
27453
- * ```ts
27454
- * protected battery = this.sliceProxy(batteryCapability)
27455
- * // …
27456
- * this.battery.sleeping = true // patches the slice
27457
- * const charging = this.battery.charging // reads the slice
27458
- * ```
27459
- *
27460
- * Reads return `undefined` when the slice hasn't been seeded yet
27461
- * (cap not registered, or seeded but the field is absent). Writes
27462
- * route through `runtimeState.patchCapState` so the cap's `runtimeState`
27463
- * schema validates the merged result and the cap event fires.
27464
- *
27465
- * Pattern is generic — same shape works for `battery`, `device-status`,
27466
- * `motion`, `doorbell`, anything with a `runtimeState:` schema. Drivers
27467
- * declare one proxy per cap they read/write directly.
27468
- */
27469
- sliceProxy(cap) {
27470
- return new Proxy({}, {
27471
- get: (_, key) => {
27472
- return this.runtimeState.getCapState(cap.name)?.[key];
27473
- },
27474
- set: (_, key, value) => {
27475
- this.runtimeState.patchCapState(cap.name, { [key]: value });
27476
- return true;
27477
- },
27478
- has: (_, key) => {
27479
- const slice = this.runtimeState.getCapState(cap.name);
27480
- return slice ? key in slice : false;
27481
- },
27482
- ownKeys: () => {
27483
- const slice = this.runtimeState.getCapState(cap.name);
27484
- return slice ? Object.keys(slice) : [];
27485
- },
27486
- getOwnPropertyDescriptor: (_, key) => {
27487
- const slice = this.runtimeState.getCapState(cap.name);
27488
- if (!slice || !(key in slice)) return void 0;
27570
+ /** Consume a punctuator token, erroring if the next token isn't it. */
27571
+ expectPunct(punct) {
27572
+ const tok = this.peek();
27573
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
27574
+ this.pos += 1;
27575
+ }
27576
+ matchPunct(punct) {
27577
+ const tok = this.peek();
27578
+ if (tok.type === "punct" && tok.punct === punct) {
27579
+ this.pos += 1;
27580
+ return true;
27581
+ }
27582
+ return false;
27583
+ }
27584
+ countNode() {
27585
+ this.nodeCount += 1;
27586
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
27587
+ }
27588
+ parseTernary() {
27589
+ const test = this.parseBinary(1);
27590
+ if (this.matchPunct("?")) {
27591
+ const consequent = this.parseTernary();
27592
+ this.expectPunct(":");
27593
+ const alternate = this.parseTernary();
27594
+ this.countNode();
27595
+ return {
27596
+ kind: "conditional",
27597
+ test,
27598
+ consequent,
27599
+ alternate
27600
+ };
27601
+ }
27602
+ return test;
27603
+ }
27604
+ parseBinary(minPrec) {
27605
+ let left = this.parseUnary();
27606
+ for (;;) {
27607
+ const tok = this.peek();
27608
+ if (tok.type !== "punct") break;
27609
+ const prec = BINARY_PRECEDENCE[tok.punct];
27610
+ if (prec === void 0 || prec < minPrec) break;
27611
+ const op = tok.punct;
27612
+ this.pos += 1;
27613
+ const right = this.parseBinary(prec + 1);
27614
+ this.countNode();
27615
+ if (isLogicalOp(op)) left = {
27616
+ kind: "logical",
27617
+ op,
27618
+ left,
27619
+ right
27620
+ };
27621
+ else if (isBinaryOp(op)) left = {
27622
+ kind: "binary",
27623
+ op,
27624
+ left,
27625
+ right
27626
+ };
27627
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
27628
+ }
27629
+ return left;
27630
+ }
27631
+ parseUnary() {
27632
+ const tok = this.peek();
27633
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
27634
+ const op = tok.punct;
27635
+ this.pos += 1;
27636
+ const operand = this.parseUnary();
27637
+ this.countNode();
27638
+ return {
27639
+ kind: "unary",
27640
+ op,
27641
+ operand
27642
+ };
27643
+ }
27644
+ return this.parsePrimary();
27645
+ }
27646
+ parsePrimary() {
27647
+ const tok = this.next();
27648
+ switch (tok.type) {
27649
+ case "number":
27650
+ this.countNode();
27489
27651
  return {
27490
- configurable: true,
27491
- enumerable: true,
27492
- value: slice[key]
27652
+ kind: "literal",
27653
+ value: tok.value
27654
+ };
27655
+ case "string":
27656
+ this.countNode();
27657
+ return {
27658
+ kind: "literal",
27659
+ value: tok.value
27660
+ };
27661
+ case "keyword":
27662
+ this.countNode();
27663
+ return {
27664
+ kind: "literal",
27665
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
27666
+ };
27667
+ case "identifier": {
27668
+ const nextTok = this.peek();
27669
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
27670
+ this.identifiers.add(tok.name);
27671
+ this.countNode();
27672
+ return {
27673
+ kind: "identifier",
27674
+ name: tok.name
27493
27675
  };
27494
27676
  }
27495
- });
27496
- }
27497
- /**
27498
- * Default empty settings UI. Drivers override this to expose an
27499
- * editable form in the device-details page. Returning an empty sections
27500
- * array signals "nothing to contribute" — the aggregator drops the
27501
- * contribution entirely rather than rendering a blank panel.
27502
- */
27503
- getSettingsUISchema() {
27504
- return { sections: [] };
27505
- }
27506
- /**
27507
- * Default write path: forward the flat patch directly to storage.
27508
- * Drivers that project a UI shape different from storage (e.g. `RtspCamera`
27509
- * exposing `mainStreamUrl`/`subStreamUrl` over `streams[]`) override this
27510
- * to reshape before `config.setAll`.
27511
- */
27512
- async applySettingsPatch(patch) {
27513
- await this.config.setAll(patch);
27677
+ case "punct":
27678
+ if (tok.punct === "(") {
27679
+ const inner = this.parseTernary();
27680
+ this.expectPunct(")");
27681
+ return inner;
27682
+ }
27683
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
27684
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
27685
+ }
27514
27686
  }
27515
- /**
27516
- * Phase 3 — populate device-scoped state needed by downstream phases
27517
- * (accessory reconciliation, public `features` array, optional cap
27518
- * registration). Called ONCE per construction, after register but
27519
- * before `getAccessoryChildren()`.
27520
- *
27521
- * Drivers write the `feature-probe` runtime-state slice via
27522
- * `this.runtimeState.setCapState('feature-probe', {...})` — flag bag
27523
- * is open (Reolink writes `hasPtz/hasIntercom`, Hikvision writes
27524
- * `hasSupplementalLight/hasAlarmIo`, etc).
27525
- *
27526
- * Default: nothing to probe → mark the device PROBED (set `lastProbedAt`) so
27527
- * the kernel treats it as ready immediately. A device that derives its shape
27528
- * from a spec (a container, or an accessory sensor) rather than from a
27529
- * hardware probe has no probe to "complete"; without stamping `lastProbedAt`
27530
- * it would look perpetually un-probed — logging "Initial probe did not
27531
- * complete" on every boot and spinning a pointless retry chain. Drivers that
27532
- * DO probe override this and write their own `feature-probe` slice (including
27533
- * `lastProbedAt`) once their probe actually succeeds.
27534
- */
27535
- async onProbe() {
27536
- const base = this.runtimeState.getCapState("feature-probe") ?? {
27537
- flags: {},
27538
- deviceType: null,
27539
- model: null,
27540
- channelCount: null,
27541
- lastProbedAt: 0,
27542
- lastFetchedAt: 0
27687
+ parseCall(callee, pos) {
27688
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
27689
+ this.expectPunct("(");
27690
+ const args = [];
27691
+ if (!this.matchPunct(")")) for (;;) {
27692
+ args.push(this.parseTernary());
27693
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
27694
+ if (this.matchPunct(",")) continue;
27695
+ this.expectPunct(")");
27696
+ break;
27697
+ }
27698
+ this.callees.add(callee);
27699
+ this.countNode();
27700
+ return {
27701
+ kind: "call",
27702
+ callee,
27703
+ args
27543
27704
  };
27544
- this.runtimeState.setCapState("feature-probe", {
27545
- ...base,
27546
- lastProbedAt: Date.now()
27547
- });
27548
27705
  }
27549
- /**
27550
- * Phase 5 — fired after the device + its accessories are registered.
27551
- * Drivers publish streams to the broker, kick off background tasks,
27552
- * or subscribe to lib events that need a fully-registered device id.
27553
- *
27554
- * Default: no-op.
27555
- *
27556
- * RENAMED FROM `onCreated` (which still exists for back-compat in this
27557
- * pass). The new name reflects the post-probe, post-accessory contract.
27558
- */
27559
- async onActivate() {}
27560
- /**
27561
- * Re-run the probe + reconcile accessories + refresh features meta.
27562
- * Drivers call this when device-side state changes (battery cam wakes,
27563
- * firmware update, manual operator trigger).
27564
- *
27565
- * The kernel injects `_kernelReprobe` on registration so this method
27566
- * delegates to the same orchestrator that runs the boot-time phase
27567
- * 3 + 4 sequence. Drivers should NOT override this — they override
27568
- * `onProbe()` instead.
27569
- */
27570
- async reprobe() {
27571
- if (this._kernelReprobe) await this._kernelReprobe();
27572
- else await this.onProbe();
27706
+ };
27707
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
27708
+ * `ExpressionParseError` on any lexical or grammatical failure. */
27709
+ function parseExpression(source) {
27710
+ return new Parser(tokenize(source)).parse();
27711
+ }
27712
+ /**
27713
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
27714
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
27715
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
27716
+ * one per read on a hot resolve path.
27717
+ *
27718
+ * The cache is a module-level singleton: entries are pure, content-addressed
27719
+ * ASTs keyed by the raw source string, so sharing one instance across all
27720
+ * callers is safe and maximises hit rate.
27721
+ */
27722
+ var cache = /* @__PURE__ */ new Map();
27723
+ function getCached(source) {
27724
+ const hit = cache.get(source);
27725
+ if (hit !== void 0) {
27726
+ cache.delete(source);
27727
+ cache.set(source, hit);
27728
+ return hit;
27729
+ }
27730
+ let result;
27731
+ try {
27732
+ result = {
27733
+ ok: true,
27734
+ parsed: parseExpression(source)
27735
+ };
27736
+ } catch (err) {
27737
+ result = {
27738
+ ok: false,
27739
+ error: err instanceof ExpressionParseError ? err.message : String(err)
27740
+ };
27573
27741
  }
27574
- /**
27575
- * Kernel-injected callback that runs the full post-probe orchestration
27576
- * (onProbe → registerDevice meta refresh → accessory reconciliation).
27577
- * Set by `device-cap-proxy.register()`. Drivers should not touch this
27578
- * directly — call `reprobe()` instead.
27579
- */
27580
- _kernelReprobe;
27581
- /**
27582
- * Declare accessory child devices the kernel should auto-spawn
27583
- * after `onProbe()` resolves. Each spec fully describes one child
27584
- * — stableId suffix (deterministic per kind for restore-safety),
27585
- * meta (type / name / location), config (initial blob the child
27586
- * self-hydrates), and a factory that constructs the concrete
27587
- * class with whatever closure-captured refs it needs (typically
27588
- * `this` for the parent reference).
27589
- *
27590
- * The kernel handles the rest: allocateDeviceId, persistInitialConfig
27591
- * (skipped on restore when the row already exists),
27592
- * persistInitialMeta, createContext, factory invocation, register,
27593
- * and recursive lifecycle (probe + accessories + activate).
27594
- *
27595
- * Implementations should derive children from
27596
- * `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
27597
- * Drivers can use the `getProbeFlags()` helper to read the flag bag
27598
- * with a typed cast.
27599
- *
27600
- * Default: no children.
27601
- */
27602
- getAccessoryChildren() {
27603
- return [];
27742
+ cache.set(source, result);
27743
+ if (cache.size > 256) {
27744
+ const oldest = cache.keys().next().value;
27745
+ if (oldest !== void 0) cache.delete(oldest);
27604
27746
  }
27605
- /**
27606
- * Read the current feature-probe flag bag with a typed cast. Helper
27607
- * for `getAccessoryChildren()` and `features` getters that derive
27608
- * outputs from the probe results.
27609
- */
27610
- getProbeFlags() {
27611
- return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
27747
+ return result;
27748
+ }
27749
+ /** Compile `source`, returning a discriminated result instead of throwing.
27750
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
27751
+ function compileExpressionSafe(source) {
27752
+ return getCached(source);
27753
+ }
27754
+ Object.freeze({});
27755
+ /**
27756
+ * Author-time validation. Returns `null` when the source is valid, else a
27757
+ * human-readable error message. Checks: the expression compiles; binding count
27758
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
27759
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
27760
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
27761
+ */
27762
+ function validateExpressionSource(src) {
27763
+ const names = Object.keys(src.bindings);
27764
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
27765
+ for (const name of names) {
27766
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
27767
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
27768
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
27612
27769
  }
27613
- /**
27614
- * Returns true once `onProbe` has completed at least once
27615
- * (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
27616
- * to avoid spawning stale accessories on a fresh device whose probe
27617
- * hasn't landed yet.
27618
- */
27619
- hasProbed() {
27620
- return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
27770
+ const compiled = compileExpressionSafe(src.expr);
27771
+ if (!compiled.ok) return compiled.error;
27772
+ const bound = new Set(names);
27773
+ for (const id of compiled.parsed.identifiers) {
27774
+ if (id === "now") continue;
27775
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
27621
27776
  }
27622
- };
27623
- 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;
27624
- new Set(Object.values(DeviceType));
27625
- DeviceFeature.BatteryOperated;
27777
+ return null;
27778
+ }
27779
+ var ExpressionBindingSourceSchema = union([
27780
+ object({
27781
+ kind: literal("field").optional(),
27782
+ sourceKey: string(),
27783
+ cap: string(),
27784
+ fieldPath: string()
27785
+ }),
27786
+ object({
27787
+ kind: literal("literal"),
27788
+ value: union([
27789
+ string(),
27790
+ number(),
27791
+ boolean(),
27792
+ _null()
27793
+ ])
27794
+ }),
27795
+ object({
27796
+ kind: literal("global"),
27797
+ sourceStableId: string(),
27798
+ cap: string(),
27799
+ fieldPath: string()
27800
+ })
27801
+ ]);
27802
+ object({
27803
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
27804
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
27805
+ }).superRefine((src, ctx) => {
27806
+ const err = validateExpressionSource(src);
27807
+ if (err !== null) ctx.addIssue({
27808
+ code: "custom",
27809
+ message: err,
27810
+ path: ["expr"]
27811
+ });
27812
+ });
27626
27813
  Object.freeze({
27627
27814
  "accessories.setChildHidden": {
27628
27815
  capName: "accessories",
@@ -28446,6 +28633,12 @@ Object.freeze({
28446
28633
  addonId: null,
28447
28634
  access: "view"
28448
28635
  },
28636
+ "coreBlocks.restart": {
28637
+ capName: "core-blocks",
28638
+ capScope: "system",
28639
+ addonId: null,
28640
+ access: "create"
28641
+ },
28449
28642
  "coreBlocks.setEnabled": {
28450
28643
  capName: "core-blocks",
28451
28644
  capScope: "system",
@@ -29082,12 +29275,6 @@ Object.freeze({
29082
29275
  addonId: null,
29083
29276
  access: "create"
29084
29277
  },
29085
- "deviceManager.setDeviceLinks": {
29086
- capName: "device-manager",
29087
- capScope: "system",
29088
- addonId: null,
29089
- access: "create"
29090
- },
29091
29278
  "deviceManager.setDisabled": {
29092
29279
  capName: "device-manager",
29093
29280
  capScope: "system",
@@ -33185,51 +33372,6 @@ var TimelapseRuleSchema = TimelapseRuleInputSchema.extend({
33185
33372
  createdAt: number(),
33186
33373
  updatedAt: number()
33187
33374
  });
33188
- /** Cosine similarity between two embedding vectors */
33189
- function cosineSimilarity(a, b) {
33190
- if (a.length !== b.length) return 0;
33191
- let dotProduct = 0;
33192
- let normA = 0;
33193
- let normB = 0;
33194
- for (let i = 0; i < a.length; i++) {
33195
- dotProduct += a[i] * b[i];
33196
- normA += a[i] * a[i];
33197
- normB += b[i] * b[i];
33198
- }
33199
- const denom = Math.sqrt(normA) * Math.sqrt(normB);
33200
- return denom === 0 ? 0 : dotProduct / denom;
33201
- }
33202
- function hfModelUrl(repo, path) {
33203
- return `https://huggingface.co/${repo}/resolve/main/${path}`;
33204
- }
33205
- /**
33206
- * Vector wire codec — base64 of little-endian Float32.
33207
- *
33208
- * The `vector-store` capability carries vectors as base64 rather than
33209
- * `number[]` or a typed array, and both rejected alternatives have a scar here:
33210
- *
33211
- * - a `Float32Array` does NOT survive msgpack across the UDS transport (it
33212
- * arrived as an empty object and froze audio dBFS until the payload was
33213
- * changed to carry bytes);
33214
- * - `number[]` is the ~5x-larger encoding the capability exists to stop paying
33215
- * — a 512-dim vector is 2,048 bytes raw and 2,732 as base64, against roughly
33216
- * 10-12 KB rendered as JSON text.
33217
- *
33218
- * Endianness is pinned to little-endian explicitly instead of inheriting the
33219
- * platform's, so a vector written on one node decodes correctly on another.
33220
- */
33221
- /** Encode a vector as base64 of little-endian Float32. */
33222
- function encodeVectorBase64(vector) {
33223
- const floats = vector instanceof Float32Array ? vector : Float32Array.from(vector);
33224
- const bytes = new Uint8Array(floats.length * 4);
33225
- const view = new DataView(bytes.buffer);
33226
- for (let i = 0; i < floats.length; i += 1) view.setFloat32(i * 4, floats[i] ?? 0, true);
33227
- return Buffer.from(bytes).toString("base64");
33228
- }
33229
- /** Vector length implied by a base64 payload, without decoding it. */
33230
- function vectorDimFromBase64(encoded) {
33231
- return Math.floor(Buffer.from(encoded, "base64").byteLength / 4);
33232
- }
33233
33375
  object({
33234
33376
  /**
33235
33377
  * Fraction of the box's own size added on EACH side before cutting.
@@ -33336,6 +33478,51 @@ DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
33336
33478
  DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
33337
33479
  DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
33338
33480
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
33481
+ /** Cosine similarity between two embedding vectors */
33482
+ function cosineSimilarity(a, b) {
33483
+ if (a.length !== b.length) return 0;
33484
+ let dotProduct = 0;
33485
+ let normA = 0;
33486
+ let normB = 0;
33487
+ for (let i = 0; i < a.length; i++) {
33488
+ dotProduct += a[i] * b[i];
33489
+ normA += a[i] * a[i];
33490
+ normB += b[i] * b[i];
33491
+ }
33492
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
33493
+ return denom === 0 ? 0 : dotProduct / denom;
33494
+ }
33495
+ function hfModelUrl(repo, path) {
33496
+ return `https://huggingface.co/${repo}/resolve/main/${path}`;
33497
+ }
33498
+ /**
33499
+ * Vector wire codec — base64 of little-endian Float32.
33500
+ *
33501
+ * The `vector-store` capability carries vectors as base64 rather than
33502
+ * `number[]` or a typed array, and both rejected alternatives have a scar here:
33503
+ *
33504
+ * - a `Float32Array` does NOT survive msgpack across the UDS transport (it
33505
+ * arrived as an empty object and froze audio dBFS until the payload was
33506
+ * changed to carry bytes);
33507
+ * - `number[]` is the ~5x-larger encoding the capability exists to stop paying
33508
+ * — a 512-dim vector is 2,048 bytes raw and 2,732 as base64, against roughly
33509
+ * 10-12 KB rendered as JSON text.
33510
+ *
33511
+ * Endianness is pinned to little-endian explicitly instead of inheriting the
33512
+ * platform's, so a vector written on one node decodes correctly on another.
33513
+ */
33514
+ /** Encode a vector as base64 of little-endian Float32. */
33515
+ function encodeVectorBase64(vector) {
33516
+ const floats = vector instanceof Float32Array ? vector : Float32Array.from(vector);
33517
+ const bytes = new Uint8Array(floats.length * 4);
33518
+ const view = new DataView(bytes.buffer);
33519
+ for (let i = 0; i < floats.length; i += 1) view.setFloat32(i * 4, floats[i] ?? 0, true);
33520
+ return Buffer.from(bytes).toString("base64");
33521
+ }
33522
+ /** Vector length implied by a base64 payload, without decoding it. */
33523
+ function vectorDimFromBase64(encoded) {
33524
+ return Math.floor(Buffer.from(encoded, "base64").byteLength / 4);
33525
+ }
33339
33526
  //#endregion
33340
33527
  Object.defineProperty(exports, "BaseAddon", {
33341
33528
  enumerable: true,
@@ -33355,6 +33542,12 @@ Object.defineProperty(exports, "DEFAULT_EVENT_COLOR", {
33355
33542
  return DEFAULT_EVENT_COLOR;
33356
33543
  }
33357
33544
  });
33545
+ Object.defineProperty(exports, "DeclaredDevices", {
33546
+ enumerable: true,
33547
+ get: function() {
33548
+ return DeclaredDevices;
33549
+ }
33550
+ });
33358
33551
  Object.defineProperty(exports, "DeviceType", {
33359
33552
  enumerable: true,
33360
33553
  get: function() {