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