@camstack/addon-post-analysis 1.2.52 → 1.2.54

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(),
@@ -21608,7 +20953,29 @@ var FaceInfoSchema = object({
21608
20953
  recognizedIdentityId: string().optional(),
21609
20954
  identityName: string().optional(),
21610
20955
  assigned: boolean(),
20956
+ /**
20957
+ * The crop, inline, base64.
20958
+ *
20959
+ * **Prefer {@link cropUrl}.** At the 500 rows the Faces view asks for this
20960
+ * field alone is ~2.87 MiB, re-sent in full on every operator assign and
20961
+ * every 30 s poll, base64-inflated over the msgpack socket and held in the
20962
+ * query heap. It stays for callers that have not migrated; `includeCrops:
20963
+ * false` turns it off once they have.
20964
+ */
21611
20965
  base64: string().optional(),
20966
+ /**
20967
+ * Same crop, as a data-plane URL for `<img src>` — the move the admin
20968
+ * snapshot surfaces made on 2026-08-08.
20969
+ *
20970
+ * Served by the `event-media` plane, which resolves a raw MediaStore key and
20971
+ * is `access: 'authenticated'`: a bare `<img>` carries the `camstack_session`
20972
+ * cookie, so no header plumbing is needed. The bytes then ride the browser's
20973
+ * HTTP cache with an ETag and `immutable`, instead of the WebSocket.
20974
+ *
20975
+ * Absent when the face has no stored crop, or when the addon has no data
20976
+ * plane — callers fall back to {@link base64}.
20977
+ */
20978
+ cropUrl: string().optional(),
21612
20979
  /** Design B: the face bbox (pixel space) on the key frame — lets a detail
21613
20980
  * view draw the box over the native `keyFrameMediaKey` frame. Absent on
21614
20981
  * legacy rows written before design B. */
@@ -21684,7 +21051,23 @@ var faceGalleryCapability = {
21684
21051
  }),
21685
21052
  listRecentFaces: method(object({
21686
21053
  limit: number().int().positive().optional(),
21687
- filter: FaceFilterEnum.optional()
21054
+ filter: FaceFilterEnum.optional(),
21055
+ /**
21056
+ * Inline the base64 crop on every row. Default `true` — the existing
21057
+ * behaviour, kept so no caller breaks.
21058
+ *
21059
+ * Set `false` once the caller renders {@link FaceInfo.cropUrl}: that
21060
+ * drops ~2.87 MiB per 500-row page to a few KiB of metadata and lets
21061
+ * the browser cache the images.
21062
+ *
21063
+ * **This is an INPUT field, so it does not reach the addon until the
21064
+ * next train.** The hub router validates cap inputs against its own
21065
+ * compiled Zod, which strips a key it does not know — verified today
21066
+ * on the OUTPUT side, where an additive field DOES arrive immediately
21067
+ * (`Track.hasFace`). Until the train ships, sending `false` is
21068
+ * harmless and simply keeps the crops inline.
21069
+ */
21070
+ includeCrops: boolean().optional()
21688
21071
  }).optional(), array(FaceInfoSchema).readonly()),
21689
21072
  getFaceByTrack: method(object({
21690
21073
  deviceId: number().int(),
@@ -26131,13 +25514,18 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
26131
25514
  username: string(),
26132
25515
  scopes: array(TokenScopeSchema),
26133
25516
  redirectUri: string(),
26134
- hubUrl: string()
25517
+ hubUrl: string(),
25518
+ /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
25519
+ * that carries one can ONLY be exchanged with the matching verifier. */
25520
+ codeChallenge: string().optional()
26135
25521
  }), object({ code: string() }), {
26136
25522
  kind: "mutation",
26137
25523
  access: "create"
26138
25524
  }), method(object({
26139
25525
  code: string(),
26140
- redirectUri: string()
25526
+ redirectUri: string(),
25527
+ /** PKCE verifier. REQUIRED when the code carries a challenge. */
25528
+ codeVerifier: string().optional()
26141
25529
  }), object({
26142
25530
  accessToken: string(),
26143
25531
  refreshToken: string(),
@@ -26616,1013 +26004,1850 @@ var HistoryPointSchema = object({
26616
26004
  * outside-any-zone). Crossing them with `className?` gives the full
26617
26005
  * combinatorial coverage the operator UI requested.
26618
26006
  */
26619
- var zoneAnalyticsCapability = {
26620
- name: "zone-analytics",
26007
+ var zoneAnalyticsCapability = {
26008
+ name: "zone-analytics",
26009
+ scope: "device",
26010
+ mode: "singleton",
26011
+ deviceTypes: [DeviceType.Camera],
26012
+ methods: {
26013
+ /** Latest computed occupancy snapshot for this camera. Null when
26014
+ * the analytics pipeline hasn't seen a frame for this device yet
26015
+ * (no inference result emitted since boot or since binding was
26016
+ * activated). */
26017
+ getCurrentSnapshot: method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()),
26018
+ /** Time-series object count inside one zone. `className` optional —
26019
+ * omit to count every class in the zone. */
26020
+ getZoneHistory: method(object({
26021
+ deviceId: number(),
26022
+ zoneId: string(),
26023
+ className: string().optional()
26024
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()),
26025
+ /** Time-series frame-wide object count (everywhere). */
26026
+ getCameraHistory: method(object({
26027
+ deviceId: number(),
26028
+ className: string().optional()
26029
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()),
26030
+ /** Time-series count of objects outside every zone. */
26031
+ getUnzonedHistory: method(object({
26032
+ deviceId: number(),
26033
+ className: string().optional()
26034
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly())
26035
+ },
26036
+ /**
26037
+ * Runtime-state slice — the latest occupancy snapshot mirrored by
26038
+ * the analytics frame processor on every inference result. Consumers
26039
+ * read via `device.state.zoneAnalytics.value` and stay in sync
26040
+ * automatically; the explicit `getCurrentSnapshot` cap method is
26041
+ * still useful for one-off polls without a subscription.
26042
+ */
26043
+ runtimeState: CameraOccupancySnapshotSchema
26044
+ };
26045
+ /**
26046
+ * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
26047
+ * cap so a single CRUD surface backs every consumer; each stage has
26048
+ * its own dev-state mirror slice (`motion-zone-rules`,
26049
+ * `detection-zone-rules`, …) so consumer addons subscribe independently.
26050
+ *
26051
+ * Extend the enum here when a new gating consumer comes online (audio
26052
+ * gating, alert filtering, …) — no other surface needs to change.
26053
+ */
26054
+ var ZoneRuleStageEnum = _enum([
26055
+ "motion",
26056
+ "detection",
26057
+ "package"
26058
+ ]);
26059
+ /**
26060
+ * Zone rules capability — per-camera CRUD over the {@link ZoneRule}
26061
+ * arrays that decide how each pipeline stage uses the polygon zones.
26062
+ *
26063
+ * Hosted by `addon-pipeline-orchestrator` alongside the zones provider
26064
+ * so the operator has a single hub-side source of truth for both
26065
+ * geometry and behaviour. Per-stage rules are stored under the
26066
+ * `zoneRules.<stage>` key in the orchestrator's per-device store and
26067
+ * mirrored to the device-state slice `<stage>-zone-rules` on every
26068
+ * mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
26069
+ * subscribe to that slice and refresh their gating without
26070
+ * round-tripping the cap.
26071
+ *
26072
+ * Sets are bulk-replace — the operator UI sends the new rule list
26073
+ * wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
26074
+ * naturally in the rule editor without per-rule mutation chatter.
26075
+ */
26076
+ var zoneRulesCapability = {
26077
+ name: "zone-rules",
26621
26078
  scope: "device",
26622
26079
  mode: "singleton",
26623
26080
  deviceTypes: [DeviceType.Camera],
26624
26081
  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({
26082
+ /** Read the full rule list for a given stage (empty when no rules
26083
+ * are defined yet). */
26084
+ listRules: method(object({
26639
26085
  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({
26086
+ stage: ZoneRuleStageEnum
26087
+ }), array(ZoneRuleSchema).readonly()),
26088
+ /** Bulk-replace the rule list for one stage. The provider validates
26089
+ * each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
26090
+ * thresholds in range) and rejects the whole patch if any entry
26091
+ * is invalid — partial writes are a configuration footgun. */
26092
+ setRules: method(object({
26644
26093
  deviceId: number(),
26645
- className: string().optional()
26646
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly())
26094
+ stage: ZoneRuleStageEnum,
26095
+ rules: array(ZoneRuleSchema).readonly()
26096
+ }), _void(), {
26097
+ kind: "mutation",
26098
+ auth: "admin"
26099
+ })
26647
26100
  },
26648
26101
  /**
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.
26102
+ * Runtime-state slice — every stage mirrored together so consumers
26103
+ * see one reactive handle (`device.state.zoneRules.value`) instead
26104
+ * of one per stage. Bulk-replace mutations on any stage write the full
26105
+ * `{motion, detection, package}` shape, so subscribers always get the
26106
+ * complete current set. Consumers that only care about one stage
26107
+ * just read the matching property.
26108
+ *
26109
+ * `package` backs the package-drop detector — a package zone is a
26110
+ * `ZoneRule` on the `'package'` stage referencing drawn polygons
26111
+ * (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
26112
+ * The orchestrator provider writes this stage as a first-class slice
26113
+ * (Phase 4): every mutation mirrors the full `{motion, detection,
26114
+ * package}` shape, so consumers read the current package rules directly
26115
+ * off `device.state.zoneRules.value.package`.
26654
26116
  */
26655
- runtimeState: CameraOccupancySnapshotSchema
26117
+ runtimeState: object({
26118
+ motion: array(ZoneRuleSchema).readonly(),
26119
+ detection: array(ZoneRuleSchema).readonly(),
26120
+ package: array(ZoneRuleSchema).readonly()
26121
+ })
26122
+ };
26123
+ /**
26124
+ * Most specific first. Extending this list is how a new device kind becomes
26125
+ * gateable; nothing else needs to change.
26126
+ */
26127
+ var DEVICE_STATE_READERS = [
26128
+ {
26129
+ cap: "alarm-panel",
26130
+ field: "state"
26131
+ },
26132
+ {
26133
+ cap: "cover",
26134
+ field: "state"
26135
+ },
26136
+ {
26137
+ cap: "presence",
26138
+ field: "state"
26139
+ },
26140
+ {
26141
+ cap: "lock",
26142
+ field: "locked",
26143
+ booleanWords: ["locked", "unlocked"]
26144
+ },
26145
+ {
26146
+ cap: "contact",
26147
+ field: "entryOpen",
26148
+ booleanWords: ["open", "closed"]
26149
+ },
26150
+ {
26151
+ cap: "switch",
26152
+ field: "on",
26153
+ booleanWords: ["on", "off"]
26154
+ },
26155
+ {
26156
+ cap: "binary",
26157
+ field: "on",
26158
+ booleanWords: ["on", "off"]
26159
+ }
26160
+ ];
26161
+ /**
26162
+ * Collapse a device's full runtime state to the one string a rule compares
26163
+ * against, or `undefined` when nothing in the table applies.
26164
+ *
26165
+ * `undefined` is the safe answer everywhere: the gate treats it as "does not
26166
+ * match", so a device whose kind we cannot read simply never arms a rule.
26167
+ */
26168
+ function readDeviceStateFrom(runtimeState) {
26169
+ for (const reader of DEVICE_STATE_READERS) {
26170
+ const slice = runtimeState[reader.cap];
26171
+ if (slice === null || typeof slice !== "object") continue;
26172
+ const value = slice[reader.field];
26173
+ if (typeof value === "string" && value.length > 0) return value;
26174
+ if (typeof value === "boolean" && reader.booleanWords !== void 0) return value ? reader.booleanWords[0] : reader.booleanWords[1];
26175
+ }
26176
+ }
26177
+ /**
26178
+ * Accessory device helpers — shared across drivers.
26179
+ *
26180
+ * Many vendor-specific drivers register accessory child devices on
26181
+ * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
26182
+ * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
26183
+ * driver picks the right `DeviceType` + `DeviceRole` explicitly when
26184
+ * spawning, builds a name derived from the parent, and produces a
26185
+ * stableId tied to the parent so boot-restore can reconstruct the
26186
+ * relationship.
26187
+ *
26188
+ * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
26189
+ * drivers may reasonably disagree on the right type for an accessory
26190
+ * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
26191
+ * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
26192
+ * one canonical mapping was over-prescriptive and added a layer of
26193
+ * indirection without saving meaningful code at call sites — the
26194
+ * driver knows its own hardware best.
26195
+ */
26196
+ /**
26197
+ * Subset of `DeviceRole` values that drivers register as child
26198
+ * accessories of a parent device. Sourced verbatim from `DeviceRole`
26199
+ * — `AccessoryKind` is the alias drivers use when building accessory
26200
+ * children, so the call site reads as
26201
+ * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
26202
+ * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
26203
+ * any role works, including non-accessory ones like Doorbell).
26204
+ */
26205
+ var AccessoryKind = {
26206
+ Siren: DeviceRole.Siren,
26207
+ Floodlight: DeviceRole.Floodlight,
26208
+ Spotlight: DeviceRole.Spotlight,
26209
+ PirSensor: DeviceRole.PirSensor,
26210
+ Chime: DeviceRole.Chime,
26211
+ Autotrack: DeviceRole.Autotrack,
26212
+ Nightvision: DeviceRole.Nightvision,
26213
+ PrivacyMask: DeviceRole.PrivacyMask
26214
+ };
26215
+ AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
26216
+ var DeviceConfig = class DeviceConfig {
26217
+ schema;
26218
+ data;
26219
+ persistFn;
26220
+ constructor(schema, data, persist) {
26221
+ this.schema = schema;
26222
+ this.data = data;
26223
+ this.persistFn = persist;
26224
+ }
26225
+ /**
26226
+ * Build a `DeviceConfig` from a persisted blob, with automatic
26227
+ * recovery from schema-validation failures. Boot must never be
26228
+ * blocked by stale persisted values: if Zod rejects the blob,
26229
+ * we drop every offending top-level field, retry, and persist
26230
+ * the cleaned blob so the bad value is healed in the DB on next
26231
+ * write. The most common trigger is a tightened range constraint
26232
+ * (e.g. `max(100) → max(50)`) on a field that already has an
26233
+ * out-of-range value persisted from the previous schema. Without
26234
+ * this safety net, the device would fail to instantiate and end
26235
+ * up with no caps registered — exactly the failure mode that
26236
+ * stranded device 15 when `motionSensitivity: 90` no longer fit
26237
+ * the new `1..50` schema.
26238
+ *
26239
+ * Recovery rules:
26240
+ * 1. Try `safeParse(initialData)`. If it succeeds, done.
26241
+ * 2. On failure, walk `error.issues`, collect the top-level path
26242
+ * of each issue, and drop those keys from `initialData`.
26243
+ * 3. Re-run `safeParse`. If the cleaned blob now passes (Zod
26244
+ * fills the missing keys with schema defaults / undefined for
26245
+ * `.optional()`), persist it via `persist()` so the bad
26246
+ * values disappear from the DB, and return the device.
26247
+ * 4. If the cleaned blob STILL fails (very rare — would require
26248
+ * a non-recoverable required field), fall back to
26249
+ * `schema.parse({})` so the device still boots with pure
26250
+ * schema defaults. Persist nothing in that path so the next
26251
+ * successful `setAll` still writes a coherent blob.
26252
+ */
26253
+ static fromSchema(schema, persist, initialData = {}, onRecover) {
26254
+ const first = schema.safeParse(initialData);
26255
+ if (first.success) return new DeviceConfig(schema, first.data, persist);
26256
+ const droppedKeys = /* @__PURE__ */ new Set();
26257
+ for (const issue of first.error.issues) {
26258
+ const top = issue.path[0];
26259
+ if (typeof top === "string") droppedKeys.add(top);
26260
+ }
26261
+ const cleaned = { ...initialData };
26262
+ for (const k of droppedKeys) delete cleaned[k];
26263
+ const second = schema.safeParse(cleaned);
26264
+ onRecover?.({
26265
+ droppedKeys: [...droppedKeys],
26266
+ issues: first.error.issues
26267
+ });
26268
+ if (second.success) {
26269
+ persist(second.data).catch(() => {});
26270
+ return new DeviceConfig(schema, second.data, persist);
26271
+ }
26272
+ return new DeviceConfig(schema, schema.parse({}), persist);
26273
+ }
26274
+ get values() {
26275
+ return this.data;
26276
+ }
26277
+ get(key) {
26278
+ return this.data[key];
26279
+ }
26280
+ async set(key, value) {
26281
+ const next = this.schema.parse({
26282
+ ...this.data,
26283
+ [key]: value
26284
+ });
26285
+ this.data = next;
26286
+ await this.persistFn(this.data);
26287
+ }
26288
+ /**
26289
+ * Merge an untyped patch onto the current config and persist. Accepts
26290
+ * `Record<string, unknown>` because the patch typically comes from the
26291
+ * UI form layer (a `ConfigField.key → value` map) where the caller
26292
+ * doesn't hold the Zod schema's static type. Runtime validation is
26293
+ * authoritative: `this.schema.parse` rejects unknown keys or invalid
26294
+ * shapes before touching storage.
26295
+ */
26296
+ async setAll(partial) {
26297
+ const next = this.schema.parse({
26298
+ ...this.data,
26299
+ ...partial
26300
+ });
26301
+ this.data = next;
26302
+ await this.persistFn(this.data);
26303
+ }
26304
+ async deleteKey(key) {
26305
+ const { [key]: _, ...rest } = this.data;
26306
+ const next = this.schema.parse(rest);
26307
+ this.data = next;
26308
+ await this.persistFn(this.data);
26309
+ }
26310
+ entries() {
26311
+ const shape = this.schema.shape;
26312
+ return Object.entries(shape).map(([key, fieldSchema]) => ({
26313
+ key,
26314
+ schema: fieldSchema,
26315
+ value: this.data[key],
26316
+ description: fieldSchema.description
26317
+ }));
26318
+ }
26656
26319
  };
26657
26320
  /**
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.
26321
+ * Concrete implementation. Routes every successful write through
26322
+ * `writer(capName, slice)` — the kernel hooks this up to
26323
+ * `device-state.setCapSlice`, the canonical cross-layer write
26324
+ * entrypoint, which handles disk persistence (debounced on the hub)
26325
+ * and mirror updates.
26674
26326
  *
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.
26327
+ * Schema validation runs in-process before the writer is called —
26328
+ * the round-trip should never carry an invalid slice. `flush()`
26329
+ * awaits any in-flight writer promises so shutdown is lossless.
26683
26330
  *
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.
26331
+ * `initial` is the persisted blob loaded at boot. Slices for caps
26332
+ * whose schema hasn't been installed yet are kept in-memory verbatim
26333
+ * and validated when the cap registers later.
26687
26334
  */
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
- },
26335
+ var DeviceRuntimeState = class DeviceRuntimeState {
26336
+ writer;
26337
+ /** In-flight writer promises tracked so `flush()` can await them. */
26338
+ pendingWrites = /* @__PURE__ */ new Set();
26339
+ /** Per-cap committed slice — after schema validation when known. */
26340
+ slices;
26341
+ /** Per-cap registered schema (set by `installCapSchema`). */
26342
+ schemas = /* @__PURE__ */ new Map();
26343
+ listeners = /* @__PURE__ */ new Set();
26344
+ capListeners = /* @__PURE__ */ new Map();
26345
+ constructor(initial, writer) {
26346
+ this.writer = writer;
26347
+ this.slices = /* @__PURE__ */ new Map();
26348
+ for (const [k, v] of Object.entries(initial)) if (v && typeof v === "object" && !Array.isArray(v)) this.slices.set(k, { ...v });
26349
+ }
26350
+ static fromInitial(initial, writer) {
26351
+ return new DeviceRuntimeState(initial, writer);
26352
+ }
26353
+ installCapSchema(capName, schema) {
26354
+ const existing = this.schemas.get(capName);
26355
+ if (existing) {
26356
+ if (existing !== schema) throw new Error(`[DeviceRuntimeState] capability "${capName}" registered a different runtime-state schema; each cap must declare ONE shape across every provider`);
26357
+ return;
26358
+ }
26359
+ this.schemas.set(capName, schema);
26360
+ const stored = this.slices.get(capName);
26361
+ if (stored) {
26362
+ const result = schema.safeParse(stored);
26363
+ if (result.success) this.slices.set(capName, result.data);
26364
+ else this.slices.delete(capName);
26365
+ }
26366
+ }
26367
+ getCapState(capName) {
26368
+ const slice = this.slices.get(capName);
26369
+ if (!slice) return void 0;
26370
+ return Object.freeze({ ...slice });
26371
+ }
26372
+ getCapField(capName, key) {
26373
+ return this.slices.get(capName)?.[key];
26374
+ }
26375
+ setCapState(capName, value) {
26376
+ this.applyCapWrite(capName, value, false);
26377
+ }
26378
+ patchCapState(capName, partial) {
26379
+ this.applyCapWrite(capName, partial, true);
26380
+ }
26713
26381
  /**
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`.
26382
+ * Internal worker. `merge` controls whether `value` replaces or
26383
+ * shallow-merges into the existing slice. Schema validation runs
26384
+ * on the FINAL composed object regardless.
26728
26385
  */
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"]
26386
+ applyCapWrite(capName, value, merge) {
26387
+ const schema = this.schemas.get(capName);
26388
+ if (!schema) throw new Error(`[DeviceRuntimeState] no schema registered for cap "${capName}" — did the device register it via ctx.registerNativeCap before writing?`);
26389
+ const current = this.slices.get(capName) ?? {};
26390
+ const next = merge ? {
26391
+ ...current,
26392
+ ...value
26393
+ } : { ...value };
26394
+ const parsed = schema.parse(next);
26395
+ if (shallowEqual(current, parsed)) return;
26396
+ this.slices.set(capName, parsed);
26397
+ this.fireListeners([capName]);
26398
+ const writePromise = this.writer(capName, { ...parsed }).catch(() => {});
26399
+ this.pendingWrites.add(writePromise);
26400
+ writePromise.finally(() => {
26401
+ this.pendingWrites.delete(writePromise);
26402
+ });
26771
26403
  }
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];
26404
+ fireListeners(changed) {
26405
+ const snap = this.snapshot();
26406
+ for (const cb of this.listeners) try {
26407
+ cb(changed, snap);
26408
+ } catch {}
26409
+ for (const capName of changed) {
26410
+ const subs = this.capListeners.get(capName);
26411
+ if (!subs) continue;
26412
+ const slice = this.getCapState(capName);
26413
+ for (const cb of subs) try {
26414
+ cb(slice);
26415
+ } catch {}
26416
+ }
26417
+ }
26418
+ subscribe(cb) {
26419
+ this.listeners.add(cb);
26420
+ return () => {
26421
+ this.listeners.delete(cb);
26422
+ };
26423
+ }
26424
+ subscribeCap(capName, cb) {
26425
+ let subs = this.capListeners.get(capName);
26426
+ if (!subs) {
26427
+ subs = /* @__PURE__ */ new Set();
26428
+ this.capListeners.set(capName, subs);
26429
+ }
26430
+ const adapter = (slice) => {
26431
+ cb(slice);
26432
+ };
26433
+ subs.add(adapter);
26434
+ return () => {
26435
+ const set = this.capListeners.get(capName);
26436
+ if (!set) return;
26437
+ set.delete(adapter);
26438
+ if (set.size === 0) this.capListeners.delete(capName);
26439
+ };
26440
+ }
26441
+ snapshot() {
26442
+ const out = {};
26443
+ for (const [k, v] of this.slices) out[k] = Object.freeze({ ...v });
26444
+ return Object.freeze(out);
26787
26445
  }
26446
+ async flush() {
26447
+ if (this.pendingWrites.size === 0) return;
26448
+ const inflight = [...this.pendingWrites];
26449
+ await Promise.allSettled(inflight);
26450
+ }
26451
+ };
26452
+ function shallowEqual(a, b) {
26453
+ const ak = Object.keys(a);
26454
+ const bk = Object.keys(b);
26455
+ if (ak.length !== bk.length) return false;
26456
+ for (const k of ak) if (a[k] !== b[k]) return false;
26457
+ return true;
26788
26458
  }
26789
26459
  /**
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).
26460
+ * Runtime registry: cap-property-name → cap definition. `BaseDevice`'s
26461
+ * `state` getter looks up the cap definition here to construct a
26462
+ * `sliceProxy()` lazily on first access. Generated alongside the type
26463
+ * so type and runtime registry can never drift apart.
26816
26464
  */
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
26465
+ var DEVICE_LOCAL_STATE_CAPS = {
26466
+ airQualitySensor: airQualitySensorCapability,
26467
+ alarmPanel: alarmPanelCapability,
26468
+ ambientLightSensor: ambientLightSensorCapability,
26469
+ audioMetrics: audioMetricsCapability,
26470
+ automationControl: automationControlCapability,
26471
+ battery: batteryCapability,
26472
+ binary: binaryCapability,
26473
+ brightness: brightnessCapability,
26474
+ cameraStreams: cameraStreamsCapability,
26475
+ carbonMonoxide: carbonMonoxideCapability,
26476
+ climateControl: climateControlCapability,
26477
+ color: colorCapability,
26478
+ connectivity: connectivityCapability,
26479
+ consumables: consumablesCapability,
26480
+ contact: contactCapability,
26481
+ control: controlCapability,
26482
+ cover: coverCapability,
26483
+ dayNight: dayNightCapability,
26484
+ deviceDiscovery: deviceDiscoveryCapability,
26485
+ deviceStatus: deviceStatusCapability,
26486
+ doorbell: doorbellCapability,
26487
+ enumSensor: enumSensorCapability,
26488
+ eventEmitter: eventEmitterCapability,
26489
+ fanControl: fanControlCapability,
26490
+ featureProbe: featureProbeCapability,
26491
+ flood: floodCapability,
26492
+ gas: gasCapability,
26493
+ humidifier: humidifierCapability,
26494
+ humiditySensor: humiditySensorCapability,
26495
+ image: imageCapability,
26496
+ imageSettings: imageSettingsCapability,
26497
+ lawnMowerControl: lawnMowerControlCapability,
26498
+ lockControl: lockControlCapability,
26499
+ mediaPlayer: mediaPlayerCapability,
26500
+ motion: motionCapability,
26501
+ motionTrigger: motionTriggerCapability,
26502
+ motionZones: motionZonesCapability,
26503
+ nativeObjectDetection: nativeObjectDetectionCapability,
26504
+ notifier: notifierCapability,
26505
+ numericSensor: numericSensorCapability,
26506
+ petFeeder: petFeederCapability,
26507
+ powerMeter: powerMeterCapability,
26508
+ presence: presenceCapability,
26509
+ pressureSensor: pressureSensorCapability,
26510
+ privacyMask: privacyMaskCapability,
26511
+ ptzAutotrack: ptzAutotrackCapability,
26512
+ sceneMonitor: sceneMonitorCapability,
26513
+ scriptRunner: scriptRunnerCapability,
26514
+ smoke: smokeCapability,
26515
+ streamParams: streamParamsCapability,
26516
+ switch: switchCapability,
26517
+ tamper: tamperCapability,
26518
+ temperatureSensor: temperatureSensorCapability,
26519
+ update: updateCapability,
26520
+ vacuumControl: vacuumControlCapability,
26521
+ valve: valveCapability,
26522
+ vibration: vibrationCapability,
26523
+ waterHeater: waterHeaterCapability,
26524
+ weather: weatherCapability,
26525
+ zoneAnalytics: zoneAnalyticsCapability,
26526
+ zoneRules: zoneRulesCapability,
26527
+ zones: zonesCapability
26826
26528
  };
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;
26529
+ var BaseDevice = class {
26530
+ id;
26531
+ stableId;
26532
+ type;
26533
+ name;
26534
+ parentDeviceId;
26535
+ role;
26536
+ /**
26537
+ * Cap-keyed runtime-state slice is the single source of truth for
26538
+ * `online`. Both getter and setter proxy to the slice — drivers can
26539
+ * write `this.online = true` ergonomically, and the cap event fires
26540
+ * automatically through the runtime-state writer. `markOnline()` is
26541
+ * kept as the explicit method form mandated by `IDevice`.
26542
+ */
26543
+ get online() {
26544
+ return this.runtimeState.getCapState("device-status")?.online ?? false;
26545
+ }
26546
+ set online(value) {
26547
+ this.markOnline(value);
26836
26548
  }
26837
26549
  /**
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.
26550
+ * Generic per-cap runtime-state namespace. One entry per cap with
26551
+ * `runtimeState:` declared, auto-generated by codegen — see
26552
+ * `device-local-state.ts`. Drivers access via:
26850
26553
  *
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.
26554
+ * `this.state.battery.sleeping = true` // patches the battery slice
26555
+ * `const pct = this.state.battery.percentage` // reads the battery slice
26556
+ * `this.state.deviceStatus.online = true` // mirrors `markOnline(true)`
26557
+ *
26558
+ * Adding a new cap with `runtimeState:` automatically extends this
26559
+ * namespace — drivers don't have to declare proxies. Reads return
26560
+ * `undefined` when the slice hasn't been seeded; writes patch via
26561
+ * `runtimeState.patchCapState` and validate against the cap's schema
26562
+ * (so partial writes need the slice to be seeded with the required
26563
+ * fields first — drivers do this on cap registration).
26564
+ *
26565
+ * For caps not exposed in `DeviceLocalState`, drivers can build their
26566
+ * own typed proxy via `this.sliceProxy(cap)`.
26864
26567
  */
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);
26568
+ get state() {
26569
+ if (!this._stateProxyCache) {
26570
+ const cache = {};
26571
+ const handler = { get: (_target, key) => {
26572
+ const k = key;
26573
+ if (k in cache) return cache[k];
26574
+ const cap = DEVICE_LOCAL_STATE_CAPS[k];
26575
+ if (!cap) return void 0;
26576
+ const proxy = this.sliceProxy(cap);
26577
+ cache[k] = proxy;
26578
+ return proxy;
26579
+ } };
26580
+ this._stateProxyCache = new Proxy(cache, handler);
26872
26581
  }
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
26582
+ return this._stateProxyCache;
26583
+ }
26584
+ _stateProxyCache;
26585
+ config;
26586
+ /**
26587
+ * Per-device runtime state, cap-keyed. Always installed — slices
26588
+ * for individual caps materialise as those caps register their
26589
+ * native providers (`ctx.registerNativeCap`). The cap's own
26590
+ * `runtimeState` schema is the source of truth for the slice
26591
+ * shape; drivers don't redeclare it, they just write through.
26592
+ *
26593
+ * Read: `this.runtimeState.getCapState('battery')` →
26594
+ * `{percentage, charging, sleeping, lastUpdated}` for any
26595
+ * provider that registers `batteryCapability`.
26596
+ * Write: `this.runtimeState.setCapState('battery', { … })`.
26597
+ *
26598
+ * Cross-process consumers reach this state through the
26599
+ * `deviceState` cap router (or via cap-specific events the driver
26600
+ * emits — e.g. `battery.onStatusChanged`). The local handle is
26601
+ * accessed in-process by the driver to avoid roundtrips.
26602
+ */
26603
+ runtimeState;
26604
+ ctx;
26605
+ /**
26606
+ * Operator-organisational location label (room / area / zone).
26607
+ * Read from `ctx.deviceMeta.location`; mutated via
26608
+ * `kernel.devices.setLocation(id, value)`. Free-text — providers
26609
+ * don't interpret it; the UI groups devices by this for filters
26610
+ * like "show me all cameras in Kitchen". `null` when unset.
26611
+ */
26612
+ location;
26613
+ /**
26614
+ * Soft-disabled flag. When `true`, the device class is still
26615
+ * instantiated and visible in the UI (so the operator can flip
26616
+ * back on without re-adding) but lifecycle hooks (publishToBroker,
26617
+ * alarm-stream subscribe, …) MUST be gated by the driver to skip
26618
+ * work. The `BaseDevice` enforces this by exposing the flag here;
26619
+ * it does NOT mutate cap behaviour automatically — drivers consult
26620
+ * `this.disabled` at the top of their lifecycle methods. Read from
26621
+ * `ctx.deviceMeta.disabled`; mutated via
26622
+ * `kernel.devices.setDisabled(id, value)`.
26623
+ */
26624
+ disabled;
26625
+ /**
26626
+ * Cached materialised `SourceInfo` — either the value persisted under
26627
+ * `metadata.sourceInfo` at construction time, or a synthetic
26628
+ * `{ id: stableId, system: addonId }` for providers that haven't
26629
+ * migrated yet. Lazily populated on first `sourceInfo` read so the
26630
+ * cost of Zod-parsing the meta blob is paid once per device boot.
26631
+ * Invalidated by `updateSourceInfo()` so providers see the new value
26632
+ * back through the getter without a re-fetch from the meta surface.
26633
+ */
26634
+ _sourceInfoCache = null;
26635
+ constructor(ctx, schema, options) {
26636
+ this.ctx = ctx;
26637
+ this.id = ctx.id;
26638
+ this.stableId = ctx.stableId;
26639
+ this.type = options.type;
26640
+ if (!ctx.deviceMeta) throw new Error(`BaseDevice constructor: ctx.deviceMeta is required (id=${ctx.id} stableId=${ctx.stableId})`);
26641
+ this.name = ctx.deviceMeta.name;
26642
+ this.location = ctx.deviceMeta.location;
26643
+ this.disabled = ctx.deviceMeta.disabled;
26644
+ this.role = options.role;
26645
+ this.parentDeviceId = ctx.parentDeviceId;
26646
+ const seedData = ctx.persistedConfig ?? {};
26647
+ this.config = DeviceConfig.fromSchema(schema, (data) => ctx.persistConfig(data), seedData, ({ droppedKeys, issues }) => {
26648
+ ctx.logger.warn("Device config recovery: dropping invalid persisted fields", {
26649
+ tags: {
26650
+ deviceId: ctx.id,
26651
+ stableId: ctx.stableId
26652
+ },
26653
+ meta: {
26654
+ droppedKeys: [...droppedKeys],
26655
+ firstIssue: issues[0]?.message ?? null
26656
+ }
26657
+ });
26658
+ });
26659
+ let cachedProxy = null;
26660
+ const writer = async (capName, slice) => {
26661
+ if (!cachedProxy) cachedProxy = ctx.fetchDevice(ctx.id);
26662
+ await (await cachedProxy).deviceState.setCapSlice({
26663
+ capName,
26664
+ slice
26665
+ });
26666
+ };
26667
+ const initial = ctx.initialRuntimeState ?? {};
26668
+ this.runtimeState = DeviceRuntimeState.fromInitial(initial, writer);
26669
+ ctx.bindRuntimeState?.(this.runtimeState);
26670
+ ctx.registerNativeCap?.(deviceStatusCapability, {});
26671
+ const seed = {
26672
+ online: true,
26673
+ lastChangedAt: Date.now()
26674
+ };
26675
+ this.runtimeState.setCapState("device-status", seed);
26676
+ ctx.registerNativeCap?.(featureProbeCapability, {});
26677
+ this.runtimeState.setCapState("feature-probe", {
26678
+ flags: {},
26679
+ deviceType: null,
26680
+ model: null,
26681
+ channelCount: null,
26682
+ lastProbedAt: 0,
26683
+ lastFetchedAt: 0
26879
26684
  });
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
26685
  }
26886
- get values() {
26887
- return this.data;
26686
+ deviceActions = /* @__PURE__ */ new Map();
26687
+ /** Declare a device custom action + its typed handler. Idempotent per name. */
26688
+ registerDeviceAction(name, spec, handler) {
26689
+ this.deviceActions.set(name, {
26690
+ spec,
26691
+ handler
26692
+ });
26888
26693
  }
26889
- get(key) {
26890
- return this.data[key];
26694
+ /** Invoke a registered device action. Validates input against the spec. */
26695
+ async runDeviceAction(action, input) {
26696
+ const entry = this.deviceActions.get(action);
26697
+ if (!entry) throw new Error(`unknown device action "${action}" on device ${this.id}`);
26698
+ const parsed = entry.spec.input.parse(input);
26699
+ return entry.handler(parsed);
26891
26700
  }
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);
26701
+ async removeDevice() {}
26702
+ /**
26703
+ * Set the device's online flag. Called by `BaseDeviceProvider` after
26704
+ * aggregating per-profile stream-broker health, or directly by drivers
26705
+ * that have provider-side liveness signals (e.g. ONVIF heartbeats,
26706
+ * Reolink Baichuan firmware push events). Mirrors the new value into
26707
+ * the `device-status` runtime-state slice so cross-process consumers
26708
+ * pick it up via the standard cap-state channel. Subclasses can
26709
+ * override to gate side effects on the transition.
26710
+ */
26711
+ markOnline(online) {
26712
+ if (this.online === online) return;
26713
+ const next = {
26714
+ online,
26715
+ lastChangedAt: Date.now()
26716
+ };
26717
+ this.runtimeState.setCapState("device-status", next);
26899
26718
  }
26900
26719
  /**
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.
26720
+ * Upstream-system identity + rendering envelope for this device. See
26721
+ * `SourceInfo` for the field contract. Always returns a valid object:
26722
+ * if the persisted `metadata.sourceInfo` blob is absent or fails Zod
26723
+ * validation, falls back to a synthetic `{ id: stableId, system: addonId }`
26724
+ * so providers that haven't migrated keep working without code changes.
26725
+ *
26726
+ * The value is cached after the first read. `updateSourceInfo()`
26727
+ * invalidates the cache so subsequent reads see the new patch. The
26728
+ * returned object is frozen to prevent accidental in-place mutation —
26729
+ * use `updateSourceInfo({ patch })` to change fields.
26907
26730
  */
26908
- async setAll(partial) {
26909
- const next = this.schema.parse({
26910
- ...this.data,
26911
- ...partial
26731
+ get sourceInfo() {
26732
+ if (this._sourceInfoCache) return this._sourceInfoCache;
26733
+ const resolved = extractSourceInfoFromMetadata(this.ctx.deviceMeta.metadata) ?? synthesizeSourceInfo({
26734
+ stableId: this.stableId,
26735
+ addonId: this.ctx.deviceMeta.addonId
26912
26736
  });
26913
- this.data = next;
26914
- await this.persistFn(this.data);
26737
+ this._sourceInfoCache = Object.freeze({ ...resolved });
26738
+ return this._sourceInfoCache;
26915
26739
  }
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);
26740
+ /**
26741
+ * Convenience accessor for the upstream dispatch key. Equivalent to
26742
+ * `this.sourceInfo.id` — providers use this to keep a
26743
+ * `Map<sourceId, IDevice>` for routing inbound push events.
26744
+ */
26745
+ get sourceId() {
26746
+ return this.sourceInfo.id;
26921
26747
  }
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
26748
+ /**
26749
+ * Patch the device's `SourceInfo`. Shallow-merges `patch` over the
26750
+ * current value, persists the merged result under
26751
+ * `metadata.sourceInfo` via the `device-manager.setMetadata` cap, and
26752
+ * emits `EventCategory.DeviceSourceInfoChanged` for live consumers.
26753
+ *
26754
+ * Safe to call from anywhere in the device's lifetime — the call is
26755
+ * idempotent for `undefined` patch values (ignored) and best-effort
26756
+ * for persistence (a transient device-manager error doesn't unwind
26757
+ * the local cache update, so subsequent reads still see the patch).
26758
+ *
26759
+ * Drivers populate this on adoption + on every metadata change push
26760
+ * from the upstream source. Subscribers (UI, export adapters) react
26761
+ * via the `DeviceSourceInfoChanged` event without polling.
26762
+ */
26763
+ async updateSourceInfo(patch) {
26764
+ const next = mergeSourceInfo(this.sourceInfo, patch);
26765
+ this._sourceInfoCache = Object.freeze({ ...next });
26766
+ const action = this.ctx.api?.deviceManager?.setMetadata;
26767
+ if (action) try {
26768
+ await action.mutate({
26769
+ deviceId: this.id,
26770
+ patch: { [SOURCE_INFO_METADATA_KEY]: next }
26771
+ });
26772
+ } catch {}
26773
+ this.ctx.eventBus.emit(createEvent("device.source-info-changed", {
26774
+ type: "device",
26775
+ id: this.stableId
26776
+ }, {
26777
+ deviceId: this.id,
26778
+ sourceInfo: next
26929
26779
  }));
26930
26780
  }
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];
26781
+ /**
26782
+ * Re-publish the device's current `features` array to the persisted
26783
+ * meta blob. Drivers call this after a probe finishes when the live
26784
+ * `features` getter has gained new flags (e.g. `hasIntercom` flips
26785
+ * to true → `DeviceFeature.TwoWayAudio` joins the list).
26786
+ *
26787
+ * Without this, only the construction-time snapshot is written —
26788
+ * `deviceManager.registerDevice` is invoked once per boot, so probe-
26789
+ * driven additions don't reach the persisted index until the next
26790
+ * server restart, and `getDevice` / `listAll` keep returning the
26791
+ * stale list for forked-worker devices (whose live IDevice instance
26792
+ * is invisible to the hub registry).
26793
+ *
26794
+ * Idempotent: re-calling with the same features just no-ops on the
26795
+ * persisted meta. Best-effort: lookup or write failures are logged
26796
+ * at debug and swallowed — the live `device.features` getter is
26797
+ * still authoritative within this process, so callers never block
26798
+ * device boot on a meta refresh.
26799
+ */
26800
+ async refreshFeatures() {
26801
+ const action = this.ctx.api?.deviceManager?.registerDevice;
26802
+ if (!action) return;
26803
+ try {
26804
+ await action.mutate({
26805
+ addonId: this.ctx.deviceMeta.addonId,
26806
+ stableId: this.stableId,
26807
+ id: this.id,
26808
+ type: this.type,
26809
+ name: this.name,
26810
+ parentDeviceId: this.parentDeviceId,
26811
+ features: [...this.features],
26812
+ config: {}
26813
+ });
26814
+ } catch (err) {}
26986
26815
  }
26987
- setCapState(capName, value) {
26988
- this.applyCapWrite(capName, value, false);
26816
+ /**
26817
+ * Typed read-through to a cap-keyed runtime-state slice. Drivers
26818
+ * call `this.getCapSlice(batteryCapability)` and the return type
26819
+ * is inferred from the cap's `runtimeState` Zod schema — no string
26820
+ * key, no manual generic. Returns `null` when the slice hasn't
26821
+ * been written yet (e.g. driver hasn't seeded battery yet).
26822
+ */
26823
+ getCapSlice(cap) {
26824
+ return this.runtimeState.getCapState(cap.name) ?? null;
26989
26825
  }
26990
- patchCapState(capName, partial) {
26991
- this.applyCapWrite(capName, partial, true);
26826
+ /**
26827
+ * Typed writer to a cap-keyed runtime-state slice. Routes through
26828
+ * the runtime-state writer (validate → persist → emit cap event).
26829
+ * Equivalent to `this.runtimeState.setCapState(cap.name, value)`
26830
+ * but with the cap's `runtimeState` schema enforcing the value
26831
+ * shape at compile time. Mirrors the symmetry of
26832
+ * `getCapSlice` / `setCapSlice` for cross-cap consistency.
26833
+ */
26834
+ setCapSlice(cap, value) {
26835
+ this.runtimeState.setCapState(cap.name, value);
26992
26836
  }
26993
26837
  /**
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.
26838
+ * Field-level read/write proxy over a cap's runtime-state slice.
26839
+ * Drivers that want ergonomic per-field access declare:
26840
+ *
26841
+ * ```ts
26842
+ * protected battery = this.sliceProxy(batteryCapability)
26843
+ * // …
26844
+ * this.battery.sleeping = true // patches the slice
26845
+ * const charging = this.battery.charging // reads the slice
26846
+ * ```
26847
+ *
26848
+ * Reads return `undefined` when the slice hasn't been seeded yet
26849
+ * (cap not registered, or seeded but the field is absent). Writes
26850
+ * route through `runtimeState.patchCapState` so the cap's `runtimeState`
26851
+ * schema validates the merged result and the cap event fires.
26852
+ *
26853
+ * Pattern is generic — same shape works for `battery`, `device-status`,
26854
+ * `motion`, `doorbell`, anything with a `runtimeState:` schema. Drivers
26855
+ * declare one proxy per cap they read/write directly.
26997
26856
  */
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);
26857
+ sliceProxy(cap) {
26858
+ return new Proxy({}, {
26859
+ get: (_, key) => {
26860
+ return this.runtimeState.getCapState(cap.name)?.[key];
26861
+ },
26862
+ set: (_, key, value) => {
26863
+ this.runtimeState.patchCapState(cap.name, { [key]: value });
26864
+ return true;
26865
+ },
26866
+ has: (_, key) => {
26867
+ const slice = this.runtimeState.getCapState(cap.name);
26868
+ return slice ? key in slice : false;
26869
+ },
26870
+ ownKeys: () => {
26871
+ const slice = this.runtimeState.getCapState(cap.name);
26872
+ return slice ? Object.keys(slice) : [];
26873
+ },
26874
+ getOwnPropertyDescriptor: (_, key) => {
26875
+ const slice = this.runtimeState.getCapState(cap.name);
26876
+ if (!slice || !(key in slice)) return void 0;
26877
+ return {
26878
+ configurable: true,
26879
+ enumerable: true,
26880
+ value: slice[key]
26881
+ };
26882
+ }
27014
26883
  });
27015
26884
  }
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
- }
26885
+ /**
26886
+ * Default empty settings UI. Drivers override this to expose an
26887
+ * editable form in the device-details page. Returning an empty sections
26888
+ * array signals "nothing to contribute" — the aggregator drops the
26889
+ * contribution entirely rather than rendering a blank panel.
26890
+ */
26891
+ getSettingsUISchema() {
26892
+ return { sections: [] };
27029
26893
  }
27030
- subscribe(cb) {
27031
- this.listeners.add(cb);
27032
- return () => {
27033
- this.listeners.delete(cb);
27034
- };
26894
+ /**
26895
+ * Default write path: forward the flat patch directly to storage.
26896
+ * Drivers that project a UI shape different from storage (e.g. `RtspCamera`
26897
+ * exposing `mainStreamUrl`/`subStreamUrl` over `streams[]`) override this
26898
+ * to reshape before `config.setAll`.
26899
+ */
26900
+ async applySettingsPatch(patch) {
26901
+ await this.config.setAll(patch);
27035
26902
  }
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);
26903
+ /**
26904
+ * Phase 3 — populate device-scoped state needed by downstream phases
26905
+ * (accessory reconciliation, public `features` array, optional cap
26906
+ * registration). Called ONCE per construction, after register but
26907
+ * before `getAccessoryChildren()`.
26908
+ *
26909
+ * Drivers write the `feature-probe` runtime-state slice via
26910
+ * `this.runtimeState.setCapState('feature-probe', {...})` — flag bag
26911
+ * is open (Reolink writes `hasPtz/hasIntercom`, Hikvision writes
26912
+ * `hasSupplementalLight/hasAlarmIo`, etc).
26913
+ *
26914
+ * Default: nothing to probe → mark the device PROBED (set `lastProbedAt`) so
26915
+ * the kernel treats it as ready immediately. A device that derives its shape
26916
+ * from a spec (a container, or an accessory sensor) rather than from a
26917
+ * hardware probe has no probe to "complete"; without stamping `lastProbedAt`
26918
+ * it would look perpetually un-probed — logging "Initial probe did not
26919
+ * complete" on every boot and spinning a pointless retry chain. Drivers that
26920
+ * DO probe override this and write their own `feature-probe` slice (including
26921
+ * `lastProbedAt`) once their probe actually succeeds.
26922
+ */
26923
+ async onProbe() {
26924
+ const base = this.runtimeState.getCapState("feature-probe") ?? {
26925
+ flags: {},
26926
+ deviceType: null,
26927
+ model: null,
26928
+ channelCount: null,
26929
+ lastProbedAt: 0,
26930
+ lastFetchedAt: 0
27051
26931
  };
26932
+ this.runtimeState.setCapState("feature-probe", {
26933
+ ...base,
26934
+ lastProbedAt: Date.now()
26935
+ });
27052
26936
  }
27053
- snapshot() {
27054
- const out = {};
27055
- for (const [k, v] of this.slices) out[k] = Object.freeze({ ...v });
27056
- return Object.freeze(out);
26937
+ /**
26938
+ * Phase 5 — fired after the device + its accessories are registered.
26939
+ * Drivers publish streams to the broker, kick off background tasks,
26940
+ * or subscribe to lib events that need a fully-registered device id.
26941
+ *
26942
+ * Default: no-op.
26943
+ *
26944
+ * RENAMED FROM `onCreated` (which still exists for back-compat in this
26945
+ * pass). The new name reflects the post-probe, post-accessory contract.
26946
+ */
26947
+ async onActivate() {}
26948
+ /**
26949
+ * Re-run the probe + reconcile accessories + refresh features meta.
26950
+ * Drivers call this when device-side state changes (battery cam wakes,
26951
+ * firmware update, manual operator trigger).
26952
+ *
26953
+ * The kernel injects `_kernelReprobe` on registration so this method
26954
+ * delegates to the same orchestrator that runs the boot-time phase
26955
+ * 3 + 4 sequence. Drivers should NOT override this — they override
26956
+ * `onProbe()` instead.
26957
+ */
26958
+ async reprobe() {
26959
+ if (this._kernelReprobe) await this._kernelReprobe();
26960
+ else await this.onProbe();
27057
26961
  }
27058
- async flush() {
27059
- if (this.pendingWrites.size === 0) return;
27060
- const inflight = [...this.pendingWrites];
27061
- await Promise.allSettled(inflight);
26962
+ /**
26963
+ * Kernel-injected callback that runs the full post-probe orchestration
26964
+ * (onProbe → registerDevice meta refresh → accessory reconciliation).
26965
+ * Set by `device-cap-proxy.register()`. Drivers should not touch this
26966
+ * directly — call `reprobe()` instead.
26967
+ */
26968
+ _kernelReprobe;
26969
+ /**
26970
+ * Declare accessory child devices the kernel should auto-spawn
26971
+ * after `onProbe()` resolves. Each spec fully describes one child
26972
+ * — stableId suffix (deterministic per kind for restore-safety),
26973
+ * meta (type / name / location), config (initial blob the child
26974
+ * self-hydrates), and a factory that constructs the concrete
26975
+ * class with whatever closure-captured refs it needs (typically
26976
+ * `this` for the parent reference).
26977
+ *
26978
+ * The kernel handles the rest: allocateDeviceId, persistInitialConfig
26979
+ * (skipped on restore when the row already exists),
26980
+ * persistInitialMeta, createContext, factory invocation, register,
26981
+ * and recursive lifecycle (probe + accessories + activate).
26982
+ *
26983
+ * Implementations should derive children from
26984
+ * `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
26985
+ * Drivers can use the `getProbeFlags()` helper to read the flag bag
26986
+ * with a typed cast.
26987
+ *
26988
+ * Default: no children.
26989
+ */
26990
+ getAccessoryChildren() {
26991
+ return [];
27062
26992
  }
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;
26993
+ /**
26994
+ * Read the current feature-probe flag bag with a typed cast. Helper
26995
+ * for `getAccessoryChildren()` and `features` getters that derive
26996
+ * outputs from the probe results.
26997
+ */
26998
+ getProbeFlags() {
26999
+ return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
27000
+ }
27001
+ /**
27002
+ * Returns true once `onProbe` has completed at least once
27003
+ * (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
27004
+ * to avoid spawning stale accessories on a fresh device whose probe
27005
+ * hasn't landed yet.
27006
+ */
27007
+ hasProbed() {
27008
+ return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
27009
+ }
27010
+ };
27011
+ /** Marker written to a declared integration's `info`. */
27012
+ var DECLARED_INTEGRATION_FIXED_KEY = "fixed";
27013
+ /**
27014
+ * Strip the `<node>/<addon>` suffix a forked child carries.
27015
+ *
27016
+ * Comparing `ctx.kernel.localNodeId` raw skipped EVERY node — including the one
27017
+ * that was supposed to act — because on the hub it reads `hub/<addon>`.
27018
+ */
27019
+ function declarationOwnerNodeId(localNodeId) {
27020
+ const raw = localNodeId ?? "hub";
27021
+ if (!raw.includes("/")) return raw;
27022
+ return raw.split("/")[0] ?? "hub";
27070
27023
  }
27071
27024
  /**
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.
27025
+ * The one way an addon owns a device it declares.
27026
+ *
27027
+ * Construct once with the addon's ports, then call {@link reconcile} on boot and
27028
+ * on every convergence tick. There is no second get-or-create helper — a guard
27029
+ * in `scripts/` enforces that.
27076
27030
  */
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;
27031
+ var DeclaredDevices = class {
27032
+ ports;
27033
+ constructor(ports) {
27034
+ this.ports = ports;
27035
+ }
27148
27036
  /**
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;
27037
+ * Converge the declared set. Idempotent, and safe to call repeatedly.
27038
+ *
27039
+ * Throws only what the ports throw on the FIRST index read; every other
27040
+ * failure is per-device and logged, so one bad declaration never takes the
27041
+ * others down.
27042
+ */
27043
+ async reconcile(spec) {
27044
+ if ((spec.placement ?? "hub") === "hub") {
27045
+ const nodeId = declarationOwnerNodeId(this.ports.localNodeId);
27046
+ if (nodeId !== "hub") {
27047
+ this.ports.logger.info("declared devices are hub-owned — skipping on this node", { meta: {
27048
+ nodeId,
27049
+ rawNodeId: this.ports.localNodeId ?? null
27050
+ } });
27051
+ return {
27052
+ integrationId: null,
27053
+ devices: [],
27054
+ removed: [],
27055
+ owned: false
27056
+ };
27057
+ }
27058
+ }
27059
+ const integrationId = await this.ensureIntegration(spec.integrationName);
27060
+ const index = await this.readIndex();
27061
+ const outcomes = [];
27062
+ for (const declaration of spec.devices) {
27063
+ const outcome = await this.applyDeclaration(declaration, integrationId, index);
27064
+ if (outcome !== null) outcomes.push(outcome);
27065
+ }
27066
+ return {
27067
+ integrationId,
27068
+ devices: outcomes,
27069
+ removed: await this.sweepWithdrawn(spec.devices, integrationId, index),
27070
+ owned: true
27071
+ };
27157
27072
  }
27158
- set online(value) {
27159
- this.markOnline(value);
27073
+ /**
27074
+ * Get-or-create the FIXED integration, and RE-ASSERT the flag every pass.
27075
+ *
27076
+ * The re-assertion is the fix for the defect the hand-rolled version shipped
27077
+ * with: writing `info.fixed` only on the create path left every pre-existing
27078
+ * install without it, and the kernel kept offering to delete an integration
27079
+ * the addon owns.
27080
+ */
27081
+ async ensureIntegration(integrationName) {
27082
+ const existing = await this.ports.getIntegration(this.ports.addonId);
27083
+ if (existing === null) {
27084
+ const created = await this.ports.createIntegration({
27085
+ addonId: this.ports.addonId,
27086
+ name: integrationName,
27087
+ info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
27088
+ });
27089
+ this.ports.logger.info("declared a fixed integration", { meta: {
27090
+ integrationId: created.id,
27091
+ name: integrationName
27092
+ } });
27093
+ return created.id;
27094
+ }
27095
+ if (existing.info?.["fixed"] !== true) {
27096
+ await this.ports.updateIntegration({
27097
+ id: existing.id,
27098
+ info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
27099
+ });
27100
+ this.ports.logger.info("re-asserted `fixed` on a declared integration", { meta: { integrationId: existing.id } });
27101
+ }
27102
+ return existing.id;
27103
+ }
27104
+ async readIndex() {
27105
+ const rows = await this.ports.listOwnDevices();
27106
+ return new Map(rows.map((row) => [row.stableId, row]));
27160
27107
  }
27161
27108
  /**
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:
27109
+ * One declaration: adopt what exists, create what does not.
27165
27110
  *
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)`
27111
+ * The create branch is the destructive one — it seeds `initialMeta`, and
27112
+ * `initialMeta.name` lands as an unconditional `setName`. A transiently empty
27113
+ * index therefore looks exactly like a first boot and would silently re-stamp
27114
+ * the declared name over the operator's rename. D49: that branch needs a
27115
+ * second read to agree.
27116
+ */
27117
+ async applyDeclaration(declaration, integrationId, index) {
27118
+ try {
27119
+ let existing = index.get(declaration.stableId);
27120
+ if (existing === void 0) {
27121
+ existing = (await this.readIndex()).get(declaration.stableId);
27122
+ if (existing !== void 0) this.ports.logger.warn("device index disagreed with itself — adopting instead of re-creating", {
27123
+ tags: { deviceId: existing.id },
27124
+ meta: {
27125
+ stableId: declaration.stableId,
27126
+ addonId: this.ports.addonId
27127
+ }
27128
+ });
27129
+ }
27130
+ if (existing !== void 0) {
27131
+ const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, {}, null, void 0);
27132
+ this.ports.logger.info("declared device adopted", {
27133
+ tags: { deviceId: device.id },
27134
+ meta: {
27135
+ stableId: declaration.stableId,
27136
+ integrationId
27137
+ }
27138
+ });
27139
+ return {
27140
+ stableId: declaration.stableId,
27141
+ deviceId: device.id,
27142
+ device,
27143
+ created: false
27144
+ };
27145
+ }
27146
+ const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, declaration.config ?? {}, null, {
27147
+ type: declaration.type,
27148
+ name: declaration.name,
27149
+ integrationId,
27150
+ ...declaration.role === void 0 ? {} : { role: declaration.role }
27151
+ });
27152
+ this.ports.logger.info("declared device created", {
27153
+ tags: { deviceId: device.id },
27154
+ meta: {
27155
+ stableId: declaration.stableId,
27156
+ integrationId
27157
+ }
27158
+ });
27159
+ return {
27160
+ stableId: declaration.stableId,
27161
+ deviceId: device.id,
27162
+ device,
27163
+ created: true
27164
+ };
27165
+ } catch (err) {
27166
+ this.ports.logger.warn("a declared device could not be brought up", { meta: {
27167
+ stableId: declaration.stableId,
27168
+ error: err instanceof Error ? err.message : String(err)
27169
+ } });
27170
+ return null;
27171
+ }
27172
+ }
27173
+ /**
27174
+ * Remove rows under the addon's FIXED integration whose declaration is gone.
27169
27175
  *
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).
27176
+ * Bounded to that integration: a declared integration has no operator
27177
+ * add-flow, so every row under it got there by declaration. Devices this
27178
+ * addon owns OUTSIDE it (a provider's adopted devices) are never candidates.
27176
27179
  *
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);
27180
+ * Bounded in count, and every deletion is logged with its `deviceId` — a
27181
+ * withdrawal that removes an operator-visible row silently is the failure
27182
+ * mode, not the removal itself.
27183
+ */
27184
+ async sweepWithdrawn(declarations, integrationId, index) {
27185
+ const declared = new Set(declarations.map((d) => d.stableId));
27186
+ const candidates = [...index.values()].filter((row) => row.integrationId === integrationId && !declared.has(row.stableId));
27187
+ if (candidates.length === 0) return [];
27188
+ if (candidates.length > 32) {
27189
+ this.ports.logger.warn("withdrawal sweep exceeded its bound — removing nothing", { meta: {
27190
+ integrationId,
27191
+ candidates: candidates.length,
27192
+ bound: 32
27193
+ } });
27194
+ return [];
27195
+ }
27196
+ const removed = [];
27197
+ for (const row of candidates) try {
27198
+ await this.ports.devices.remove(row.id);
27199
+ removed.push(row.id);
27200
+ this.ports.logger.info("declared device removed — its declaration was withdrawn", {
27201
+ tags: { deviceId: row.id },
27202
+ meta: {
27203
+ stableId: row.stableId,
27204
+ integrationId
27205
+ }
27206
+ });
27207
+ } catch (err) {
27208
+ this.ports.logger.warn("a withdrawn declared device could not be removed", {
27209
+ tags: { deviceId: row.id },
27210
+ meta: {
27211
+ stableId: row.stableId,
27212
+ error: err instanceof Error ? err.message : String(err)
27213
+ }
27214
+ });
27215
+ }
27216
+ return removed;
27217
+ }
27218
+ };
27219
+ 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;
27220
+ new Set(Object.values(DeviceType));
27221
+ DeviceFeature.BatteryOperated;
27222
+ /**
27223
+ * Error types for the safe expression engine. Two distinct classes so callers
27224
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
27225
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
27226
+ */
27227
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
27228
+ * the failure is anchored to a character (author-facing inline feedback). */
27229
+ var ExpressionParseError = class extends Error {
27230
+ position;
27231
+ constructor(message, position) {
27232
+ super(message);
27233
+ this.name = "ExpressionParseError";
27234
+ this.position = position;
27235
+ }
27236
+ };
27237
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
27238
+ * result, unknown builtin, step-budget exceeded). */
27239
+ var ExpressionEvalError = class extends Error {
27240
+ constructor(message) {
27241
+ super(message);
27242
+ this.name = "ExpressionEvalError";
27243
+ }
27244
+ };
27245
+ /**
27246
+ * Frozen, null-prototype builtin function table for the expression engine
27247
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
27248
+ * parser rejects any callee not in it, and the evaluator gates each call on an
27249
+ * own-property check against it.
27250
+ *
27251
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
27252
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
27253
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
27254
+ * (there is no `Object.prototype` in the chain), so those names are not
27255
+ * callable — they are simply "unknown function" at parse time.
27256
+ *
27257
+ * Every numeric argument is validated as a finite number and every numeric
27258
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
27259
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
27260
+ * closed rather than emitting a garbage value.
27261
+ */
27262
+ function asFiniteNumber(value, name, index) {
27263
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
27264
+ return value;
27265
+ }
27266
+ function asString$1(value, name, index) {
27267
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
27268
+ return value;
27269
+ }
27270
+ function finiteResult(value, name) {
27271
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
27272
+ return value;
27273
+ }
27274
+ function allFiniteNumbers(args, name) {
27275
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
27276
+ }
27277
+ var INF = Number.POSITIVE_INFINITY;
27278
+ var table = {
27279
+ min: {
27280
+ minArgs: 1,
27281
+ maxArgs: INF,
27282
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
27283
+ },
27284
+ max: {
27285
+ minArgs: 1,
27286
+ maxArgs: INF,
27287
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
27288
+ },
27289
+ abs: {
27290
+ minArgs: 1,
27291
+ maxArgs: 1,
27292
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
27293
+ },
27294
+ floor: {
27295
+ minArgs: 1,
27296
+ maxArgs: 1,
27297
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
27298
+ },
27299
+ ceil: {
27300
+ minArgs: 1,
27301
+ maxArgs: 1,
27302
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
27303
+ },
27304
+ sqrt: {
27305
+ minArgs: 1,
27306
+ maxArgs: 1,
27307
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
27308
+ },
27309
+ round: {
27310
+ minArgs: 1,
27311
+ maxArgs: 2,
27312
+ apply: (args) => {
27313
+ const x = asFiniteNumber(args[0], "round", 0);
27314
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
27315
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
27316
+ const factor = 10 ** digits;
27317
+ return finiteResult(Math.round(x * factor) / factor, "round");
27318
+ }
27319
+ },
27320
+ pow: {
27321
+ minArgs: 2,
27322
+ maxArgs: 2,
27323
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
27324
+ },
27325
+ clamp: {
27326
+ minArgs: 3,
27327
+ maxArgs: 3,
27328
+ apply: (args) => {
27329
+ const x = asFiniteNumber(args[0], "clamp", 0);
27330
+ const lo = asFiniteNumber(args[1], "clamp", 1);
27331
+ const hi = asFiniteNumber(args[2], "clamp", 2);
27332
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
27333
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
27334
+ }
27335
+ },
27336
+ avg: {
27337
+ minArgs: 1,
27338
+ maxArgs: INF,
27339
+ apply: (args) => {
27340
+ const nums = allFiniteNumbers(args, "avg");
27341
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
27342
+ }
27343
+ },
27344
+ sum: {
27345
+ minArgs: 1,
27346
+ maxArgs: INF,
27347
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
27348
+ },
27349
+ coalesce: {
27350
+ minArgs: 1,
27351
+ maxArgs: INF,
27352
+ apply: (args) => {
27353
+ for (const a of args) if (a !== null) return a;
27354
+ return null;
27355
+ }
27356
+ },
27357
+ age: {
27358
+ minArgs: 2,
27359
+ maxArgs: 2,
27360
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
27361
+ },
27362
+ convert: {
27363
+ minArgs: 3,
27364
+ maxArgs: 3,
27365
+ apply: (args, hooks) => {
27366
+ const x = asFiniteNumber(args[0], "convert", 0);
27367
+ const from = asString$1(args[1], "convert", 1).trim();
27368
+ const to = asString$1(args[2], "convert", 2).trim();
27369
+ if (hooks.convert) {
27370
+ const out = hooks.convert(x, from, to);
27371
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
27372
+ return finiteResult(out, "convert");
27373
+ }
27374
+ if (from === to) return x;
27375
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
27193
27376
  }
27194
- return this._stateProxyCache;
27195
27377
  }
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
27378
+ };
27379
+ Object.freeze(Object.assign(Object.create(null), table));
27380
+ /** The set of valid builtin names — used by the parser to reject unknown
27381
+ * callees at parse time (immediate author feedback). */
27382
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
27383
+ /**
27384
+ * Resource-bound constants for the safe expression engine.
27385
+ *
27386
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
27387
+ * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
27388
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
27389
+ * work a single author-supplied expression can request, so a hostile or
27390
+ * accidental pathological string can never spend unbounded CPU/memory.
27391
+ */
27392
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
27393
+ * rejected without allocation. */
27394
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
27395
+ /** A legal binding / identifier name. */
27396
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
27397
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
27398
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
27399
+ var RESERVED_BINDING_NAMES = new Set([
27400
+ "now",
27401
+ "true",
27402
+ "false",
27403
+ "null"
27404
+ ]);
27405
+ /**
27406
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
27407
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
27408
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
27409
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
27410
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
27411
+ * is a parse error with a source position, so member access / assignment /
27412
+ * template literals are lexically impossible.
27413
+ */
27414
+ var KEYWORDS = new Set([
27415
+ "true",
27416
+ "false",
27417
+ "null"
27418
+ ]);
27419
+ function isDigit(ch) {
27420
+ return ch >= "0" && ch <= "9";
27421
+ }
27422
+ function isIdentStart(ch) {
27423
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
27424
+ }
27425
+ function isIdentPart(ch) {
27426
+ return isIdentStart(ch) || isDigit(ch);
27427
+ }
27428
+ function isWhitespace(ch) {
27429
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
27430
+ }
27431
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
27432
+ * Throws `ExpressionParseError` on any illegal character or unterminated
27433
+ * string. */
27434
+ function tokenize(source) {
27435
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
27436
+ const tokens = [];
27437
+ let i = 0;
27438
+ const n = source.length;
27439
+ while (i < n) {
27440
+ const ch = source[i];
27441
+ if (isWhitespace(ch)) {
27442
+ i += 1;
27443
+ continue;
27444
+ }
27445
+ if (isDigit(ch)) {
27446
+ const start = i;
27447
+ while (i < n && isDigit(source[i])) i += 1;
27448
+ if (i < n && source[i] === ".") {
27449
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
27450
+ i += 1;
27451
+ while (i < n && isDigit(source[i])) i += 1;
27452
+ }
27453
+ const text = source.slice(start, i);
27454
+ const value = Number(text);
27455
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
27456
+ tokens.push({
27457
+ type: "number",
27458
+ value,
27459
+ pos: start
27460
+ });
27461
+ continue;
27462
+ }
27463
+ if (ch === "'" || ch === "\"") {
27464
+ const quote = ch;
27465
+ const start = i;
27466
+ i += 1;
27467
+ let out = "";
27468
+ let closed = false;
27469
+ while (i < n) {
27470
+ const c = source[i];
27471
+ if (c === "\\") {
27472
+ const next = i + 1 < n ? source[i + 1] : "";
27473
+ if (next === "\\" || next === "'" || next === "\"") {
27474
+ out += next;
27475
+ i += 2;
27476
+ continue;
27477
+ }
27478
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
27479
+ }
27480
+ if (c === quote) {
27481
+ closed = true;
27482
+ i += 1;
27483
+ break;
27268
27484
  }
27485
+ out += c;
27486
+ i += 1;
27487
+ }
27488
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
27489
+ tokens.push({
27490
+ type: "string",
27491
+ value: out,
27492
+ pos: start
27269
27493
  });
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
27494
+ continue;
27495
+ }
27496
+ if (isIdentStart(ch)) {
27497
+ const start = i;
27498
+ while (i < n && isIdentPart(source[i])) i += 1;
27499
+ const text = source.slice(start, i);
27500
+ if (KEYWORDS.has(text)) tokens.push({
27501
+ type: "keyword",
27502
+ keyword: keywordOf(text),
27503
+ pos: start
27277
27504
  });
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
- });
27505
+ else tokens.push({
27506
+ type: "identifier",
27507
+ name: text,
27508
+ pos: start
27509
+ });
27510
+ continue;
27511
+ }
27512
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
27513
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
27514
+ tokens.push({
27515
+ type: "punct",
27516
+ punct: two,
27517
+ pos: i
27518
+ });
27519
+ i += 2;
27520
+ continue;
27521
+ }
27522
+ if (isSinglePunct(ch)) {
27523
+ tokens.push({
27524
+ type: "punct",
27525
+ punct: ch,
27526
+ pos: i
27527
+ });
27528
+ i += 1;
27529
+ continue;
27530
+ }
27531
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
27305
27532
  }
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);
27533
+ tokens.push({
27534
+ type: "eof",
27535
+ pos: n
27536
+ });
27537
+ return tokens;
27538
+ }
27539
+ function keywordOf(text) {
27540
+ if (text === "true") return "true";
27541
+ if (text === "false") return "false";
27542
+ return "null";
27543
+ }
27544
+ function isSinglePunct(ch) {
27545
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
27546
+ }
27547
+ /**
27548
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
27549
+ *
27550
+ * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
27551
+ * → relational → additive → multiplicative → unary `! -` → call / primary.
27552
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
27553
+ * string validated against the builtin table at parse time, so an unknown
27554
+ * function is rejected immediately (author feedback) and a persisted expression
27555
+ * that references a since-removed builtin degrades at read.
27556
+ *
27557
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
27558
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
27559
+ */
27560
+ /** Binary/logical operator precedence (higher binds tighter). */
27561
+ var BINARY_PRECEDENCE = {
27562
+ "||": 1,
27563
+ "&&": 2,
27564
+ "==": 3,
27565
+ "!=": 3,
27566
+ "<": 4,
27567
+ "<=": 4,
27568
+ ">": 4,
27569
+ ">=": 4,
27570
+ "+": 5,
27571
+ "-": 5,
27572
+ "*": 6,
27573
+ "/": 6,
27574
+ "%": 6
27575
+ };
27576
+ function isLogicalOp(op) {
27577
+ return op === "&&" || op === "||";
27578
+ }
27579
+ function isBinaryOp(op) {
27580
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
27581
+ }
27582
+ var Parser = class {
27583
+ tokens;
27584
+ pos = 0;
27585
+ nodeCount = 0;
27586
+ identifiers = /* @__PURE__ */ new Set();
27587
+ callees = /* @__PURE__ */ new Set();
27588
+ constructor(tokens) {
27589
+ this.tokens = tokens;
27312
27590
  }
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()
27591
+ parse() {
27592
+ const ast = this.parseTernary();
27593
+ const tok = this.peek();
27594
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
27595
+ return {
27596
+ ast,
27597
+ identifiers: this.identifiers,
27598
+ callees: this.callees,
27599
+ nodeCount: this.nodeCount
27328
27600
  };
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
27601
  }
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;
27602
+ peek() {
27603
+ return this.tokens[this.pos];
27437
27604
  }
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);
27605
+ next() {
27606
+ return this.tokens[this.pos++];
27448
27607
  }
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;
27608
+ /** Consume a punctuator token, erroring if the next token isn't it. */
27609
+ expectPunct(punct) {
27610
+ const tok = this.peek();
27611
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
27612
+ this.pos += 1;
27613
+ }
27614
+ matchPunct(punct) {
27615
+ const tok = this.peek();
27616
+ if (tok.type === "punct" && tok.punct === punct) {
27617
+ this.pos += 1;
27618
+ return true;
27619
+ }
27620
+ return false;
27621
+ }
27622
+ countNode() {
27623
+ this.nodeCount += 1;
27624
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
27625
+ }
27626
+ parseTernary() {
27627
+ const test = this.parseBinary(1);
27628
+ if (this.matchPunct("?")) {
27629
+ const consequent = this.parseTernary();
27630
+ this.expectPunct(":");
27631
+ const alternate = this.parseTernary();
27632
+ this.countNode();
27633
+ return {
27634
+ kind: "conditional",
27635
+ test,
27636
+ consequent,
27637
+ alternate
27638
+ };
27639
+ }
27640
+ return test;
27641
+ }
27642
+ parseBinary(minPrec) {
27643
+ let left = this.parseUnary();
27644
+ for (;;) {
27645
+ const tok = this.peek();
27646
+ if (tok.type !== "punct") break;
27647
+ const prec = BINARY_PRECEDENCE[tok.punct];
27648
+ if (prec === void 0 || prec < minPrec) break;
27649
+ const op = tok.punct;
27650
+ this.pos += 1;
27651
+ const right = this.parseBinary(prec + 1);
27652
+ this.countNode();
27653
+ if (isLogicalOp(op)) left = {
27654
+ kind: "logical",
27655
+ op,
27656
+ left,
27657
+ right
27658
+ };
27659
+ else if (isBinaryOp(op)) left = {
27660
+ kind: "binary",
27661
+ op,
27662
+ left,
27663
+ right
27664
+ };
27665
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
27666
+ }
27667
+ return left;
27668
+ }
27669
+ parseUnary() {
27670
+ const tok = this.peek();
27671
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
27672
+ const op = tok.punct;
27673
+ this.pos += 1;
27674
+ const operand = this.parseUnary();
27675
+ this.countNode();
27676
+ return {
27677
+ kind: "unary",
27678
+ op,
27679
+ operand
27680
+ };
27681
+ }
27682
+ return this.parsePrimary();
27683
+ }
27684
+ parsePrimary() {
27685
+ const tok = this.next();
27686
+ switch (tok.type) {
27687
+ case "number":
27688
+ this.countNode();
27489
27689
  return {
27490
- configurable: true,
27491
- enumerable: true,
27492
- value: slice[key]
27690
+ kind: "literal",
27691
+ value: tok.value
27692
+ };
27693
+ case "string":
27694
+ this.countNode();
27695
+ return {
27696
+ kind: "literal",
27697
+ value: tok.value
27698
+ };
27699
+ case "keyword":
27700
+ this.countNode();
27701
+ return {
27702
+ kind: "literal",
27703
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
27704
+ };
27705
+ case "identifier": {
27706
+ const nextTok = this.peek();
27707
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
27708
+ this.identifiers.add(tok.name);
27709
+ this.countNode();
27710
+ return {
27711
+ kind: "identifier",
27712
+ name: tok.name
27493
27713
  };
27494
27714
  }
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);
27715
+ case "punct":
27716
+ if (tok.punct === "(") {
27717
+ const inner = this.parseTernary();
27718
+ this.expectPunct(")");
27719
+ return inner;
27720
+ }
27721
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
27722
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
27723
+ }
27514
27724
  }
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
27725
+ parseCall(callee, pos) {
27726
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
27727
+ this.expectPunct("(");
27728
+ const args = [];
27729
+ if (!this.matchPunct(")")) for (;;) {
27730
+ args.push(this.parseTernary());
27731
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
27732
+ if (this.matchPunct(",")) continue;
27733
+ this.expectPunct(")");
27734
+ break;
27735
+ }
27736
+ this.callees.add(callee);
27737
+ this.countNode();
27738
+ return {
27739
+ kind: "call",
27740
+ callee,
27741
+ args
27543
27742
  };
27544
- this.runtimeState.setCapState("feature-probe", {
27545
- ...base,
27546
- lastProbedAt: Date.now()
27547
- });
27548
27743
  }
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();
27744
+ };
27745
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
27746
+ * `ExpressionParseError` on any lexical or grammatical failure. */
27747
+ function parseExpression(source) {
27748
+ return new Parser(tokenize(source)).parse();
27749
+ }
27750
+ /**
27751
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
27752
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
27753
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
27754
+ * one per read on a hot resolve path.
27755
+ *
27756
+ * The cache is a module-level singleton: entries are pure, content-addressed
27757
+ * ASTs keyed by the raw source string, so sharing one instance across all
27758
+ * callers is safe and maximises hit rate.
27759
+ */
27760
+ var cache = /* @__PURE__ */ new Map();
27761
+ function getCached(source) {
27762
+ const hit = cache.get(source);
27763
+ if (hit !== void 0) {
27764
+ cache.delete(source);
27765
+ cache.set(source, hit);
27766
+ return hit;
27767
+ }
27768
+ let result;
27769
+ try {
27770
+ result = {
27771
+ ok: true,
27772
+ parsed: parseExpression(source)
27773
+ };
27774
+ } catch (err) {
27775
+ result = {
27776
+ ok: false,
27777
+ error: err instanceof ExpressionParseError ? err.message : String(err)
27778
+ };
27573
27779
  }
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 [];
27780
+ cache.set(source, result);
27781
+ if (cache.size > 256) {
27782
+ const oldest = cache.keys().next().value;
27783
+ if (oldest !== void 0) cache.delete(oldest);
27604
27784
  }
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 ?? {};
27785
+ return result;
27786
+ }
27787
+ /** Compile `source`, returning a discriminated result instead of throwing.
27788
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
27789
+ function compileExpressionSafe(source) {
27790
+ return getCached(source);
27791
+ }
27792
+ Object.freeze({});
27793
+ /**
27794
+ * Author-time validation. Returns `null` when the source is valid, else a
27795
+ * human-readable error message. Checks: the expression compiles; binding count
27796
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
27797
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
27798
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
27799
+ */
27800
+ function validateExpressionSource(src) {
27801
+ const names = Object.keys(src.bindings);
27802
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
27803
+ for (const name of names) {
27804
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
27805
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
27806
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
27612
27807
  }
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;
27808
+ const compiled = compileExpressionSafe(src.expr);
27809
+ if (!compiled.ok) return compiled.error;
27810
+ const bound = new Set(names);
27811
+ for (const id of compiled.parsed.identifiers) {
27812
+ if (id === "now") continue;
27813
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
27621
27814
  }
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;
27815
+ return null;
27816
+ }
27817
+ var ExpressionBindingSourceSchema = union([
27818
+ object({
27819
+ kind: literal("field").optional(),
27820
+ sourceKey: string(),
27821
+ cap: string(),
27822
+ fieldPath: string()
27823
+ }),
27824
+ object({
27825
+ kind: literal("literal"),
27826
+ value: union([
27827
+ string(),
27828
+ number(),
27829
+ boolean(),
27830
+ _null()
27831
+ ])
27832
+ }),
27833
+ object({
27834
+ kind: literal("global"),
27835
+ sourceStableId: string(),
27836
+ cap: string(),
27837
+ fieldPath: string()
27838
+ })
27839
+ ]);
27840
+ object({
27841
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
27842
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
27843
+ }).superRefine((src, ctx) => {
27844
+ const err = validateExpressionSource(src);
27845
+ if (err !== null) ctx.addIssue({
27846
+ code: "custom",
27847
+ message: err,
27848
+ path: ["expr"]
27849
+ });
27850
+ });
27626
27851
  Object.freeze({
27627
27852
  "accessories.setChildHidden": {
27628
27853
  capName: "accessories",
@@ -28446,6 +28671,12 @@ Object.freeze({
28446
28671
  addonId: null,
28447
28672
  access: "view"
28448
28673
  },
28674
+ "coreBlocks.restart": {
28675
+ capName: "core-blocks",
28676
+ capScope: "system",
28677
+ addonId: null,
28678
+ access: "create"
28679
+ },
28449
28680
  "coreBlocks.setEnabled": {
28450
28681
  capName: "core-blocks",
28451
28682
  capScope: "system",
@@ -29082,12 +29313,6 @@ Object.freeze({
29082
29313
  addonId: null,
29083
29314
  access: "create"
29084
29315
  },
29085
- "deviceManager.setDeviceLinks": {
29086
- capName: "device-manager",
29087
- capScope: "system",
29088
- addonId: null,
29089
- access: "create"
29090
- },
29091
29316
  "deviceManager.setDisabled": {
29092
29317
  capName: "device-manager",
29093
29318
  capScope: "system",
@@ -33185,51 +33410,6 @@ var TimelapseRuleSchema = TimelapseRuleInputSchema.extend({
33185
33410
  createdAt: number(),
33186
33411
  updatedAt: number()
33187
33412
  });
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
33413
  object({
33234
33414
  /**
33235
33415
  * Fraction of the box's own size added on EACH side before cutting.
@@ -33336,6 +33516,51 @@ DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
33336
33516
  DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
33337
33517
  DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
33338
33518
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
33519
+ /** Cosine similarity between two embedding vectors */
33520
+ function cosineSimilarity(a, b) {
33521
+ if (a.length !== b.length) return 0;
33522
+ let dotProduct = 0;
33523
+ let normA = 0;
33524
+ let normB = 0;
33525
+ for (let i = 0; i < a.length; i++) {
33526
+ dotProduct += a[i] * b[i];
33527
+ normA += a[i] * a[i];
33528
+ normB += b[i] * b[i];
33529
+ }
33530
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
33531
+ return denom === 0 ? 0 : dotProduct / denom;
33532
+ }
33533
+ function hfModelUrl(repo, path) {
33534
+ return `https://huggingface.co/${repo}/resolve/main/${path}`;
33535
+ }
33536
+ /**
33537
+ * Vector wire codec — base64 of little-endian Float32.
33538
+ *
33539
+ * The `vector-store` capability carries vectors as base64 rather than
33540
+ * `number[]` or a typed array, and both rejected alternatives have a scar here:
33541
+ *
33542
+ * - a `Float32Array` does NOT survive msgpack across the UDS transport (it
33543
+ * arrived as an empty object and froze audio dBFS until the payload was
33544
+ * changed to carry bytes);
33545
+ * - `number[]` is the ~5x-larger encoding the capability exists to stop paying
33546
+ * — a 512-dim vector is 2,048 bytes raw and 2,732 as base64, against roughly
33547
+ * 10-12 KB rendered as JSON text.
33548
+ *
33549
+ * Endianness is pinned to little-endian explicitly instead of inheriting the
33550
+ * platform's, so a vector written on one node decodes correctly on another.
33551
+ */
33552
+ /** Encode a vector as base64 of little-endian Float32. */
33553
+ function encodeVectorBase64(vector) {
33554
+ const floats = vector instanceof Float32Array ? vector : Float32Array.from(vector);
33555
+ const bytes = new Uint8Array(floats.length * 4);
33556
+ const view = new DataView(bytes.buffer);
33557
+ for (let i = 0; i < floats.length; i += 1) view.setFloat32(i * 4, floats[i] ?? 0, true);
33558
+ return Buffer.from(bytes).toString("base64");
33559
+ }
33560
+ /** Vector length implied by a base64 payload, without decoding it. */
33561
+ function vectorDimFromBase64(encoded) {
33562
+ return Math.floor(Buffer.from(encoded, "base64").byteLength / 4);
33563
+ }
33339
33564
  //#endregion
33340
33565
  Object.defineProperty(exports, "BaseAddon", {
33341
33566
  enumerable: true,
@@ -33355,6 +33580,12 @@ Object.defineProperty(exports, "DEFAULT_EVENT_COLOR", {
33355
33580
  return DEFAULT_EVENT_COLOR;
33356
33581
  }
33357
33582
  });
33583
+ Object.defineProperty(exports, "DeclaredDevices", {
33584
+ enumerable: true,
33585
+ get: function() {
33586
+ return DeclaredDevices;
33587
+ }
33588
+ });
33358
33589
  Object.defineProperty(exports, "DeviceType", {
33359
33590
  enumerable: true,
33360
33591
  get: function() {