@camstack/addon-provider-homematic 1.2.10 → 1.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +1921 -1244
  2. package/dist/addon.mjs +1921 -1244
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -8,7 +8,7 @@ let http = require("http");
8
8
  let crypto$1 = require("crypto");
9
9
  let fs_promises = require("fs/promises");
10
10
  let path = require("path");
11
- //#region ../types/dist/event-category-41fKf-q9.mjs
11
+ //#region ../types/dist/event-category-Cv9dO26A.mjs
12
12
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
13
13
  EventCategory["SystemBoot"] = "system.boot";
14
14
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -24,6 +24,13 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
24
24
  */
25
25
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
26
26
  /**
27
+ * A newer addon or server-root package version was found by the
28
+ * authoritative registry check. Emitted once per
29
+ * `(target, packageName, currentVersion, latestVersion)` transition; repeated
30
+ * polling of the same result is deduplicated by the checker.
31
+ */
32
+ EventCategory["UpdateAvailable"] = "update.available";
33
+ /**
27
34
  * Readiness transition for a capability provider. Every producer emits
28
35
  * this event on `onInitialize` completion, `onDestroy`, and
29
36
  * `$node.reconnect`; every consumer that needs to gate on a cross-process
@@ -7123,6 +7130,104 @@ object({
7123
7130
  })
7124
7131
  });
7125
7132
  /**
7133
+ * Adoption job — the background form of `device-adoption.adopt`.
7134
+ *
7135
+ * ## Why this exists
7136
+ *
7137
+ * `adopt({childNativeIds: [...]})` materialises one CamStack device per
7138
+ * candidate PLUS every accessory child, and the whole array shares ONE UDS
7139
+ * request deadline (60s). Measured on the live hub against Home Assistant:
7140
+ * each device the kernel creates costs ~450 ms — `devices.create` pre-seeds
7141
+ * meta with up to eleven SEQUENTIAL round trips (`setName`, `setType`,
7142
+ * `setRole`, … `persistConfig`) before the class is constructed — and an
7143
+ * accessory child costs the same as its parent. So the real unit of work is
7144
+ * the CHILD, not the candidate:
7145
+ *
7146
+ * - 25 candidates averaging 6 children → ~150 devices → **>60s, times out**
7147
+ * - ONE candidate with 217 children → ~217 devices → **>60s, times out**
7148
+ *
7149
+ * That second line is why this is a job and not a smaller batch. No chunking,
7150
+ * no bounded concurrency over candidates and no per-call tuning can fix a
7151
+ * shape where **N=1 already exceeds the deadline** — the count that blows the
7152
+ * budget is the source system's accessory fan-out, which the operator does not
7153
+ * choose and cannot see. A design that only works below some N is the same bug
7154
+ * deferred.
7155
+ *
7156
+ * ## What the timeout did NOT do
7157
+ *
7158
+ * It did not stop the work. The UDS deadline ends the CALLER's wait; the
7159
+ * provider's loop runs to completion. Measured: a 25-candidate adopt that
7160
+ * "failed" at 60s had adopted 17 by 87s and all 25 by ~130s. The operator saw
7161
+ * an error and had no way to learn that. Every field below exists so that
7162
+ * question has an answer.
7163
+ *
7164
+ * ## Idempotency
7165
+ *
7166
+ * Jobs are in-RAM; a restart forgets them. That is safe here because adoption
7167
+ * is keyed by a stable id (`ha:<broker>:dev:<nativeId>` and equivalents), so
7168
+ * re-running a job re-adopts nothing: an already-adopted candidate is SKIPPED
7169
+ * by the engine before any provider call and lands in `alreadyAdopted`. It is
7170
+ * never a duplicate device, and never an error the operator has to interpret.
7171
+ */
7172
+ var AdoptionJobStateSchema = _enum([
7173
+ "running",
7174
+ "done",
7175
+ "failed",
7176
+ "cancelled"
7177
+ ]);
7178
+ /**
7179
+ * Per-candidate result. Every candidate the job was asked to adopt ends in
7180
+ * exactly one of these buckets — there is no silent drop, and the operator can
7181
+ * always answer "which of my 25 landed?".
7182
+ *
7183
+ * - `adopted` — created now by this job.
7184
+ * - `already-adopted` — a device for this candidate existed before the job
7185
+ * reached it (a re-run, or a retry after a timeout). Not an error.
7186
+ * - `failed` — the provider threw; `error` carries the message.
7187
+ * - `cancelled` — the operator cancelled before this candidate was reached.
7188
+ */
7189
+ var AdoptionOutcomeSchema = _enum([
7190
+ "adopted",
7191
+ "already-adopted",
7192
+ "failed",
7193
+ "cancelled"
7194
+ ]);
7195
+ var AdoptionCandidateResultSchema = object({
7196
+ childNativeId: string(),
7197
+ outcome: AdoptionOutcomeSchema,
7198
+ /** The materialised parent device id — null for `failed` / `cancelled`. */
7199
+ parentDeviceId: number().int().nonnegative().nullable(),
7200
+ /** Accessory children created for this candidate. */
7201
+ accessoryCount: number().int().nonnegative(),
7202
+ /** Failure message; null unless `outcome === 'failed'`. */
7203
+ error: string().nullable()
7204
+ });
7205
+ var AdoptionJobSchema = object({
7206
+ jobId: string(),
7207
+ /** The integration provider this job adopts through (the `addonId` pin). */
7208
+ addonId: string(),
7209
+ integrationId: string(),
7210
+ state: AdoptionJobStateSchema,
7211
+ /** Candidates the job was asked to adopt. Known up front, so never null. */
7212
+ total: number().int().nonnegative(),
7213
+ /** Candidates that have reached a terminal bucket. */
7214
+ processed: number().int().nonnegative(),
7215
+ adopted: number().int().nonnegative(),
7216
+ alreadyAdopted: number().int().nonnegative(),
7217
+ failed: number().int().nonnegative(),
7218
+ /** Accessory child devices created across every candidate — the real unit
7219
+ * of work, surfaced so a slow job is legible rather than mysterious. */
7220
+ accessoriesCreated: number().int().nonnegative(),
7221
+ /** The candidate currently being adopted; null when idle or finished. */
7222
+ currentChildNativeId: string().nullable(),
7223
+ /** One entry per candidate, in the order they were processed. */
7224
+ results: array(AdoptionCandidateResultSchema).readonly(),
7225
+ startedAt: number(),
7226
+ finishedAt: number().nullable(),
7227
+ /** Set only when the job itself broke (not a per-candidate failure). */
7228
+ error: string().nullable()
7229
+ });
7230
+ /**
7126
7231
  * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7127
7232
  * pipeline functions an operator thinks in terms of.
7128
7233
  *
@@ -7942,314 +8047,1088 @@ var ConvertResultSchema = object({
7942
8047
  })).readonly()
7943
8048
  });
7944
8049
  /**
7945
- * `addon-pages` — system-scoped singleton aggregator cap. Public-facing
7946
- * surface that admin-ui consumes through `useAddonPagesListPages()`.
7947
- *
7948
- * The provider iterates every `addon-pages-source` (collection) provider
7949
- * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
7950
- * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
7951
- * filesystem `mtime` cache-buster lets the browser pick up addon
7952
- * rebuilds without manual reload.
7953
- *
7954
- * The hub-local builtin `addon-pages-aggregator` (see
7955
- * `@camstack/system/builtins/addon-pages-aggregator`) registers the
7956
- * provider. Splitting the public aggregator from the raw collection
7957
- * keeps both ends in codegen — there's no hand-written
7958
- * `addon-pages.router.ts` wrapper anymore.
8050
+ * Error types for the safe expression engine. Two distinct classes so callers
8051
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
8052
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
7959
8053
  */
7960
- var AddonPageDeclarationSchema$1 = object({
7961
- id: string(),
7962
- label: string(),
7963
- icon: string(),
7964
- path: string(),
7965
- remoteName: string(),
7966
- bundle: string(),
7967
- section: string().optional(),
7968
- sectionLabel: string().optional()
7969
- });
7970
- var AddonPageInfoSchema = object({
7971
- addonId: string(),
7972
- page: AddonPageDeclarationSchema$1,
7973
- bundleUrl: string()
7974
- });
7975
- method(_void(), array(AddonPageInfoSchema).readonly());
8054
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
8055
+ * the failure is anchored to a character (author-facing inline feedback). */
8056
+ var ExpressionParseError = class extends Error {
8057
+ position;
8058
+ constructor(message, position) {
8059
+ super(message);
8060
+ this.name = "ExpressionParseError";
8061
+ this.position = position;
8062
+ }
8063
+ };
8064
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
8065
+ * result, unknown builtin, step-budget exceeded). */
8066
+ var ExpressionEvalError = class extends Error {
8067
+ constructor(message) {
8068
+ super(message);
8069
+ this.name = "ExpressionEvalError";
8070
+ }
8071
+ };
7976
8072
  /**
7977
- * `addon-pages-source` — collection cap exposing per-provider raw page
7978
- * declarations. Every addon that contributes a UI page registers a
7979
- * provider here. The hub-side singleton aggregator (`addon-pages` cap,
7980
- * see `addon-pages.cap.ts`) walks this collection, stamps versioned
7981
- * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
7982
- * that admin-ui consumes.
8073
+ * Frozen, null-prototype builtin function table for the expression engine
8074
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
8075
+ * parser rejects any callee not in it, and the evaluator gates each call on an
8076
+ * own-property check against it.
7983
8077
  *
7984
- * The split exists because the public listing has a different output
7985
- * shape than the per-provider raw declarations, and we want both ends
7986
- * to flow through codegen instead of relying on a hand-written wrapper.
7987
- */
7988
- var AddonPageDeclarationSchema = object({
7989
- id: string(),
7990
- label: string(),
7991
- icon: string(),
7992
- path: string(),
7993
- /**
7994
- * Module Federation remote name — must match the `name` field on the
7995
- * page addon's `federation()` plugin config. Used by admin-ui's
7996
- * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
7997
- * Conventionally `addon_<id>_page` (snake_case; MF names cannot
7998
- * contain hyphens).
7999
- */
8000
- remoteName: string(),
8001
- /**
8002
- * Bundle filename inside the addon's `dist/` dir served at
8003
- * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
8004
- * is always `'remoteEntry.js'`; the value is kept on the metadata so
8005
- * the static-file route can compute an mtime-based cache-buster URL
8006
- * without a separate filesystem stat.
8007
- */
8008
- bundle: string(),
8009
- /**
8010
- * Sidebar section this page docks into. Well-known ids: `'detection'`,
8011
- * `'cluster'`, `'administration'` — the page renders inside that group.
8012
- * Any OTHER string creates (or joins) a custom section rendered after
8013
- * the built-in groups; its label comes from `sectionLabel` (first
8014
- * declaration wins), falling back to the id. Absent → the legacy
8015
- * "Addon Pages" group.
8016
- */
8017
- section: string().optional(),
8018
- /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
8019
- sectionLabel: string().optional()
8020
- });
8021
- method(_void(), array(AddonPageDeclarationSchema).readonly());
8022
- var AddonHttpRouteSchema = object({
8023
- method: _enum([
8024
- "GET",
8025
- "POST",
8026
- "PUT",
8027
- "DELETE",
8028
- "PATCH"
8029
- ]),
8030
- path: string(),
8031
- access: _enum([
8032
- "public",
8033
- "authenticated",
8034
- "admin"
8035
- ]).optional(),
8036
- description: string().optional()
8037
- });
8038
- /**
8039
- * Cross-process route invocation envelope. The hub captures the
8040
- * request as plain data, ships it to the worker via Moleculer, and
8041
- * the worker runs the local handler against a capturing reply. The
8042
- * envelope returned describes what the handler intended (status,
8043
- * headers, body, or a redirect) so the hub can translate it back to
8044
- * the Fastify reply that's actually wired to the socket.
8078
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
8079
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
8080
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
8081
+ * (there is no `Object.prototype` in the chain), so those names are not
8082
+ * callable — they are simply "unknown function" at parse time.
8083
+ *
8084
+ * Every numeric argument is validated as a finite number and every numeric
8085
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
8086
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
8087
+ * closed rather than emitting a garbage value.
8045
8088
  */
8046
- var InvokeRequestSchema = object({
8047
- method: string(),
8048
- path: string(),
8049
- params: record(string(), string()),
8050
- query: record(string(), string()),
8051
- body: unknown(),
8052
- headers: record(string(), string()),
8053
- user: object({
8054
- id: string(),
8055
- username: string(),
8056
- isAdmin: boolean()
8057
- }).optional(),
8058
- scopedToken: unknown().optional()
8059
- });
8060
- var InvokeReplyEnvelopeSchema = object({
8061
- status: number().int(),
8062
- headers: record(string(), string()),
8063
- /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
8064
- * sending `body`. Status defaults to 302 when this is set unless
8065
- * the handler called `reply.code(...)` explicitly. */
8066
- redirectUrl: string().nullable(),
8067
- /** JSON-serializable body. `undefined` is treated as "no body". */
8068
- body: unknown().optional(),
8069
- /** Set when the handler called `reply.type(mime)`. */
8070
- contentType: string().optional()
8071
- });
8072
- method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
8073
- var ConfigTabDeclarationSchema = object({
8074
- id: string(),
8075
- label: string(),
8076
- icon: string(),
8077
- order: number().optional()
8078
- });
8079
- var ConfigSectionWithValuesSchema = object({
8080
- id: string(),
8081
- title: string(),
8082
- description: string().optional(),
8083
- style: _enum(["card", "accordion"]).optional(),
8084
- defaultCollapsed: boolean().optional(),
8085
- columns: union([
8086
- literal(1),
8087
- literal(2),
8088
- literal(3),
8089
- literal(4)
8090
- ]).optional(),
8091
- tab: string().optional(),
8092
- location: _enum(["settings", "top-tab"]).optional(),
8093
- order: number().optional(),
8094
- fields: array(any())
8095
- });
8096
- var SettingsSchemaWithValuesSchema = object({
8097
- tabs: array(ConfigTabDeclarationSchema).optional(),
8098
- sections: array(ConfigSectionWithValuesSchema)
8099
- });
8100
- /** Patch object — keys are field names, values are the new field values. */
8101
- var SettingsPatchSchema = record(string(), unknown());
8102
- /** Standard success response for update operations. */
8103
- var SettingsUpdateResultSchema = object({ success: literal(true) });
8104
- method(object({
8105
- addonId: string(),
8106
- nodeId: string().optional(),
8107
- overlay: record(string(), unknown()).optional(),
8108
- cap: string().optional()
8109
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8110
- addonId: string(),
8111
- nodeId: string().optional(),
8112
- patch: SettingsPatchSchema
8113
- }), SettingsUpdateResultSchema, {
8114
- kind: "mutation",
8115
- auth: "admin"
8116
- }), method(object({
8117
- addonId: string(),
8118
- deviceId: number(),
8119
- nodeId: string().optional()
8120
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8121
- addonId: string(),
8122
- deviceId: number(),
8123
- nodeId: string().optional(),
8124
- patch: SettingsPatchSchema
8125
- }), SettingsUpdateResultSchema, {
8126
- kind: "mutation",
8127
- auth: "admin"
8128
- });
8089
+ function asFiniteNumber(value, name, index) {
8090
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
8091
+ return value;
8092
+ }
8093
+ function asString$1(value, name, index) {
8094
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
8095
+ return value;
8096
+ }
8097
+ function finiteResult(value, name) {
8098
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
8099
+ return value;
8100
+ }
8101
+ function allFiniteNumbers(args, name) {
8102
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
8103
+ }
8104
+ var INF = Number.POSITIVE_INFINITY;
8105
+ var table = {
8106
+ min: {
8107
+ minArgs: 1,
8108
+ maxArgs: INF,
8109
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
8110
+ },
8111
+ max: {
8112
+ minArgs: 1,
8113
+ maxArgs: INF,
8114
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
8115
+ },
8116
+ abs: {
8117
+ minArgs: 1,
8118
+ maxArgs: 1,
8119
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
8120
+ },
8121
+ floor: {
8122
+ minArgs: 1,
8123
+ maxArgs: 1,
8124
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
8125
+ },
8126
+ ceil: {
8127
+ minArgs: 1,
8128
+ maxArgs: 1,
8129
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
8130
+ },
8131
+ sqrt: {
8132
+ minArgs: 1,
8133
+ maxArgs: 1,
8134
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
8135
+ },
8136
+ round: {
8137
+ minArgs: 1,
8138
+ maxArgs: 2,
8139
+ apply: (args) => {
8140
+ const x = asFiniteNumber(args[0], "round", 0);
8141
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
8142
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
8143
+ const factor = 10 ** digits;
8144
+ return finiteResult(Math.round(x * factor) / factor, "round");
8145
+ }
8146
+ },
8147
+ pow: {
8148
+ minArgs: 2,
8149
+ maxArgs: 2,
8150
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
8151
+ },
8152
+ clamp: {
8153
+ minArgs: 3,
8154
+ maxArgs: 3,
8155
+ apply: (args) => {
8156
+ const x = asFiniteNumber(args[0], "clamp", 0);
8157
+ const lo = asFiniteNumber(args[1], "clamp", 1);
8158
+ const hi = asFiniteNumber(args[2], "clamp", 2);
8159
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
8160
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
8161
+ }
8162
+ },
8163
+ avg: {
8164
+ minArgs: 1,
8165
+ maxArgs: INF,
8166
+ apply: (args) => {
8167
+ const nums = allFiniteNumbers(args, "avg");
8168
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
8169
+ }
8170
+ },
8171
+ sum: {
8172
+ minArgs: 1,
8173
+ maxArgs: INF,
8174
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
8175
+ },
8176
+ coalesce: {
8177
+ minArgs: 1,
8178
+ maxArgs: INF,
8179
+ apply: (args) => {
8180
+ for (const a of args) if (a !== null) return a;
8181
+ return null;
8182
+ }
8183
+ },
8184
+ age: {
8185
+ minArgs: 2,
8186
+ maxArgs: 2,
8187
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
8188
+ },
8189
+ convert: {
8190
+ minArgs: 3,
8191
+ maxArgs: 3,
8192
+ apply: (args, hooks) => {
8193
+ const x = asFiniteNumber(args[0], "convert", 0);
8194
+ const from = asString$1(args[1], "convert", 1).trim();
8195
+ const to = asString$1(args[2], "convert", 2).trim();
8196
+ if (hooks.convert) {
8197
+ const out = hooks.convert(x, from, to);
8198
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
8199
+ return finiteResult(out, "convert");
8200
+ }
8201
+ if (from === to) return x;
8202
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
8203
+ }
8204
+ }
8205
+ };
8206
+ Object.freeze(Object.assign(Object.create(null), table));
8207
+ /** The set of valid builtin names — used by the parser to reject unknown
8208
+ * callees at parse time (immediate author feedback). */
8209
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
8129
8210
  /**
8130
- * `addon-widgets-source` — collection cap exposing per-addon raw widget
8131
- * declarations. Mirrors the addon-pages split: every addon shipping
8132
- * widgets registers a provider on this collection cap; the hub-local
8133
- * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
8134
- * collection, stamps versioned `bundleUrl`s onto each declaration, and
8135
- * exposes the public listing surface that admin-ui consumes.
8136
- *
8137
- * The split exists because the public listing has a different output
8138
- * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
8139
- * per-provider raw declarations. Both ends flow through codegen.
8211
+ * Resource-bound constants for the safe expression engine.
8140
8212
  *
8141
- * Unified UI-contribution model (Task 10): a widget descriptor IS a
8142
- * `UiContribution` with `kind:'remote'`. The host renders it through the
8143
- * same `ContributionRenderer` / Module-Federation path as every other
8144
- * contributed UI surface — no bespoke widget-rendering path. The widget-
8145
- * only metadata (sizing hints, `requires`) lives as extra fields on the
8146
- * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
8147
- * `kind` / `remote`) carries identity + placement + the MF remote.
8213
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
8214
+ * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
8215
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
8216
+ * work a single author-supplied expression can request, so a hostile or
8217
+ * accidental pathological string can never spend unbounded CPU/memory.
8148
8218
  */
8149
- /** Where the widget makes sense to render — maps to a contribution `tab`. */
8150
- var WidgetHostEnum = _enum([
8151
- "device-tab",
8152
- "dashboard",
8153
- "integration-detail"
8154
- ]);
8155
- var WidgetSizeEnum = _enum([
8156
- "xs",
8157
- "sm",
8158
- "md",
8159
- "lg",
8160
- "xl"
8219
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
8220
+ * rejected without allocation. */
8221
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
8222
+ /** A legal binding / identifier name. */
8223
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
8224
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
8225
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
8226
+ var RESERVED_BINDING_NAMES = new Set([
8227
+ "now",
8228
+ "true",
8229
+ "false",
8230
+ "null"
8161
8231
  ]);
8162
8232
  /**
8163
- * MF remote descriptor — mirrors `UiContributionRemote` from
8164
- * `capability-definition.ts`. Widget remotes expose a single
8165
- * `'./widgets'` module whose default export is a
8166
- * `Record<componentKey, Component>` map; `componentKey` (the widget
8167
- * `stableId`) picks the entry the host mounts.
8168
- */
8169
- var WidgetRemoteSchema = object({
8170
- remoteName: string(),
8171
- exposedModule: string(),
8172
- componentKey: string().optional()
8173
- });
8174
- /**
8175
- * One widget declaration — a `UiContribution` (`kind:'remote'`) plus
8176
- * widget-only metadata. The `UiContribution` core fields:
8177
- *
8178
- * - `tab` — where the widget hosts. A widget that runs on the
8179
- * dashboard declares `tab:'dashboard'`; a device-tab
8180
- * widget declares the target device-detail tab id.
8181
- * - `subTab` — optional sub-tab within `tab`.
8182
- * - `label` — operator-facing label.
8183
- * - `order` — ordering within `(tab, subTab)`.
8184
- * - `kind` — always `'remote'` for widgets.
8185
- * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
8186
- *
8187
- * Widget-only fields retained alongside the contribution core:
8188
- *
8189
- * - `stableId` — stable identity within the addon (the MF
8190
- * `componentKey`; kept top-level so consumers have
8191
- * a stable key without reaching into `remote`).
8192
- * - `description` / `icon` — picker metadata.
8193
- * - `bundle` — entry filename inside the addon `dist/` dir; the
8194
- * aggregator stamps a versioned `bundleUrl` from it.
8195
- * - `hosts` — every host the widget supports (a widget can run
8196
- * both on the dashboard and a device tab). `tab`
8197
- * is the PRIMARY host; `hosts` is the full set the
8198
- * picker filters on.
8199
- * - `requires` — host-context requirements validated at mount.
8200
- * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
8201
- * — dashboard placement hints.
8233
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
8234
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
8235
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
8236
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
8237
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
8238
+ * is a parse error with a source position, so member access / assignment /
8239
+ * template literals are lexically impossible.
8202
8240
  */
8203
- var WidgetMetadataSchema = object({
8204
- /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
8205
- tab: string(),
8206
- /** Optional sub-tab within `tab`. */
8207
- subTab: string().optional(),
8208
- /** Operator-facing label. */
8209
- label: string(),
8210
- /** Ordering within `(tab, subTab)`, ascending. */
8211
- order: number().optional(),
8212
- /** Always `'remote'` — a widget is a Module Federation remote. */
8213
- kind: literal("remote"),
8214
- /** MF remote descriptor. */
8215
- remote: WidgetRemoteSchema,
8216
- /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
8217
- stableId: string(),
8218
- description: string().optional(),
8219
- icon: string().optional(),
8220
- /**
8221
- * Bundle filename inside the addon's `dist/` dir served at
8222
- * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
8223
- * this is always `'remoteEntry.js'` — the value is kept on the
8224
- * metadata so the static-file route can compute an mtime-based
8225
- * cache-buster URL without a separate filesystem stat.
8226
- */
8227
- bundle: string(),
8228
- /** Every host the widget supports. The picker filters on this set. */
8229
- hosts: array(WidgetHostEnum).readonly(),
8230
- /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
8231
- requires: object({
8232
- deviceContext: boolean().default(false),
8233
- integrationContext: boolean().default(false)
8234
- }),
8235
- /**
8236
- * Loadable BEFORE authentication. The normal widget registry listing
8237
- * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
8238
- * (the login page) cannot discover a widget through it. A widget that
8239
- * declares `preAuth: true` marks itself as safe to mount on a pre-auth
8240
- * screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
8241
- * login-method contribution channel (see `login-method.cap.ts`) rather
8242
- * than the authenticated registry, and its bundle is served by the
8243
- * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
8244
- */
8245
- preAuth: boolean().optional().default(false),
8246
- /** Dashboard placement HINTS (operator can override per instance). */
8247
- defaultSize: WidgetSizeEnum.default("md"),
8248
- allowedSizes: array(WidgetSizeEnum).readonly().default([
8249
- "sm",
8250
- "md",
8251
- "lg"
8252
- ]),
8241
+ var KEYWORDS = new Set([
8242
+ "true",
8243
+ "false",
8244
+ "null"
8245
+ ]);
8246
+ function isDigit(ch) {
8247
+ return ch >= "0" && ch <= "9";
8248
+ }
8249
+ function isIdentStart(ch) {
8250
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
8251
+ }
8252
+ function isIdentPart(ch) {
8253
+ return isIdentStart(ch) || isDigit(ch);
8254
+ }
8255
+ function isWhitespace(ch) {
8256
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
8257
+ }
8258
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
8259
+ * Throws `ExpressionParseError` on any illegal character or unterminated
8260
+ * string. */
8261
+ function tokenize(source) {
8262
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
8263
+ const tokens = [];
8264
+ let i = 0;
8265
+ const n = source.length;
8266
+ while (i < n) {
8267
+ const ch = source[i];
8268
+ if (isWhitespace(ch)) {
8269
+ i += 1;
8270
+ continue;
8271
+ }
8272
+ if (isDigit(ch)) {
8273
+ const start = i;
8274
+ while (i < n && isDigit(source[i])) i += 1;
8275
+ if (i < n && source[i] === ".") {
8276
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
8277
+ i += 1;
8278
+ while (i < n && isDigit(source[i])) i += 1;
8279
+ }
8280
+ const text = source.slice(start, i);
8281
+ const value = Number(text);
8282
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
8283
+ tokens.push({
8284
+ type: "number",
8285
+ value,
8286
+ pos: start
8287
+ });
8288
+ continue;
8289
+ }
8290
+ if (ch === "'" || ch === "\"") {
8291
+ const quote = ch;
8292
+ const start = i;
8293
+ i += 1;
8294
+ let out = "";
8295
+ let closed = false;
8296
+ while (i < n) {
8297
+ const c = source[i];
8298
+ if (c === "\\") {
8299
+ const next = i + 1 < n ? source[i + 1] : "";
8300
+ if (next === "\\" || next === "'" || next === "\"") {
8301
+ out += next;
8302
+ i += 2;
8303
+ continue;
8304
+ }
8305
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
8306
+ }
8307
+ if (c === quote) {
8308
+ closed = true;
8309
+ i += 1;
8310
+ break;
8311
+ }
8312
+ out += c;
8313
+ i += 1;
8314
+ }
8315
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
8316
+ tokens.push({
8317
+ type: "string",
8318
+ value: out,
8319
+ pos: start
8320
+ });
8321
+ continue;
8322
+ }
8323
+ if (isIdentStart(ch)) {
8324
+ const start = i;
8325
+ while (i < n && isIdentPart(source[i])) i += 1;
8326
+ const text = source.slice(start, i);
8327
+ if (KEYWORDS.has(text)) tokens.push({
8328
+ type: "keyword",
8329
+ keyword: keywordOf(text),
8330
+ pos: start
8331
+ });
8332
+ else tokens.push({
8333
+ type: "identifier",
8334
+ name: text,
8335
+ pos: start
8336
+ });
8337
+ continue;
8338
+ }
8339
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
8340
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
8341
+ tokens.push({
8342
+ type: "punct",
8343
+ punct: two,
8344
+ pos: i
8345
+ });
8346
+ i += 2;
8347
+ continue;
8348
+ }
8349
+ if (isSinglePunct(ch)) {
8350
+ tokens.push({
8351
+ type: "punct",
8352
+ punct: ch,
8353
+ pos: i
8354
+ });
8355
+ i += 1;
8356
+ continue;
8357
+ }
8358
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
8359
+ }
8360
+ tokens.push({
8361
+ type: "eof",
8362
+ pos: n
8363
+ });
8364
+ return tokens;
8365
+ }
8366
+ function keywordOf(text) {
8367
+ if (text === "true") return "true";
8368
+ if (text === "false") return "false";
8369
+ return "null";
8370
+ }
8371
+ function isSinglePunct(ch) {
8372
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
8373
+ }
8374
+ /**
8375
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
8376
+ *
8377
+ * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
8378
+ * → relational → additive → multiplicative → unary `! -` → call / primary.
8379
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
8380
+ * string validated against the builtin table at parse time, so an unknown
8381
+ * function is rejected immediately (author feedback) and a persisted expression
8382
+ * that references a since-removed builtin degrades at read.
8383
+ *
8384
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
8385
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
8386
+ */
8387
+ /** Binary/logical operator precedence (higher binds tighter). */
8388
+ var BINARY_PRECEDENCE = {
8389
+ "||": 1,
8390
+ "&&": 2,
8391
+ "==": 3,
8392
+ "!=": 3,
8393
+ "<": 4,
8394
+ "<=": 4,
8395
+ ">": 4,
8396
+ ">=": 4,
8397
+ "+": 5,
8398
+ "-": 5,
8399
+ "*": 6,
8400
+ "/": 6,
8401
+ "%": 6
8402
+ };
8403
+ function isLogicalOp(op) {
8404
+ return op === "&&" || op === "||";
8405
+ }
8406
+ function isBinaryOp(op) {
8407
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
8408
+ }
8409
+ var Parser = class {
8410
+ tokens;
8411
+ pos = 0;
8412
+ nodeCount = 0;
8413
+ identifiers = /* @__PURE__ */ new Set();
8414
+ callees = /* @__PURE__ */ new Set();
8415
+ constructor(tokens) {
8416
+ this.tokens = tokens;
8417
+ }
8418
+ parse() {
8419
+ const ast = this.parseTernary();
8420
+ const tok = this.peek();
8421
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
8422
+ return {
8423
+ ast,
8424
+ identifiers: this.identifiers,
8425
+ callees: this.callees,
8426
+ nodeCount: this.nodeCount
8427
+ };
8428
+ }
8429
+ peek() {
8430
+ return this.tokens[this.pos];
8431
+ }
8432
+ next() {
8433
+ return this.tokens[this.pos++];
8434
+ }
8435
+ /** Consume a punctuator token, erroring if the next token isn't it. */
8436
+ expectPunct(punct) {
8437
+ const tok = this.peek();
8438
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
8439
+ this.pos += 1;
8440
+ }
8441
+ matchPunct(punct) {
8442
+ const tok = this.peek();
8443
+ if (tok.type === "punct" && tok.punct === punct) {
8444
+ this.pos += 1;
8445
+ return true;
8446
+ }
8447
+ return false;
8448
+ }
8449
+ countNode() {
8450
+ this.nodeCount += 1;
8451
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
8452
+ }
8453
+ parseTernary() {
8454
+ const test = this.parseBinary(1);
8455
+ if (this.matchPunct("?")) {
8456
+ const consequent = this.parseTernary();
8457
+ this.expectPunct(":");
8458
+ const alternate = this.parseTernary();
8459
+ this.countNode();
8460
+ return {
8461
+ kind: "conditional",
8462
+ test,
8463
+ consequent,
8464
+ alternate
8465
+ };
8466
+ }
8467
+ return test;
8468
+ }
8469
+ parseBinary(minPrec) {
8470
+ let left = this.parseUnary();
8471
+ for (;;) {
8472
+ const tok = this.peek();
8473
+ if (tok.type !== "punct") break;
8474
+ const prec = BINARY_PRECEDENCE[tok.punct];
8475
+ if (prec === void 0 || prec < minPrec) break;
8476
+ const op = tok.punct;
8477
+ this.pos += 1;
8478
+ const right = this.parseBinary(prec + 1);
8479
+ this.countNode();
8480
+ if (isLogicalOp(op)) left = {
8481
+ kind: "logical",
8482
+ op,
8483
+ left,
8484
+ right
8485
+ };
8486
+ else if (isBinaryOp(op)) left = {
8487
+ kind: "binary",
8488
+ op,
8489
+ left,
8490
+ right
8491
+ };
8492
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
8493
+ }
8494
+ return left;
8495
+ }
8496
+ parseUnary() {
8497
+ const tok = this.peek();
8498
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
8499
+ const op = tok.punct;
8500
+ this.pos += 1;
8501
+ const operand = this.parseUnary();
8502
+ this.countNode();
8503
+ return {
8504
+ kind: "unary",
8505
+ op,
8506
+ operand
8507
+ };
8508
+ }
8509
+ return this.parsePrimary();
8510
+ }
8511
+ parsePrimary() {
8512
+ const tok = this.next();
8513
+ switch (tok.type) {
8514
+ case "number":
8515
+ this.countNode();
8516
+ return {
8517
+ kind: "literal",
8518
+ value: tok.value
8519
+ };
8520
+ case "string":
8521
+ this.countNode();
8522
+ return {
8523
+ kind: "literal",
8524
+ value: tok.value
8525
+ };
8526
+ case "keyword":
8527
+ this.countNode();
8528
+ return {
8529
+ kind: "literal",
8530
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
8531
+ };
8532
+ case "identifier": {
8533
+ const nextTok = this.peek();
8534
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
8535
+ this.identifiers.add(tok.name);
8536
+ this.countNode();
8537
+ return {
8538
+ kind: "identifier",
8539
+ name: tok.name
8540
+ };
8541
+ }
8542
+ case "punct":
8543
+ if (tok.punct === "(") {
8544
+ const inner = this.parseTernary();
8545
+ this.expectPunct(")");
8546
+ return inner;
8547
+ }
8548
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
8549
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
8550
+ }
8551
+ }
8552
+ parseCall(callee, pos) {
8553
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
8554
+ this.expectPunct("(");
8555
+ const args = [];
8556
+ if (!this.matchPunct(")")) for (;;) {
8557
+ args.push(this.parseTernary());
8558
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
8559
+ if (this.matchPunct(",")) continue;
8560
+ this.expectPunct(")");
8561
+ break;
8562
+ }
8563
+ this.callees.add(callee);
8564
+ this.countNode();
8565
+ return {
8566
+ kind: "call",
8567
+ callee,
8568
+ args
8569
+ };
8570
+ }
8571
+ };
8572
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
8573
+ * `ExpressionParseError` on any lexical or grammatical failure. */
8574
+ function parseExpression(source) {
8575
+ return new Parser(tokenize(source)).parse();
8576
+ }
8577
+ /**
8578
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
8579
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
8580
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
8581
+ * one per read on a hot resolve path.
8582
+ *
8583
+ * The cache is a module-level singleton: entries are pure, content-addressed
8584
+ * ASTs keyed by the raw source string, so sharing one instance across all
8585
+ * callers is safe and maximises hit rate.
8586
+ */
8587
+ var cache = /* @__PURE__ */ new Map();
8588
+ function getCached(source) {
8589
+ const hit = cache.get(source);
8590
+ if (hit !== void 0) {
8591
+ cache.delete(source);
8592
+ cache.set(source, hit);
8593
+ return hit;
8594
+ }
8595
+ let result;
8596
+ try {
8597
+ result = {
8598
+ ok: true,
8599
+ parsed: parseExpression(source)
8600
+ };
8601
+ } catch (err) {
8602
+ result = {
8603
+ ok: false,
8604
+ error: err instanceof ExpressionParseError ? err.message : String(err)
8605
+ };
8606
+ }
8607
+ cache.set(source, result);
8608
+ if (cache.size > 256) {
8609
+ const oldest = cache.keys().next().value;
8610
+ if (oldest !== void 0) cache.delete(oldest);
8611
+ }
8612
+ return result;
8613
+ }
8614
+ /** Compile `source`, returning a discriminated result instead of throwing.
8615
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
8616
+ function compileExpressionSafe(source) {
8617
+ return getCached(source);
8618
+ }
8619
+ Object.freeze({});
8620
+ /**
8621
+ * Author-time validation. Returns `null` when the source is valid, else a
8622
+ * human-readable error message. Checks: the expression compiles; binding count
8623
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
8624
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
8625
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
8626
+ */
8627
+ function validateExpressionSource(src) {
8628
+ const names = Object.keys(src.bindings);
8629
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
8630
+ for (const name of names) {
8631
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
8632
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
8633
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
8634
+ }
8635
+ const compiled = compileExpressionSafe(src.expr);
8636
+ if (!compiled.ok) return compiled.error;
8637
+ const bound = new Set(names);
8638
+ for (const id of compiled.parsed.identifiers) {
8639
+ if (id === "now") continue;
8640
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
8641
+ }
8642
+ return null;
8643
+ }
8644
+ var ExpressionBindingSourceSchema = union([
8645
+ object({
8646
+ kind: literal("field").optional(),
8647
+ sourceKey: string(),
8648
+ cap: string(),
8649
+ fieldPath: string()
8650
+ }),
8651
+ object({
8652
+ kind: literal("literal"),
8653
+ value: union([
8654
+ string(),
8655
+ number(),
8656
+ boolean(),
8657
+ _null()
8658
+ ])
8659
+ }),
8660
+ object({
8661
+ kind: literal("global"),
8662
+ sourceStableId: string(),
8663
+ cap: string(),
8664
+ fieldPath: string()
8665
+ })
8666
+ ]);
8667
+ object({
8668
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
8669
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
8670
+ }).superRefine((src, ctx) => {
8671
+ const err = validateExpressionSource(src);
8672
+ if (err !== null) ctx.addIssue({
8673
+ code: "custom",
8674
+ message: err,
8675
+ path: ["expr"]
8676
+ });
8677
+ });
8678
+ /** How a leaf compares a device field to a value. Derived from the field's
8679
+ * `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
8680
+ var AutomationConditionOperatorSchema = _enum([
8681
+ "eq",
8682
+ "ne",
8683
+ "gt",
8684
+ "gte",
8685
+ "lt",
8686
+ "lte",
8687
+ "contains",
8688
+ "in"
8689
+ ]);
8690
+ var AutomationConditionLeafSchema = object({
8691
+ kind: literal("condition"),
8692
+ deviceId: number().int().nonnegative(),
8693
+ cap: string().min(1),
8694
+ fieldPath: string().min(1),
8695
+ operator: AutomationConditionOperatorSchema,
8696
+ value: union([
8697
+ string(),
8698
+ number(),
8699
+ boolean(),
8700
+ array(union([string(), number()]))
8701
+ ])
8702
+ });
8703
+ /**
8704
+ * The expression leaf, declared as a plain object rather than an intersection
8705
+ * with {@link ExpressionSourceSchema}: a discriminated union has to be able to
8706
+ * read `kind` off each option, and an intersection hides it. The author-time
8707
+ * validation is the SAME function `ExpressionSourceSchema` runs, so the two
8708
+ * cannot drift — an expression that one accepts, the other accepts.
8709
+ */
8710
+ var AutomationConditionExpressionSchema = object({
8711
+ kind: literal("expression"),
8712
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
8713
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
8714
+ }).superRefine((src, ctx) => {
8715
+ const err = validateExpressionSource(src);
8716
+ if (err !== null) ctx.addIssue({
8717
+ code: "custom",
8718
+ message: err,
8719
+ path: ["expr"]
8720
+ });
8721
+ });
8722
+ var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
8723
+ object({
8724
+ kind: literal("all"),
8725
+ children: array(AutomationConditionSchema)
8726
+ }),
8727
+ object({
8728
+ kind: literal("any"),
8729
+ children: array(AutomationConditionSchema)
8730
+ }),
8731
+ object({
8732
+ kind: literal("not"),
8733
+ child: AutomationConditionSchema
8734
+ }),
8735
+ AutomationConditionLeafSchema,
8736
+ AutomationConditionExpressionSchema
8737
+ ]));
8738
+ /**
8739
+ * What starts a run.
8740
+ *
8741
+ * D8 compliance, and it is the reason `device-state` is not merely an event
8742
+ * subscription: the trigger evaluates against the **state mirror**, which is
8743
+ * reconciled, and an event only WAKES the evaluation. A dropped event therefore
8744
+ * DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
8745
+ * already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
8746
+ * not.
8747
+ */
8748
+ var AutomationTriggerSchema = discriminatedUnion("kind", [
8749
+ object({
8750
+ kind: literal("device-state"),
8751
+ deviceId: number().int().nonnegative(),
8752
+ cap: string().min(1),
8753
+ fieldPath: string().min(1),
8754
+ /** Fire when the field takes this value. Omit to fire on any change. */
8755
+ becomes: union([
8756
+ string(),
8757
+ number(),
8758
+ boolean()
8759
+ ]).optional(),
8760
+ /** Only on a CHANGE of value, not on every re-report. */
8761
+ edge: boolean().optional(),
8762
+ /** The condition must hold this long before the run starts. */
8763
+ forMs: number().int().min(0).max(864e5).optional(),
8764
+ /** Collapse a burst into one run. */
8765
+ debounceMs: number().int().min(0).max(6e5).optional()
8766
+ }),
8767
+ object({
8768
+ kind: literal("device-event"),
8769
+ /** An `EventCategory` value. */
8770
+ category: string().min(1),
8771
+ deviceId: number().int().nonnegative().optional()
8772
+ }),
8773
+ object({
8774
+ kind: literal("schedule"),
8775
+ cron: string().min(1).max(120)
8776
+ }),
8777
+ object({ kind: literal("manual") })
8778
+ ]);
8779
+ /**
8780
+ * One action step.
8781
+ *
8782
+ * `wait` and `cap` are `NcRuleActionSchema`'s two members, kept structurally
8783
+ * identical so `NcRuleActionRunner` runs them unchanged — its device-scope
8784
+ * check, stop-at-first-failure and per-sequence throttle are the whole reason
8785
+ * to reuse it, and none of them are re-implemented here.
8786
+ *
8787
+ * **The one divergence, and it is forced.** `NcRuleActionSchema.cap.deviceId` is
8788
+ * a literal `z.number().int()`, and the NC runner's own `RunSequencesInput`
8789
+ * documents its subject device as *"for the log tag, never for routing"*. So an
8790
+ * NC action can never target the device that triggered it — which is fine for
8791
+ * the NC (its rules already scope to a device) and fatal for an automation
8792
+ * ("sound the siren of the camera that saw the person"). `deviceId` therefore
8793
+ * also accepts `{ $var }`, resolved from the run's `vars` bag BEFORE the runner
8794
+ * is called. The runner still receives a number and is untouched; the
8795
+ * resolution is the recipe's job, not the runner's.
8796
+ */
8797
+ var AutomationActionSchema = discriminatedUnion("kind", [
8798
+ object({
8799
+ kind: literal("wait"),
8800
+ seconds: number().min(0).max(300)
8801
+ }),
8802
+ object({
8803
+ kind: literal("cap"),
8804
+ deviceId: union([number().int(), object({ $var: string().min(1) })]),
8805
+ cap: string().min(1),
8806
+ method: string().min(1),
8807
+ /** Values may carry `{{vars.x}}` slots, which SUBSTITUTE and do not
8808
+ * evaluate (§3.2.3). Anything beyond substitution is the expression leaf. */
8809
+ args: record(string(), unknown()).optional()
8810
+ }),
8811
+ object({
8812
+ kind: literal("code"),
8813
+ /** Compiled into the automation's OWN block by esbuild — not a third
8814
+ * runtime, not a `vm`, and not dynamically evaluated. */
8815
+ code: string().min(1).max(2e4)
8816
+ })
8817
+ ]);
8818
+ object({
8819
+ triggers: array(AutomationTriggerSchema),
8820
+ conditions: AutomationConditionSchema.optional(),
8821
+ actions: array(AutomationActionSchema)
8822
+ });
8823
+ /**
8824
+ * `addon-pages` — system-scoped singleton aggregator cap. Public-facing
8825
+ * surface that admin-ui consumes through `useAddonPagesListPages()`.
8826
+ *
8827
+ * The provider iterates every `addon-pages-source` (collection) provider
8828
+ * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
8829
+ * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
8830
+ * filesystem `mtime` cache-buster lets the browser pick up addon
8831
+ * rebuilds without manual reload.
8832
+ *
8833
+ * The hub-local builtin `addon-pages-aggregator` (see
8834
+ * `@camstack/system/builtins/addon-pages-aggregator`) registers the
8835
+ * provider. Splitting the public aggregator from the raw collection
8836
+ * keeps both ends in codegen — there's no hand-written
8837
+ * `addon-pages.router.ts` wrapper anymore.
8838
+ */
8839
+ var AddonPageDeclarationSchema$1 = object({
8840
+ id: string(),
8841
+ label: string(),
8842
+ icon: string(),
8843
+ path: string(),
8844
+ remoteName: string(),
8845
+ bundle: string(),
8846
+ section: string().optional(),
8847
+ sectionLabel: string().optional()
8848
+ });
8849
+ var AddonPageInfoSchema = object({
8850
+ addonId: string(),
8851
+ page: AddonPageDeclarationSchema$1,
8852
+ bundleUrl: string()
8853
+ });
8854
+ method(_void(), array(AddonPageInfoSchema).readonly());
8855
+ /**
8856
+ * `addon-pages-source` — collection cap exposing per-provider raw page
8857
+ * declarations. Every addon that contributes a UI page registers a
8858
+ * provider here. The hub-side singleton aggregator (`addon-pages` cap,
8859
+ * see `addon-pages.cap.ts`) walks this collection, stamps versioned
8860
+ * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
8861
+ * that admin-ui consumes.
8862
+ *
8863
+ * The split exists because the public listing has a different output
8864
+ * shape than the per-provider raw declarations, and we want both ends
8865
+ * to flow through codegen instead of relying on a hand-written wrapper.
8866
+ */
8867
+ var AddonPageDeclarationSchema = object({
8868
+ id: string(),
8869
+ label: string(),
8870
+ icon: string(),
8871
+ path: string(),
8872
+ /**
8873
+ * Module Federation remote name — must match the `name` field on the
8874
+ * page addon's `federation()` plugin config. Used by admin-ui's
8875
+ * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
8876
+ * Conventionally `addon_<id>_page` (snake_case; MF names cannot
8877
+ * contain hyphens).
8878
+ */
8879
+ remoteName: string(),
8880
+ /**
8881
+ * Bundle filename inside the addon's `dist/` dir served at
8882
+ * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
8883
+ * is always `'remoteEntry.js'`; the value is kept on the metadata so
8884
+ * the static-file route can compute an mtime-based cache-buster URL
8885
+ * without a separate filesystem stat.
8886
+ */
8887
+ bundle: string(),
8888
+ /**
8889
+ * Sidebar section this page docks into. Well-known ids: `'detection'`,
8890
+ * `'cluster'`, `'administration'` — the page renders inside that group.
8891
+ * Any OTHER string creates (or joins) a custom section rendered after
8892
+ * the built-in groups; its label comes from `sectionLabel` (first
8893
+ * declaration wins), falling back to the id. Absent → the legacy
8894
+ * "Addon Pages" group.
8895
+ */
8896
+ section: string().optional(),
8897
+ /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
8898
+ sectionLabel: string().optional()
8899
+ });
8900
+ method(_void(), array(AddonPageDeclarationSchema).readonly());
8901
+ var AddonHttpRouteSchema = object({
8902
+ method: _enum([
8903
+ "GET",
8904
+ "POST",
8905
+ "PUT",
8906
+ "DELETE",
8907
+ "PATCH"
8908
+ ]),
8909
+ path: string(),
8910
+ access: _enum([
8911
+ "public",
8912
+ "authenticated",
8913
+ "admin"
8914
+ ]).optional(),
8915
+ description: string().optional()
8916
+ });
8917
+ /**
8918
+ * Cross-process route invocation envelope. The hub captures the
8919
+ * request as plain data, ships it to the worker via Moleculer, and
8920
+ * the worker runs the local handler against a capturing reply. The
8921
+ * envelope returned describes what the handler intended (status,
8922
+ * headers, body, or a redirect) so the hub can translate it back to
8923
+ * the Fastify reply that's actually wired to the socket.
8924
+ */
8925
+ var InvokeRequestSchema = object({
8926
+ method: string(),
8927
+ path: string(),
8928
+ params: record(string(), string()),
8929
+ query: record(string(), string()),
8930
+ body: unknown(),
8931
+ headers: record(string(), string()),
8932
+ user: object({
8933
+ id: string(),
8934
+ username: string(),
8935
+ isAdmin: boolean()
8936
+ }).optional(),
8937
+ scopedToken: unknown().optional()
8938
+ });
8939
+ var InvokeReplyEnvelopeSchema = object({
8940
+ status: number().int(),
8941
+ headers: record(string(), string()),
8942
+ /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
8943
+ * sending `body`. Status defaults to 302 when this is set unless
8944
+ * the handler called `reply.code(...)` explicitly. */
8945
+ redirectUrl: string().nullable(),
8946
+ /** JSON-serializable body. `undefined` is treated as "no body". */
8947
+ body: unknown().optional(),
8948
+ /** Set when the handler called `reply.type(mime)`. */
8949
+ contentType: string().optional()
8950
+ });
8951
+ method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
8952
+ var ConfigTabDeclarationSchema = object({
8953
+ id: string(),
8954
+ label: string(),
8955
+ icon: string(),
8956
+ order: number().optional()
8957
+ });
8958
+ var ConfigSectionWithValuesSchema = object({
8959
+ id: string(),
8960
+ title: string(),
8961
+ description: string().optional(),
8962
+ style: _enum(["card", "accordion"]).optional(),
8963
+ defaultCollapsed: boolean().optional(),
8964
+ columns: union([
8965
+ literal(1),
8966
+ literal(2),
8967
+ literal(3),
8968
+ literal(4)
8969
+ ]).optional(),
8970
+ tab: string().optional(),
8971
+ location: _enum(["settings", "top-tab"]).optional(),
8972
+ order: number().optional(),
8973
+ fields: array(any())
8974
+ });
8975
+ var SettingsSchemaWithValuesSchema = object({
8976
+ tabs: array(ConfigTabDeclarationSchema).optional(),
8977
+ sections: array(ConfigSectionWithValuesSchema)
8978
+ });
8979
+ /** Patch object — keys are field names, values are the new field values. */
8980
+ var SettingsPatchSchema = record(string(), unknown());
8981
+ /** Standard success response for update operations. */
8982
+ var SettingsUpdateResultSchema = object({ success: literal(true) });
8983
+ method(object({
8984
+ addonId: string(),
8985
+ nodeId: string().optional(),
8986
+ overlay: record(string(), unknown()).optional(),
8987
+ cap: string().optional()
8988
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8989
+ addonId: string(),
8990
+ nodeId: string().optional(),
8991
+ patch: SettingsPatchSchema
8992
+ }), SettingsUpdateResultSchema, {
8993
+ kind: "mutation",
8994
+ auth: "admin"
8995
+ }), method(object({
8996
+ addonId: string(),
8997
+ deviceId: number(),
8998
+ nodeId: string().optional()
8999
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9000
+ addonId: string(),
9001
+ deviceId: number(),
9002
+ nodeId: string().optional(),
9003
+ patch: SettingsPatchSchema
9004
+ }), SettingsUpdateResultSchema, {
9005
+ kind: "mutation",
9006
+ auth: "admin"
9007
+ });
9008
+ /**
9009
+ * `addon-widgets-source` — collection cap exposing per-addon raw widget
9010
+ * declarations. Mirrors the addon-pages split: every addon shipping
9011
+ * widgets registers a provider on this collection cap; the hub-local
9012
+ * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
9013
+ * collection, stamps versioned `bundleUrl`s onto each declaration, and
9014
+ * exposes the public listing surface that admin-ui consumes.
9015
+ *
9016
+ * The split exists because the public listing has a different output
9017
+ * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
9018
+ * per-provider raw declarations. Both ends flow through codegen.
9019
+ *
9020
+ * Unified UI-contribution model (Task 10): a widget descriptor IS a
9021
+ * `UiContribution` with `kind:'remote'`. The host renders it through the
9022
+ * same `ContributionRenderer` / Module-Federation path as every other
9023
+ * contributed UI surface — no bespoke widget-rendering path. The widget-
9024
+ * only metadata (sizing hints, `requires`) lives as extra fields on the
9025
+ * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
9026
+ * `kind` / `remote`) carries identity + placement + the MF remote.
9027
+ */
9028
+ /** Where the widget makes sense to render — maps to a contribution `tab`. */
9029
+ var WidgetHostEnum = _enum([
9030
+ "device-tab",
9031
+ "dashboard",
9032
+ "integration-detail"
9033
+ ]);
9034
+ var WidgetSizeEnum = _enum([
9035
+ "xs",
9036
+ "sm",
9037
+ "md",
9038
+ "lg",
9039
+ "xl"
9040
+ ]);
9041
+ /**
9042
+ * MF remote descriptor — mirrors `UiContributionRemote` from
9043
+ * `capability-definition.ts`. Widget remotes expose a single
9044
+ * `'./widgets'` module whose default export is a
9045
+ * `Record<componentKey, Component>` map; `componentKey` (the widget
9046
+ * `stableId`) picks the entry the host mounts.
9047
+ */
9048
+ var WidgetRemoteSchema = object({
9049
+ remoteName: string(),
9050
+ exposedModule: string(),
9051
+ componentKey: string().optional()
9052
+ });
9053
+ /**
9054
+ * One widget declaration — a `UiContribution` (`kind:'remote'`) plus
9055
+ * widget-only metadata. The `UiContribution` core fields:
9056
+ *
9057
+ * - `tab` — where the widget hosts. A widget that runs on the
9058
+ * dashboard declares `tab:'dashboard'`; a device-tab
9059
+ * widget declares the target device-detail tab id.
9060
+ * - `subTab` — optional sub-tab within `tab`.
9061
+ * - `label` — operator-facing label.
9062
+ * - `order` — ordering within `(tab, subTab)`.
9063
+ * - `kind` — always `'remote'` for widgets.
9064
+ * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
9065
+ *
9066
+ * Widget-only fields retained alongside the contribution core:
9067
+ *
9068
+ * - `stableId` — stable identity within the addon (the MF
9069
+ * `componentKey`; kept top-level so consumers have
9070
+ * a stable key without reaching into `remote`).
9071
+ * - `description` / `icon` — picker metadata.
9072
+ * - `bundle` — entry filename inside the addon `dist/` dir; the
9073
+ * aggregator stamps a versioned `bundleUrl` from it.
9074
+ * - `hosts` — every host the widget supports (a widget can run
9075
+ * both on the dashboard and a device tab). `tab`
9076
+ * is the PRIMARY host; `hosts` is the full set the
9077
+ * picker filters on.
9078
+ * - `requires` — host-context requirements validated at mount.
9079
+ * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
9080
+ * — dashboard placement hints.
9081
+ */
9082
+ var WidgetMetadataSchema = object({
9083
+ /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
9084
+ tab: string(),
9085
+ /** Optional sub-tab within `tab`. */
9086
+ subTab: string().optional(),
9087
+ /** Operator-facing label. */
9088
+ label: string(),
9089
+ /** Ordering within `(tab, subTab)`, ascending. */
9090
+ order: number().optional(),
9091
+ /** Always `'remote'` — a widget is a Module Federation remote. */
9092
+ kind: literal("remote"),
9093
+ /** MF remote descriptor. */
9094
+ remote: WidgetRemoteSchema,
9095
+ /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
9096
+ stableId: string(),
9097
+ description: string().optional(),
9098
+ icon: string().optional(),
9099
+ /**
9100
+ * Bundle filename inside the addon's `dist/` dir served at
9101
+ * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
9102
+ * this is always `'remoteEntry.js'` — the value is kept on the
9103
+ * metadata so the static-file route can compute an mtime-based
9104
+ * cache-buster URL without a separate filesystem stat.
9105
+ */
9106
+ bundle: string(),
9107
+ /** Every host the widget supports. The picker filters on this set. */
9108
+ hosts: array(WidgetHostEnum).readonly(),
9109
+ /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
9110
+ requires: object({
9111
+ deviceContext: boolean().default(false),
9112
+ integrationContext: boolean().default(false)
9113
+ }),
9114
+ /**
9115
+ * Loadable BEFORE authentication. The normal widget registry listing
9116
+ * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
9117
+ * (the login page) cannot discover a widget through it. A widget that
9118
+ * declares `preAuth: true` marks itself as safe to mount on a pre-auth
9119
+ * screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
9120
+ * login-method contribution channel (see `login-method.cap.ts`) rather
9121
+ * than the authenticated registry, and its bundle is served by the
9122
+ * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
9123
+ */
9124
+ preAuth: boolean().optional().default(false),
9125
+ /** Dashboard placement HINTS (operator can override per instance). */
9126
+ defaultSize: WidgetSizeEnum.default("md"),
9127
+ allowedSizes: array(WidgetSizeEnum).readonly().default([
9128
+ "sm",
9129
+ "md",
9130
+ "lg"
9131
+ ]),
8253
9132
  defaultColumns: number().int().min(1).max(12).default(6),
8254
9133
  defaultRows: number().int().min(1).max(12).default(1)
8255
9134
  });
@@ -11331,6 +12210,15 @@ method(object({
11331
12210
  }), method(ReleaseInputSchema.extend({ addonId: string() }), _void(), {
11332
12211
  kind: "mutation",
11333
12212
  auth: "admin"
12213
+ }), method(AdoptInputSchema.extend({ addonId: string() }), object({ jobId: string() }), {
12214
+ kind: "mutation",
12215
+ auth: "admin"
12216
+ }), method(object({
12217
+ addonId: string(),
12218
+ integrationId: string().optional()
12219
+ }), array(AdoptionJobSchema).readonly(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
12220
+ kind: "mutation",
12221
+ auth: "admin"
11334
12222
  }), method(ResyncInputSchema, ResyncResultSchema, {
11335
12223
  kind: "mutation",
11336
12224
  auth: "admin"
@@ -12053,7 +12941,7 @@ DeviceType.Camera, method(object({
12053
12941
  * Why: pub/sub routing over the system event-bus loses fidelity
12054
12942
  * (callback shape, QoS guarantees, will/retain semantics) and adds
12055
12943
  * refcount bookkeeping that addons would rather own themselves. The
12056
- * canonical consumer (`addon-export-ha-mqtt`) needs raw `mqtt.js`
12944
+ * canonical consumer needs raw `mqtt.js`
12057
12945
  * features anyway — give it the connection config, get out of the way.
12058
12946
  *
12059
12947
  * Consumer flow:
@@ -13246,8 +14134,35 @@ var NcDeliverySchema = _enum([
13246
14134
  "immediate",
13247
14135
  "track-end",
13248
14136
  "device-event",
13249
- "package-event"
14137
+ "package-event",
14138
+ "system-event"
13250
14139
  ]);
14140
+ /**
14141
+ * Stable Notification Center vocabulary over infrastructure/liveness events.
14142
+ * Bus categories are normalized into these intent-level kinds so rules do not
14143
+ * depend on a provider's raw event name or payload shape.
14144
+ */
14145
+ var NcSystemEventKindSchema = _enum([
14146
+ "camera-online",
14147
+ "camera-offline",
14148
+ "stream-online",
14149
+ "stream-offline",
14150
+ "node-online",
14151
+ "node-offline",
14152
+ "addon-update-available",
14153
+ "server-update-available"
14154
+ ]);
14155
+ /**
14156
+ * One coherent system-event condition. `kinds` is the required opt-in safety
14157
+ * gate; the remaining lists are optional narrowing filters relevant to the
14158
+ * selected kinds.
14159
+ */
14160
+ var NcSystemEventConditionSchema = object({
14161
+ kinds: array(NcSystemEventKindSchema).min(1),
14162
+ deviceIds: array(number().int()).min(1).optional(),
14163
+ nodeIds: array(string().min(1)).min(1).optional(),
14164
+ packageNames: array(string().min(1)).min(1).optional()
14165
+ });
13251
14166
  /** Weekly schedule — OR of windows; absence on the rule = always active. */
13252
14167
  var NcScheduleSchema = object({
13253
14168
  windows: array(object({
@@ -13552,6 +14467,8 @@ var NcConditionsSchema = object({
13552
14467
  "picked-up",
13553
14468
  "both"
13554
14469
  ]).optional(),
14470
+ /** Infrastructure/liveness/update event matcher (`system-event` delivery). */
14471
+ systemEvent: NcSystemEventConditionSchema.optional(),
13555
14472
  /**
13556
14473
  * PERSONAL-RULE custom zones (viewer-drawn). Inline normalized polygons
13557
14474
  * (MaskShape vocabulary). A record passes when its bbox overlaps ANY
@@ -13771,7 +14688,8 @@ var NcTestResultSchema = object({
13771
14688
  "object-event",
13772
14689
  "track",
13773
14690
  "device-event",
13774
- "package-event"
14691
+ "package-event",
14692
+ "system-event"
13775
14693
  ]),
13776
14694
  deviceId: number(),
13777
14695
  timestamp: number(),
@@ -13793,7 +14711,8 @@ var NcConditionDescriptorSchema = object({
13793
14711
  "schedule",
13794
14712
  "device",
13795
14713
  "package",
13796
- "occupancy"
14714
+ "occupancy",
14715
+ "system"
13797
14716
  ]),
13798
14717
  label: string(),
13799
14718
  /** Editor widget the UI renders — never hardcode per-condition forms. */
@@ -13811,7 +14730,8 @@ var NcConditionDescriptorSchema = object({
13811
14730
  "crossingSelect",
13812
14731
  "polygonDraw",
13813
14732
  "occupancy",
13814
- "deviceState"
14733
+ "deviceState",
14734
+ "systemEvent"
13815
14735
  ]),
13816
14736
  operator: _enum([
13817
14737
  "in",
@@ -13870,7 +14790,8 @@ var NcHistoryRecordKindSchema = _enum([
13870
14790
  "object-event",
13871
14791
  "track-end",
13872
14792
  "device-event",
13873
- "package-event"
14793
+ "package-event",
14794
+ "system-event"
13874
14795
  ]);
13875
14796
  /** Subject summary frozen on the row at fire time (survives rule/record edits). */
13876
14797
  var NcHistorySubjectSchema = object({
@@ -13878,7 +14799,14 @@ var NcHistorySubjectSchema = object({
13878
14799
  label: string().optional(),
13879
14800
  confidence: number().optional(),
13880
14801
  zones: array(string()),
13881
- timestamp: number()
14802
+ timestamp: number(),
14803
+ systemEvent: object({
14804
+ kind: NcSystemEventKindSchema,
14805
+ subject: string(),
14806
+ title: string(),
14807
+ body: string(),
14808
+ data: record(string(), unknown())
14809
+ }).optional()
13882
14810
  });
13883
14811
  /**
13884
14812
  * One delivery-history row. This is a read-only VIEW over the durable
@@ -14238,6 +15166,76 @@ object({
14238
15166
  * Each provider returns a static descriptor; the core enumerates them
14239
15167
  * to validate the `integration=` query param and resolve the consent
14240
15168
  * label + the scopes baked into the issued token.
15169
+ *
15170
+ * ## Declaring one
15171
+ *
15172
+ * An OAuth client is integration-specific knowledge — who the client is, what
15173
+ * it may ask for, where it may be sent — so it is declared by the ADDON that
15174
+ * owns the integration, never by the kernel and never as a branch inside
15175
+ * `oauth2-routes.ts` ([D101](../../../../docs/decisions/adr-0101.md)). Three
15176
+ * steps, no others:
15177
+ *
15178
+ * 1. Add `{ "name": "oauth-integration" }` to the addon's `camstack.addons[]`
15179
+ * manifest entry. This is also what tells the hub, at addon-LOAD time, that
15180
+ * a descriptor is owed — see "the boot window" below.
15181
+ * 2. Return a provider from `onInitialize()`:
15182
+ *
15183
+ * ```ts
15184
+ * const provider: IOauthIntegrationProvider = {
15185
+ * getDescriptor: async () => ({
15186
+ * integrationId: 'my-thing', // the `integration=` query param
15187
+ * displayName: 'My Thing',
15188
+ * requestedScopes: [ … ], // see below
15189
+ * allowedRedirectPrefixes: ['https://callback.example/'],
15190
+ * }),
15191
+ * }
15192
+ * return [{ capability: oauthIntegrationCapability, provider }]
15193
+ * ```
15194
+ *
15195
+ * The descriptor must be **static** — it is read on the authorize path, so
15196
+ * never put an await on network or disk behind it, and never register it
15197
+ * behind one either (a provider is registered only once `onInitialize`
15198
+ * RETURNS, so anything awaited before the return delays linking).
15199
+ * 3. Nothing else. There is no allow-list to join, no id to register with the
15200
+ * core, and no per-integration branch anywhere: `/api/oauth2/authorize` and
15201
+ * `/api/oauth2/integrations` are built from this collection alone.
15202
+ *
15203
+ * **Scopes. `requestedScopes` has exactly ONE meaning: what the integration
15204
+ * NEEDS to function.** Not a blast radius, not a conservative
15205
+ * under-declaration, not a description of some other path the addon happens to
15206
+ * have. Derive it from what the client actually calls **with this token** —
15207
+ * every tRPC path against `METHOD_ACCESS_MAP`, plus an `addon:` grant for every
15208
+ * addon HTTP route it posts to — and write the call that justifies each entry
15209
+ * next to it. Two integrations once used this field to mean two different
15210
+ * things; the operator ruled there is one meaning, and any third integration
15211
+ * inherits it (2026-08-09).
15212
+ *
15213
+ * This is not documentation, it is the ENFORCEMENT INPUT. Since
15214
+ * [D103](../../../../docs/decisions/adr-0103.md) the `/addon/:addonId/*` gate
15215
+ * checks an integration token's grant before letting it reach an
15216
+ * `access: 'authenticated'` route, so an **under-declaration is an integration
15217
+ * that stops working** — a missing `addon:` entry means `403 Token scope
15218
+ * mismatch` on every control the client tries to actuate. Widen the descriptor
15219
+ * honestly rather than weakening a check to make a route pass.
15220
+ *
15221
+ * Prefer a narrow `capability:` scope to a `category:` one unless the client
15222
+ * genuinely needs a whole family; a category scope grants every future member
15223
+ * of that category too. `category:system [create]` has been rejected once and
15224
+ * should stay rejected: it hands `addons.installPackage` to an integration.
15225
+ *
15226
+ * Calls the ADDON itself makes over `ctx.api` run as the addon and are not
15227
+ * scope-checked, so they are not what this field describes — but reaching the
15228
+ * addon's route in the first place IS, and that is the entry to declare.
15229
+ *
15230
+ * **The boot window.** An addon registers its provider after its runner forks
15231
+ * and initialises, so between hub start and that moment this collection is
15232
+ * incomplete and an `integrationId` can be legitimately absent. The core does
15233
+ * not wait, poll or cache around this ([D3](../../../../docs/decisions/adr-0003.md)):
15234
+ * it compares the manifest declarers against the registered providers and
15235
+ * answers `503 temporarily_unavailable` (with `Retry-After` and the pending
15236
+ * addon ids) instead of `400 unknown integration`, and reports
15237
+ * `complete: false` on `GET /api/oauth2/integrations`. A client should retry
15238
+ * while the list is incomplete rather than conclude the hub cannot do OAuth.
14241
15239
  */
14242
15240
  var OauthIntegrationDescriptorSchema = object({
14243
15241
  /** Stable id used as the `integration=` query param, e.g. 'export-alexa'. */
@@ -14268,7 +15266,30 @@ var OauthIntegrationDescriptorSchema = object({
14268
15266
  * present, /api/oauth2/authorize bakes THIS into the code instead of the
14269
15267
  * hub-global `publicHubUrl()`, so a forked exporter addon (which can't set
14270
15268
  * the hub's env) drives the claim that its cloud Lambda routes back on. */
14271
- hubUrl: string().optional()
15269
+ hubUrl: string().optional(),
15270
+ /**
15271
+ * How long a REFRESH token issued for this integration lives — seconds, or
15272
+ * `'never'` for a token minted with no `exp` claim at all. Omit to keep the
15273
+ * 30-day default, which is what every link used before this field existed.
15274
+ *
15275
+ * Declared here for the same reason `requestedScopes` is: the integration
15276
+ * knows what it needs. Amazon's account linking and a Home Assistant config
15277
+ * entry are both meant to survive indefinitely, and re-linking is a manual
15278
+ * user action, so a 30-day expiry silently unlinks a working integration.
15279
+ *
15280
+ * **The security posture, stated so it is owned deliberately.** A refresh
15281
+ * token that never expires is permanent access if it leaks. What bounds it is
15282
+ * revocation, not time: `oauthRefresh` re-reads the session on every use and
15283
+ * returns `null` once `revokedAt` is set, as does `oauthVerifyAccessToken`.
15284
+ * The one gap is the ACCESS token — it is a plain signed JWT that nothing
15285
+ * re-checks against the session on the `/trpc` and `/addon/*` paths, so
15286
+ * revoking a link takes effect there only after its remaining hour. That hour
15287
+ * is why the access TTL is not configurable.
15288
+ *
15289
+ * The value is baked into the authorization code at `/authorize` and travels
15290
+ * on the tokens, so editing this field changes FUTURE links only.
15291
+ */
15292
+ refreshTokenTtlSec: union([number().int().positive(), literal("never")]).optional()
14272
15293
  });
14273
15294
  method(_void(), OauthIntegrationDescriptorSchema);
14274
15295
  /**
@@ -14621,8 +15642,26 @@ var TrackSchema = object({
14621
15642
  /** Periodic snapshots at snapshotIntervalMs cadence (subject to
14622
15643
  * saveThumbnails policy). */
14623
15644
  snapshots: array(TrackSnapshotSchema).readonly(),
14624
- /** Deduplicated zones the track has entered at least once. */
15645
+ /** Deduplicated zones the track has entered at least once. Zone IDS. */
14625
15646
  zonesVisited: array(string()).readonly(),
15647
+ /**
15648
+ * Human NAMES for {@link zonesVisited}, resolved at READ time against the
15649
+ * `zones` capability.
15650
+ *
15651
+ * `zonesVisited` persists ids (`cfeec78c-8d69-…`), which no operator can type
15652
+ * and no card can render — so every free-text search surface was structurally
15653
+ * unable to answer "show me the tracks in Uscio", and did not fail loudly, it
15654
+ * just returned nothing. Resolving here rather than in each client keeps ONE
15655
+ * derivation and costs the clients no extra call (the `zones` cap is
15656
+ * per-device, so a client-side resolve would be a per-camera fan-out on a
15657
+ * surface built to avoid exactly that).
15658
+ *
15659
+ * Resolved, never invented: a zone deleted since the track was written has no
15660
+ * name and is DROPPED, so this array can be shorter than `zonesVisited` — the
15661
+ * two are not positionally aligned. Absent when the track visited no zone, or
15662
+ * when the zone catalogue could not be read.
15663
+ */
15664
+ zoneNames: array(string()).readonly().optional(),
14626
15665
  /** Deduplicated set of detector classes observed for this track over its
14627
15666
  * life (a track may be reclassified, e.g. person→vehicle). Absent on
14628
15667
  * legacy rows written before class accumulation shipped. */
@@ -16725,6 +17764,23 @@ var CameraRecordingStatusSchema = object({
16725
17764
  active: boolean(),
16726
17765
  storageBytes: number()
16727
17766
  });
17767
+ /** One stage of the fan-out that could NOT be read, and how long it cost. */
17768
+ var CameraStatusDegradationSchema = object({
17769
+ stage: _enum([
17770
+ "source",
17771
+ "broker",
17772
+ "detection",
17773
+ "recording",
17774
+ "switches"
17775
+ ]),
17776
+ reason: _enum([
17777
+ "timeout",
17778
+ "error",
17779
+ "partial"
17780
+ ]),
17781
+ /** Wall-clock ms spent on the stage before it was abandoned. */
17782
+ elapsedMs: number()
17783
+ });
16728
17784
  /**
16729
17785
  * Aggregated per-camera pipeline status — server-composed, single call.
16730
17786
  *
@@ -16755,9 +17811,28 @@ var CameraStatusSchema = object({
16755
17811
  * differently — a quiet camera that looks identical to a dead one is the
16756
17812
  * silence-reads-as-never-happened trap this repo keeps paying for.
16757
17813
  *
16758
- * Empty when nothing is off. Never contains a switch no provider offers.
17814
+ * Empty when nothing is off, and never contains a switch no provider offers
17815
+ * — but an empty list is only a POSITIVE claim when `degraded` does not name
17816
+ * `'switches'`. When it does, the switch set could not be read and nothing
17817
+ * here may be rendered as "the operator turned nothing off": that is the
17818
+ * D62 failure (a camera we could not read painted as broken) in the very
17819
+ * field that exists to prevent it.
16759
17820
  */
16760
17821
  switchedOff: array(CameraSwitchIdSchema).readonly(),
17822
+ /**
17823
+ * Stages of the bounded fan-out that were CUT SHORT — a timeout or a
17824
+ * rejection — and whose block is therefore `null` because we could not
17825
+ * READ it, not because there is nothing there.
17826
+ *
17827
+ * Without this, three different facts arrive as the same `null`: "the stage
17828
+ * timed out", "the stage failed", and "this camera legitimately has no
17829
+ * decoder / no recording". Every surface that draws a conclusion from a null
17830
+ * block (or from an empty `switchedOff`) must consult this first; a stage
17831
+ * named here supports no conclusion at all, only "unknown".
17832
+ *
17833
+ * Empty on a clean read — the overwhelmingly common case.
17834
+ */
17835
+ degraded: array(CameraStatusDegradationSchema).readonly(),
16761
17836
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
16762
17837
  fetchedAt: number()
16763
17838
  });
@@ -17275,6 +18350,37 @@ DeviceType.Camera, method(object({
17275
18350
  lastCapturedAt: number().nullable(),
17276
18351
  cacheAgeMs: number().nullable(),
17277
18352
  etag: string().nullable()
18353
+ }))), systemMethod(object({
18354
+ /** The tiles a surface is actually rendering. One entry per (device,
18355
+ * width) the caller will paint — the width is snapped to the server's
18356
+ * ladder and becomes part of the link's SIGNED identity. */
18357
+ targets: array(object({
18358
+ deviceId: number(),
18359
+ /** Target width in px. Omit for the frame as captured — correct
18360
+ * for a full-bleed surface, wrong (and expensive) for a grid. */
18361
+ width: number().int().positive().optional()
18362
+ })).min(1).max(200) }), array(object({
18363
+ deviceId: number(),
18364
+ /** Root-relative signed path, or null when the link plane is not
18365
+ * served (no data-plane facility). Present even for a device that has
18366
+ * never captured — the request is what triggers the first one (D94). */
18367
+ url: string().nullable(),
18368
+ /** Epoch ms of the frame this link serves. Null = never captured.
18369
+ * THE honest age: the tRPC path carried none before this. */
18370
+ capturedAt: number().nullable(),
18371
+ /** Age of that frame at the moment the answer was built. */
18372
+ ageMs: number().nullable(),
18373
+ /** Epoch ms after which `url` stops verifying. */
18374
+ expiresAt: number().nullable(),
18375
+ /** Ladder rung the bytes are at; null = the frame as captured. */
18376
+ width: number().nullable(),
18377
+ /** The device has never produced a frame. An empty state, not a
18378
+ * failure — and never a reason to withhold the link (D94). */
18379
+ neverCaptured: boolean(),
18380
+ /** A sleeping battery camera: the frame is deliberately stale and will
18381
+ * NOT refresh in the background. A surface should say so rather than
18382
+ * present it as current. */
18383
+ sleeping: boolean()
17278
18384
  })));
17279
18385
  /**
17280
18386
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
@@ -17320,11 +18426,29 @@ var SsoBridgeClaimsSchema = object({
17320
18426
  codeChallenge: string().optional(),
17321
18427
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
17322
18428
  * tokens so the verify path can check the session is not revoked. */
17323
- sessionId: string().optional()
18429
+ sessionId: string().optional(),
18430
+ /**
18431
+ * The refresh lifetime this LINK was created with, in seconds, or `'never'`.
18432
+ * Baked into the code at `/authorize` from the integration's descriptor and
18433
+ * carried forward so `oauthRefresh` re-mints with the same lifetime. It rides
18434
+ * on the token rather than being re-read from the descriptor on purpose:
18435
+ * editing a descriptor must not retroactively extend or shorten a link the
18436
+ * operator already consented to.
18437
+ */
18438
+ refreshTtl: union([number().int().positive(), literal("never")]).optional()
17324
18439
  });
17325
18440
  method(object({
17326
18441
  claims: SsoBridgeClaimsSchema,
17327
- ttlSec: number().int().positive().optional()
18442
+ /**
18443
+ * Seconds, or `'never'` for a token minted with NO `exp` claim.
18444
+ *
18445
+ * `'never'` is a literal rather than `undefined`/`0` because omitting
18446
+ * this field already means "the 5-minute SSO hand-off default", and
18447
+ * `jwt.sign` THROWS on `{ expiresIn: undefined }` — a "no expiry" that
18448
+ * went through the numeric path would fail at mint time and break
18449
+ * linking rather than produce an eternal token.
18450
+ */
18451
+ ttlSec: union([number().int().positive(), literal("never")]).optional()
17328
18452
  }), object({ token: string() })), method(object({ token: string() }), SsoBridgeClaimsSchema.nullable());
17329
18453
  var ProviderListEntrySchema = discriminatedUnion("shouldSaveDiskSpace", [object({
17330
18454
  providerId: string().min(1),
@@ -19305,6 +20429,37 @@ onColorChanged: { data: object({
19305
20429
  */
19306
20430
  runtimeState: ColorStatusSchema
19307
20431
  };
20432
+ var ConnectionTestOutcomeSchema = discriminatedUnion("outcome", [
20433
+ object({
20434
+ outcome: literal("validated"),
20435
+ /** Round-trip of the sign-in, when the provider measured it. */
20436
+ latencyMs: number().nonnegative().optional(),
20437
+ /** Optional human detail worth showing next to the tick
20438
+ * ("3 devices visible on this account"). */
20439
+ detail: string().optional()
20440
+ }).strict(),
20441
+ object({
20442
+ outcome: literal("rejected"),
20443
+ error: string()
20444
+ }).strict(),
20445
+ object({
20446
+ outcome: literal("inconclusive"),
20447
+ error: string()
20448
+ }).strict()
20449
+ ]);
20450
+ var ConnectionTestInputSchema = object({
20451
+ /** Candidate integration settings, exactly as the create form collected them. */
20452
+ settings: record(string(), unknown()) });
20453
+ /**
20454
+ * What the provider's test actually DOES, so the UI can say it in words before
20455
+ * the operator presses the button ("Signs in to the Dreo cloud"). Purely
20456
+ * descriptive — it never changes routing.
20457
+ */
20458
+ var ConnectionTestDescriptorSchema = object({ label: string() });
20459
+ method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
20460
+ kind: "mutation",
20461
+ auth: "admin"
20462
+ }), method(_void(), ConnectionTestDescriptorSchema, { auth: "admin" });
19308
20463
  /**
19309
20464
  * Upstream-system connectivity sensor — distinct from `device-status`,
19310
20465
  * which is the kernel-managed online/offline flag for the device's
@@ -19939,7 +21094,29 @@ var FaceInfoSchema = object({
19939
21094
  recognizedIdentityId: string().optional(),
19940
21095
  identityName: string().optional(),
19941
21096
  assigned: boolean(),
21097
+ /**
21098
+ * The crop, inline, base64.
21099
+ *
21100
+ * **Prefer {@link cropUrl}.** At the 500 rows the Faces view asks for this
21101
+ * field alone is ~2.87 MiB, re-sent in full on every operator assign and
21102
+ * every 30 s poll, base64-inflated over the msgpack socket and held in the
21103
+ * query heap. It stays for callers that have not migrated; `includeCrops:
21104
+ * false` turns it off once they have.
21105
+ */
19942
21106
  base64: string().optional(),
21107
+ /**
21108
+ * Same crop, as a data-plane URL for `<img src>` — the move the admin
21109
+ * snapshot surfaces made on 2026-08-08.
21110
+ *
21111
+ * Served by the `event-media` plane, which resolves a raw MediaStore key and
21112
+ * is `access: 'authenticated'`: a bare `<img>` carries the `camstack_session`
21113
+ * cookie, so no header plumbing is needed. The bytes then ride the browser's
21114
+ * HTTP cache with an ETag and `immutable`, instead of the WebSocket.
21115
+ *
21116
+ * Absent when the face has no stored crop, or when the addon has no data
21117
+ * plane — callers fall back to {@link base64}.
21118
+ */
21119
+ cropUrl: string().optional(),
19943
21120
  /** Design B: the face bbox (pixel space) on the key frame — lets a detail
19944
21121
  * view draw the box over the native `keyFrameMediaKey` frame. Absent on
19945
21122
  * legacy rows written before design B. */
@@ -20004,7 +21181,23 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
20004
21181
  auth: "admin"
20005
21182
  }), method(object({
20006
21183
  limit: number().int().positive().optional(),
20007
- filter: FaceFilterEnum.optional()
21184
+ filter: FaceFilterEnum.optional(),
21185
+ /**
21186
+ * Inline the base64 crop on every row. Default `true` — the existing
21187
+ * behaviour, kept so no caller breaks.
21188
+ *
21189
+ * Set `false` once the caller renders {@link FaceInfo.cropUrl}: that
21190
+ * drops ~2.87 MiB per 500-row page to a few KiB of metadata and lets
21191
+ * the browser cache the images.
21192
+ *
21193
+ * **This is an INPUT field, so it does not reach the addon until the
21194
+ * next train.** The hub router validates cap inputs against its own
21195
+ * compiled Zod, which strips a key it does not know — verified today
21196
+ * on the OUTPUT side, where an additive field DOES arrive immediately
21197
+ * (`Track.hasFace`). Until the train ships, sending `false` is
21198
+ * harmless and simply keeps the crops inline.
21199
+ */
21200
+ includeCrops: boolean().optional()
20008
21201
  }).optional(), array(FaceInfoSchema).readonly()), method(object({
20009
21202
  deviceId: number().int(),
20010
21203
  trackId: string()
@@ -20628,15 +21821,57 @@ var AvailableIntegrationTypeSchema = object({
20628
21821
  * flow can import (e.g. HA areas). Drives the adopt modal's "import
20629
21822
  * locations" checkbox. Provider-declared in the addon manifest. */
20630
21823
  supportsLocationImport: boolean(),
21824
+ /**
21825
+ * True when this integration DECLARES a pre-creation test (the
21826
+ * `connection-test` cap, or a broker whose settings it stores). Drives the
21827
+ * Test button: an integration that cannot be tested must say so up front
21828
+ * rather than offering a button that always answers the same nonsense.
21829
+ */
21830
+ canTest: boolean(),
20631
21831
  existingInstances: array(object({
20632
21832
  id: string(),
20633
21833
  name: string()
20634
21834
  })),
20635
21835
  canAdd: boolean()
20636
21836
  });
21837
+ /**
21838
+ * Why a test could not be answered as a plain boolean.
21839
+ *
21840
+ * `success` alone collapsed four different situations into one red box, and the
21841
+ * one that mattered most — "nobody ever asked the remote anything" — looked
21842
+ * exactly like "the remote said no". The status is the discriminator:
21843
+ *
21844
+ * - `validated` — a provider-declared test ran and the remote ACCEPTED.
21845
+ * - `rejected` — a provider-declared test ran and the remote REFUSED.
21846
+ * The only status that blocks `integrations.create`.
21847
+ * - `inconclusive` — a test IS declared but could not complete (timeout,
21848
+ * DNS, 5xx). Nothing was observed; not a failure.
21849
+ * - `unsupported` — this integration declares NO test. Nothing was
21850
+ * observed either; not a failure, and not a pass.
21851
+ *
21852
+ * `unsupported` and `inconclusive` both carry `success: false` so an older
21853
+ * client can never read them as a green tick, and both carry an `error` string
21854
+ * that SAYS the test did not run rather than inventing a failure.
21855
+ */
21856
+ var TestConnectionStatusEnum = _enum([
21857
+ "validated",
21858
+ "rejected",
21859
+ "inconclusive",
21860
+ "unsupported"
21861
+ ]);
20637
21862
  var TestConnectionResultSchema$1 = object({
21863
+ /** True ONLY for `validated`. Never true for a test that did not run. */
20638
21864
  success: boolean(),
20639
- error: string().optional()
21865
+ error: string().optional(),
21866
+ /** Optional for wire back-compat with clients built before the tri-state;
21867
+ * the server always sets it. */
21868
+ status: TestConnectionStatusEnum.optional(),
21869
+ /** Addon id whose declared test answered — `null` when none did. Lets the UI
21870
+ * attribute a result instead of blaming "the integration". */
21871
+ testedBy: string().nullable().optional(),
21872
+ latencyMs: number().nonnegative().optional(),
21873
+ /** Human detail from a `validated` result ("3 devices on this account"). */
21874
+ detail: string().optional()
20640
21875
  });
20641
21876
  var CreateIntegrationInputSchema = object({
20642
21877
  addonId: string(),
@@ -24405,7 +25640,12 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
24405
25640
  hubUrl: string(),
24406
25641
  /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
24407
25642
  * that carries one can ONLY be exchanged with the matching verifier. */
24408
- codeChallenge: string().optional()
25643
+ codeChallenge: string().optional(),
25644
+ /** The integration's declared refresh lifetime — seconds, or `'never'`.
25645
+ * From `OauthIntegrationDescriptor.refreshTokenTtlSec`. Baked into the
25646
+ * code so the link carries its own lifetime; omit for the 30-day
25647
+ * default. */
25648
+ refreshTtlSec: union([number().int().positive(), literal("never")]).optional()
24409
25649
  }), object({ code: string() }), {
24410
25650
  kind: "mutation",
24411
25651
  access: "create"
@@ -25745,974 +26985,345 @@ var BaseDevice = class {
25745
26985
  * is open (Reolink writes `hasPtz/hasIntercom`, Hikvision writes
25746
26986
  * `hasSupplementalLight/hasAlarmIo`, etc).
25747
26987
  *
25748
- * Default: nothing to probe → mark the device PROBED (set `lastProbedAt`) so
25749
- * the kernel treats it as ready immediately. A device that derives its shape
25750
- * from a spec (a container, or an accessory sensor) rather than from a
25751
- * hardware probe has no probe to "complete"; without stamping `lastProbedAt`
25752
- * it would look perpetually un-probed — logging "Initial probe did not
25753
- * complete" on every boot and spinning a pointless retry chain. Drivers that
25754
- * DO probe override this and write their own `feature-probe` slice (including
25755
- * `lastProbedAt`) once their probe actually succeeds.
25756
- */
25757
- async onProbe() {
25758
- const base = this.runtimeState.getCapState("feature-probe") ?? {
25759
- flags: {},
25760
- deviceType: null,
25761
- model: null,
25762
- channelCount: null,
25763
- lastProbedAt: 0,
25764
- lastFetchedAt: 0
25765
- };
25766
- this.runtimeState.setCapState("feature-probe", {
25767
- ...base,
25768
- lastProbedAt: Date.now()
25769
- });
25770
- }
25771
- /**
25772
- * Phase 5 — fired after the device + its accessories are registered.
25773
- * Drivers publish streams to the broker, kick off background tasks,
25774
- * or subscribe to lib events that need a fully-registered device id.
25775
- *
25776
- * Default: no-op.
25777
- *
25778
- * RENAMED FROM `onCreated` (which still exists for back-compat in this
25779
- * pass). The new name reflects the post-probe, post-accessory contract.
25780
- */
25781
- async onActivate() {}
25782
- /**
25783
- * Re-run the probe + reconcile accessories + refresh features meta.
25784
- * Drivers call this when device-side state changes (battery cam wakes,
25785
- * firmware update, manual operator trigger).
25786
- *
25787
- * The kernel injects `_kernelReprobe` on registration so this method
25788
- * delegates to the same orchestrator that runs the boot-time phase
25789
- * 3 + 4 sequence. Drivers should NOT override this — they override
25790
- * `onProbe()` instead.
25791
- */
25792
- async reprobe() {
25793
- if (this._kernelReprobe) await this._kernelReprobe();
25794
- else await this.onProbe();
25795
- }
25796
- /**
25797
- * Kernel-injected callback that runs the full post-probe orchestration
25798
- * (onProbe → registerDevice meta refresh → accessory reconciliation).
25799
- * Set by `device-cap-proxy.register()`. Drivers should not touch this
25800
- * directly — call `reprobe()` instead.
25801
- */
25802
- _kernelReprobe;
25803
- /**
25804
- * Declare accessory child devices the kernel should auto-spawn
25805
- * after `onProbe()` resolves. Each spec fully describes one child
25806
- * — stableId suffix (deterministic per kind for restore-safety),
25807
- * meta (type / name / location), config (initial blob the child
25808
- * self-hydrates), and a factory that constructs the concrete
25809
- * class with whatever closure-captured refs it needs (typically
25810
- * `this` for the parent reference).
25811
- *
25812
- * The kernel handles the rest: allocateDeviceId, persistInitialConfig
25813
- * (skipped on restore when the row already exists),
25814
- * persistInitialMeta, createContext, factory invocation, register,
25815
- * and recursive lifecycle (probe + accessories + activate).
25816
- *
25817
- * Implementations should derive children from
25818
- * `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
25819
- * Drivers can use the `getProbeFlags()` helper to read the flag bag
25820
- * with a typed cast.
25821
- *
25822
- * Default: no children.
25823
- */
25824
- getAccessoryChildren() {
25825
- return [];
25826
- }
25827
- /**
25828
- * Read the current feature-probe flag bag with a typed cast. Helper
25829
- * for `getAccessoryChildren()` and `features` getters that derive
25830
- * outputs from the probe results.
25831
- */
25832
- getProbeFlags() {
25833
- return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
25834
- }
25835
- /**
25836
- * Returns true once `onProbe` has completed at least once
25837
- * (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
25838
- * to avoid spawning stale accessories on a fresh device whose probe
25839
- * hasn't landed yet.
25840
- */
25841
- hasProbed() {
25842
- return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
25843
- }
25844
- };
25845
- /**
25846
- * Convert an IDevice to the flat DeviceSummary shape expected by the
25847
- * device-provider cap router. Shared across all providers.
25848
- */
25849
- function toDeviceSummary(device, addonId) {
25850
- const config = {};
25851
- for (const entry of device.config.entries()) config[entry.key] = entry.value;
25852
- return {
25853
- id: device.id,
25854
- stableId: device.stableId,
25855
- addonId,
25856
- type: String(device.type),
25857
- name: device.name,
25858
- parentDeviceId: device.parentDeviceId,
25859
- online: device.online,
25860
- features: [...device.features],
25861
- config,
25862
- sourceInfo: device.sourceInfo
25863
- };
25864
- }
25865
- /**
25866
- * Base class for device-provider addons (rtsp, onvif, frigate).
25867
- *
25868
- * Provides default implementations for the common device-provider cap
25869
- * methods (`start`, `stop`, `getStatus`, `getDevices`, `supportsDiscovery`,
25870
- * `supportsManualCreation`, `toDeviceSummary`). Subclasses override the
25871
- * methods that differ per provider.
25872
- *
25873
- * @example
25874
- * ```ts
25875
- * class RtspProvider extends BaseDeviceProvider {
25876
- * protected readonly addonId = 'provider-rtsp'
25877
- * protected readonly providerName = 'RTSP'
25878
- *
25879
- * protected async onCreateDevice(input) { ... }
25880
- * protected async onGetCreationSchema(type) { ... }
25881
- * protected async onRestoreDevices(saved) { ... }
25882
- * }
25883
- * ```
25884
- */
25885
- var BaseDeviceProvider = class extends BaseAddon {
25886
- async onInitialize() {
25887
- this.ctx.logger.info(`${this.providerName} Provider initialized`);
25888
- return [{
25889
- capability: deviceProviderCapability,
25890
- provider: this
25891
- }];
25892
- }
25893
- async onShutdown() {
25894
- const devices = await this.ctx.kernel.devices?.getAll() ?? [];
25895
- for (const device of devices) try {
25896
- await this.ctx.kernel.devices?.decommission(device.id);
25897
- } catch (err) {
25898
- this.ctx.logger.warn(`${this.providerName}: decommission failed`, {
25899
- tags: {
25900
- deviceId: device.id,
25901
- stableId: device.stableId
25902
- },
25903
- meta: { error: err instanceof Error ? err.message : String(err) }
25904
- });
25905
- }
25906
- this.ctx.logger.info(`${this.providerName} Provider shut down`, { meta: { decommissionedCount: devices.length } });
25907
- }
25908
- async start() {}
25909
- async stop() {}
25910
- async getStatus() {
25911
- return {
25912
- connected: true,
25913
- deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
25914
- };
25915
- }
25916
- async getDevices() {
25917
- return (await this.ctx.kernel.devices?.getAll() ?? []).map((d) => ({
25918
- id: d.stableId,
25919
- name: d.name,
25920
- type: String(d.type)
25921
- }));
25922
- }
25923
- async supportsDiscovery() {
25924
- return false;
25925
- }
25926
- async discoverDevices(_input) {
25927
- return [];
25928
- }
25929
- /** Extra per-scan input form (e.g. a broadcast address for another subnet).
25930
- * Null = no extra params. Override in providers that support scoped scans. */
25931
- async getDiscoveryParamsSchema() {
25932
- return null;
25933
- }
25934
- /**
25935
- * The DeviceType this provider creates via manual add — derived from the
25936
- * `deviceClasses` map (first registered type). `null` when manual creation is
25937
- * unsupported. Lets the Add-Device dialog pick the right type per provider.
25938
- */
25939
- async getManualCreationType() {
25940
- if (!await this.supportsManualCreation()) return { deviceType: null };
25941
- return { deviceType: Object.values(DeviceType).find((t) => this.deviceClasses[t] !== void 0) ?? null };
25942
- }
25943
- async adoptDiscoveredDevice(_input) {
25944
- throw new Error(`${this.providerName} provider does not support discovery-based adoption`);
25945
- }
25946
- async supportsManualCreation() {
25947
- return true;
25948
- }
25949
- async getChildCreationSchema(input) {
25950
- return this.onGetCreationSchema(input.type);
25951
- }
25952
- /**
25953
- * Default kernel-orchestrated `createDevice` implementation. The
25954
- * subclass's `onCreateDevice` returns a declarative
25955
- * `CreateDeviceSpec` (`{meta, config}`) — this method handles
25956
- * stableId generation, class lookup, kernel.devices.create
25957
- * dispatch, and DeviceSummary mapping. Subclasses should NOT
25958
- * override this method; override `onCreateDevice` and
25959
- * `deviceClasses` instead.
25960
- */
25961
- async createDevice(input) {
25962
- const spec = await this.onCreateDevice(input.type, input.config);
25963
- const Class = this.deviceClasses[spec.meta.type];
25964
- if (!Class) throw new Error(`${this.providerName} provider: no device class registered for type "${spec.meta.type}" — add it to the deviceClasses map`);
25965
- const stableId = this.generateStableId(spec.meta.type, spec.config);
25966
- const device = await this.ctx.kernel.devices.create(stableId, Class, spec.config, null, spec.meta);
25967
- if (spec.onAfterCreate) try {
25968
- await spec.onAfterCreate(device);
25969
- } catch (err) {
25970
- this.ctx.logger.warn("createDevice: onAfterCreate hook threw — device is already registered", {
25971
- tags: {
25972
- deviceId: device.id,
25973
- stableId
25974
- },
25975
- meta: { error: err instanceof Error ? err.message : String(err) }
25976
- });
25977
- }
25978
- return this.toSummary(device);
25979
- }
25980
- /**
25981
- * Generate a stableId for a newly-created device. Default uses the
25982
- * `${addonId}-${Date.now()}` pattern as a unique-but-opaque
25983
- * fallback; any provider that has access to durable hardware
25984
- * identity (UID, MAC, serial) should override and derive from it
25985
- * so re-adding the same physical device reuses its persisted row.
25986
- *
25987
- * `config` is the parsed CreateDeviceSpec.config the subclass
25988
- * returned from `onCreateDevice` — the override has access to
25989
- * every operator-supplied + autodetect-resolved field. Optional
25990
- * for back-compat: existing overrides that take only `type`
25991
- * keep working unchanged.
26988
+ * Default: nothing to probe → mark the device PROBED (set `lastProbedAt`) so
26989
+ * the kernel treats it as ready immediately. A device that derives its shape
26990
+ * from a spec (a container, or an accessory sensor) rather than from a
26991
+ * hardware probe has no probe to "complete"; without stamping `lastProbedAt`
26992
+ * it would look perpetually un-probed — logging "Initial probe did not
26993
+ * complete" on every boot and spinning a pointless retry chain. Drivers that
26994
+ * DO probe override this and write their own `feature-probe` slice (including
26995
+ * `lastProbedAt`) once their probe actually succeeds.
25992
26996
  */
25993
- generateStableId(_type, _config) {
25994
- return `${this.addonId}-${Date.now()}`;
25995
- }
25996
- async testCreationField(_input) {
25997
- return {
25998
- status: "ok",
25999
- labels: ["probe not implemented"]
26997
+ async onProbe() {
26998
+ const base = this.runtimeState.getCapState("feature-probe") ?? {
26999
+ flags: {},
27000
+ deviceType: null,
27001
+ model: null,
27002
+ channelCount: null,
27003
+ lastProbedAt: 0,
27004
+ lastFetchedAt: 0
26000
27005
  };
26001
- }
26002
- async restoreDevices(savedDevices) {
26003
- await this.onRestoreDevices(savedDevices);
26004
- if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
27006
+ this.runtimeState.setCapState("feature-probe", {
27007
+ ...base,
27008
+ lastProbedAt: Date.now()
27009
+ });
26005
27010
  }
26006
27011
  /**
26007
- * Restore devices from persisted state. Two-pass:
27012
+ * Phase 5 — fired after the device + its accessories are registered.
27013
+ * Drivers publish streams to the broker, kick off background tasks,
27014
+ * or subscribe to lib events that need a fully-registered device id.
26008
27015
  *
26009
- * 1. **Top-level pass** — invokes `kernel.devices.create()` for every
26010
- * `parentDeviceId === null` row using the `deviceClasses` map.
26011
- * The kernel's register flow handles `getAccessoryChildren()` for
26012
- * each parent (siren / floodlight / PIR / etc).
27016
+ * Default: no-op.
26013
27017
  *
26014
- * 2. **Hub-adopted children pass** — for rows with
26015
- * `parentDeviceId !== null` whose `type` IS in `deviceClasses`
26016
- * (e.g. Reolink hub-adopted cameras under an NVR), spawn them
26017
- * explicitly with the persisted `parentDeviceId`. These are
26018
- * NOT accessory children — they're first-class adopted devices
26019
- * that just happen to have a parent. Without this pass, every
26020
- * server restart would lose hub-adopted cameras (their type is
26021
- * in `deviceClasses` but parent's `getAccessoryChildren` doesn't
26022
- * spawn them — that callback is only for purpose-built
26023
- * accessory roles).
27018
+ * RENAMED FROM `onCreated` (which still exists for back-compat in this
27019
+ * pass). The new name reflects the post-probe, post-accessory contract.
27020
+ */
27021
+ async onActivate() {}
27022
+ /**
27023
+ * Re-run the probe + reconcile accessories + refresh features meta.
27024
+ * Drivers call this when device-side state changes (battery cam wakes,
27025
+ * firmware update, manual operator trigger).
26024
27026
  *
26025
- * Rows whose `type` is NOT in `deviceClasses` are skipped — those
26026
- * are accessory children (siren/light/sensor) that the kernel's
26027
- * accessory-spawn flow handles via the parent's
26028
- * `getAccessoryChildren()`. Override only when the default doesn't
26029
- * fit.
27027
+ * The kernel injects `_kernelReprobe` on registration so this method
27028
+ * delegates to the same orchestrator that runs the boot-time phase
27029
+ * 3 + 4 sequence. Drivers should NOT override this — they override
27030
+ * `onProbe()` instead.
26030
27031
  */
26031
- async onRestoreDevices(savedDevices) {
26032
- const restored = /* @__PURE__ */ new Set();
26033
- for (const saved of savedDevices) {
26034
- if (saved.parentDeviceId !== null) continue;
26035
- const Class = this.deviceClasses[saved.type];
26036
- if (!Class) {
26037
- this.ctx.logger.warn("No device class registered for restored type — skipping", {
26038
- tags: { stableId: saved.stableId },
26039
- meta: { type: saved.type }
26040
- });
26041
- continue;
26042
- }
26043
- try {
26044
- await this.ctx.kernel.devices.create(saved.stableId, Class, {});
26045
- restored.add(saved.id);
26046
- } catch (err) {
26047
- this.ctx.logger.warn("Failed to restore device", {
26048
- tags: { stableId: saved.stableId },
26049
- meta: {
26050
- type: saved.type,
26051
- error: err instanceof Error ? err.message : String(err)
26052
- }
26053
- });
26054
- }
26055
- }
26056
- const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
26057
- for (const saved of childRows) {
26058
- const Class = this.deviceClasses[saved.type];
26059
- if (!Class) continue;
26060
- if (saved.parentDeviceId === null) continue;
26061
- if (!restored.has(saved.parentDeviceId)) continue;
26062
- try {
26063
- await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
26064
- restored.add(saved.id);
26065
- } catch (err) {
26066
- this.ctx.logger.warn("Failed to restore hub-adopted child", {
26067
- tags: {
26068
- stableId: saved.stableId,
26069
- parentDeviceId: saved.parentDeviceId
26070
- },
26071
- meta: {
26072
- type: saved.type,
26073
- error: err instanceof Error ? err.message : String(err)
26074
- }
26075
- });
26076
- }
26077
- }
26078
- }
26079
- /** Convert an IDevice to the flat DeviceSummary for the cap router. */
26080
- toSummary(device) {
26081
- return toDeviceSummary(device, this.addonId);
27032
+ async reprobe() {
27033
+ if (this._kernelReprobe) await this._kernelReprobe();
27034
+ else await this.onProbe();
26082
27035
  }
26083
- };
26084
- 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;
26085
- new Set(Object.values(DeviceType));
26086
- DeviceFeature.BatteryOperated;
26087
- /**
26088
- * Error types for the safe expression engine. Two distinct classes so callers
26089
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
26090
- * failure — both are non-fatal to the host: read paths degrade to "skip link".
26091
- */
26092
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
26093
- * the failure is anchored to a character (author-facing inline feedback). */
26094
- var ExpressionParseError = class extends Error {
26095
- position;
26096
- constructor(message, position) {
26097
- super(message);
26098
- this.name = "ExpressionParseError";
26099
- this.position = position;
27036
+ /**
27037
+ * Kernel-injected callback that runs the full post-probe orchestration
27038
+ * (onProbe → registerDevice meta refresh → accessory reconciliation).
27039
+ * Set by `device-cap-proxy.register()`. Drivers should not touch this
27040
+ * directly — call `reprobe()` instead.
27041
+ */
27042
+ _kernelReprobe;
27043
+ /**
27044
+ * Declare accessory child devices the kernel should auto-spawn
27045
+ * after `onProbe()` resolves. Each spec fully describes one child
27046
+ * — stableId suffix (deterministic per kind for restore-safety),
27047
+ * meta (type / name / location), config (initial blob the child
27048
+ * self-hydrates), and a factory that constructs the concrete
27049
+ * class with whatever closure-captured refs it needs (typically
27050
+ * `this` for the parent reference).
27051
+ *
27052
+ * The kernel handles the rest: allocateDeviceId, persistInitialConfig
27053
+ * (skipped on restore when the row already exists),
27054
+ * persistInitialMeta, createContext, factory invocation, register,
27055
+ * and recursive lifecycle (probe + accessories + activate).
27056
+ *
27057
+ * Implementations should derive children from
27058
+ * `this.runtimeState.getCapState('feature-probe')` (post-probe truth).
27059
+ * Drivers can use the `getProbeFlags()` helper to read the flag bag
27060
+ * with a typed cast.
27061
+ *
27062
+ * Default: no children.
27063
+ */
27064
+ getAccessoryChildren() {
27065
+ return [];
26100
27066
  }
26101
- };
26102
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
26103
- * result, unknown builtin, step-budget exceeded). */
26104
- var ExpressionEvalError = class extends Error {
26105
- constructor(message) {
26106
- super(message);
26107
- this.name = "ExpressionEvalError";
27067
+ /**
27068
+ * Read the current feature-probe flag bag with a typed cast. Helper
27069
+ * for `getAccessoryChildren()` and `features` getters that derive
27070
+ * outputs from the probe results.
27071
+ */
27072
+ getProbeFlags() {
27073
+ return this.runtimeState.getCapState("feature-probe")?.flags ?? {};
26108
27074
  }
26109
- };
26110
- /**
26111
- * Frozen, null-prototype builtin function table for the expression engine
26112
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
26113
- * parser rejects any callee not in it, and the evaluator gates each call on an
26114
- * own-property check against it.
26115
- *
26116
- * Because the object has a NULL prototype AND is `Object.freeze`d:
26117
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
26118
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
26119
- * (there is no `Object.prototype` in the chain), so those names are not
26120
- * callable — they are simply "unknown function" at parse time.
26121
- *
26122
- * Every numeric argument is validated as a finite number and every numeric
26123
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
26124
- * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
26125
- * closed rather than emitting a garbage value.
26126
- */
26127
- function asFiniteNumber(value, name, index) {
26128
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
26129
- return value;
26130
- }
26131
- function asString$1(value, name, index) {
26132
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
26133
- return value;
26134
- }
26135
- function finiteResult(value, name) {
26136
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
26137
- return value;
26138
- }
26139
- function allFiniteNumbers(args, name) {
26140
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
26141
- }
26142
- var INF = Number.POSITIVE_INFINITY;
26143
- var table = {
26144
- min: {
26145
- minArgs: 1,
26146
- maxArgs: INF,
26147
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
26148
- },
26149
- max: {
26150
- minArgs: 1,
26151
- maxArgs: INF,
26152
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
26153
- },
26154
- abs: {
26155
- minArgs: 1,
26156
- maxArgs: 1,
26157
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
26158
- },
26159
- floor: {
26160
- minArgs: 1,
26161
- maxArgs: 1,
26162
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
26163
- },
26164
- ceil: {
26165
- minArgs: 1,
26166
- maxArgs: 1,
26167
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
26168
- },
26169
- sqrt: {
26170
- minArgs: 1,
26171
- maxArgs: 1,
26172
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
26173
- },
26174
- round: {
26175
- minArgs: 1,
26176
- maxArgs: 2,
26177
- apply: (args) => {
26178
- const x = asFiniteNumber(args[0], "round", 0);
26179
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
26180
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
26181
- const factor = 10 ** digits;
26182
- return finiteResult(Math.round(x * factor) / factor, "round");
26183
- }
26184
- },
26185
- pow: {
26186
- minArgs: 2,
26187
- maxArgs: 2,
26188
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
26189
- },
26190
- clamp: {
26191
- minArgs: 3,
26192
- maxArgs: 3,
26193
- apply: (args) => {
26194
- const x = asFiniteNumber(args[0], "clamp", 0);
26195
- const lo = asFiniteNumber(args[1], "clamp", 1);
26196
- const hi = asFiniteNumber(args[2], "clamp", 2);
26197
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
26198
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
26199
- }
26200
- },
26201
- avg: {
26202
- minArgs: 1,
26203
- maxArgs: INF,
26204
- apply: (args) => {
26205
- const nums = allFiniteNumbers(args, "avg");
26206
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
26207
- }
26208
- },
26209
- sum: {
26210
- minArgs: 1,
26211
- maxArgs: INF,
26212
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
26213
- },
26214
- coalesce: {
26215
- minArgs: 1,
26216
- maxArgs: INF,
26217
- apply: (args) => {
26218
- for (const a of args) if (a !== null) return a;
26219
- return null;
26220
- }
26221
- },
26222
- age: {
26223
- minArgs: 2,
26224
- maxArgs: 2,
26225
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
26226
- },
26227
- convert: {
26228
- minArgs: 3,
26229
- maxArgs: 3,
26230
- apply: (args, hooks) => {
26231
- const x = asFiniteNumber(args[0], "convert", 0);
26232
- const from = asString$1(args[1], "convert", 1).trim();
26233
- const to = asString$1(args[2], "convert", 2).trim();
26234
- if (hooks.convert) {
26235
- const out = hooks.convert(x, from, to);
26236
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
26237
- return finiteResult(out, "convert");
26238
- }
26239
- if (from === to) return x;
26240
- throw new ExpressionEvalError("convert: unit conversion table not installed");
26241
- }
27075
+ /**
27076
+ * Returns true once `onProbe` has completed at least once
27077
+ * (`lastProbedAt > 0`). Drivers gate `getAccessoryChildren()` on this
27078
+ * to avoid spawning stale accessories on a fresh device whose probe
27079
+ * hasn't landed yet.
27080
+ */
27081
+ hasProbed() {
27082
+ return (this.runtimeState.getCapState("feature-probe")?.lastProbedAt ?? 0) > 0;
26242
27083
  }
26243
27084
  };
26244
- Object.freeze(Object.assign(Object.create(null), table));
26245
- /** The set of valid builtin names — used by the parser to reject unknown
26246
- * callees at parse time (immediate author feedback). */
26247
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
26248
- /**
26249
- * Resource-bound constants for the safe expression engine.
26250
- *
26251
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
26252
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
26253
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
26254
- * work a single author-supplied expression can request, so a hostile or
26255
- * accidental pathological string can never spend unbounded CPU/memory.
26256
- */
26257
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
26258
- * rejected without allocation. */
26259
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
26260
- /** A legal binding / identifier name. */
26261
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
26262
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
26263
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
26264
- var RESERVED_BINDING_NAMES = new Set([
26265
- "now",
26266
- "true",
26267
- "false",
26268
- "null"
26269
- ]);
26270
27085
  /**
26271
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
26272
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
26273
- * single/double-quoted strings with a tiny escape set, identifiers, the three
26274
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
26275
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
26276
- * is a parse error with a source position, so member access / assignment /
26277
- * template literals are lexically impossible.
27086
+ * Convert an IDevice to the flat DeviceSummary shape expected by the
27087
+ * device-provider cap router. Shared across all providers.
26278
27088
  */
26279
- var KEYWORDS = new Set([
26280
- "true",
26281
- "false",
26282
- "null"
26283
- ]);
26284
- function isDigit(ch) {
26285
- return ch >= "0" && ch <= "9";
26286
- }
26287
- function isIdentStart(ch) {
26288
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
26289
- }
26290
- function isIdentPart(ch) {
26291
- return isIdentStart(ch) || isDigit(ch);
26292
- }
26293
- function isWhitespace(ch) {
26294
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
26295
- }
26296
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
26297
- * Throws `ExpressionParseError` on any illegal character or unterminated
26298
- * string. */
26299
- function tokenize(source) {
26300
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
26301
- const tokens = [];
26302
- let i = 0;
26303
- const n = source.length;
26304
- while (i < n) {
26305
- const ch = source[i];
26306
- if (isWhitespace(ch)) {
26307
- i += 1;
26308
- continue;
26309
- }
26310
- if (isDigit(ch)) {
26311
- const start = i;
26312
- while (i < n && isDigit(source[i])) i += 1;
26313
- if (i < n && source[i] === ".") {
26314
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
26315
- i += 1;
26316
- while (i < n && isDigit(source[i])) i += 1;
26317
- }
26318
- const text = source.slice(start, i);
26319
- const value = Number(text);
26320
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
26321
- tokens.push({
26322
- type: "number",
26323
- value,
26324
- pos: start
26325
- });
26326
- continue;
26327
- }
26328
- if (ch === "'" || ch === "\"") {
26329
- const quote = ch;
26330
- const start = i;
26331
- i += 1;
26332
- let out = "";
26333
- let closed = false;
26334
- while (i < n) {
26335
- const c = source[i];
26336
- if (c === "\\") {
26337
- const next = i + 1 < n ? source[i + 1] : "";
26338
- if (next === "\\" || next === "'" || next === "\"") {
26339
- out += next;
26340
- i += 2;
26341
- continue;
26342
- }
26343
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
26344
- }
26345
- if (c === quote) {
26346
- closed = true;
26347
- i += 1;
26348
- break;
26349
- }
26350
- out += c;
26351
- i += 1;
26352
- }
26353
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
26354
- tokens.push({
26355
- type: "string",
26356
- value: out,
26357
- pos: start
26358
- });
26359
- continue;
26360
- }
26361
- if (isIdentStart(ch)) {
26362
- const start = i;
26363
- while (i < n && isIdentPart(source[i])) i += 1;
26364
- const text = source.slice(start, i);
26365
- if (KEYWORDS.has(text)) tokens.push({
26366
- type: "keyword",
26367
- keyword: keywordOf(text),
26368
- pos: start
26369
- });
26370
- else tokens.push({
26371
- type: "identifier",
26372
- name: text,
26373
- pos: start
26374
- });
26375
- continue;
26376
- }
26377
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
26378
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
26379
- tokens.push({
26380
- type: "punct",
26381
- punct: two,
26382
- pos: i
26383
- });
26384
- i += 2;
26385
- continue;
26386
- }
26387
- if (isSinglePunct(ch)) {
26388
- tokens.push({
26389
- type: "punct",
26390
- punct: ch,
26391
- pos: i
26392
- });
26393
- i += 1;
26394
- continue;
26395
- }
26396
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
26397
- }
26398
- tokens.push({
26399
- type: "eof",
26400
- pos: n
26401
- });
26402
- return tokens;
26403
- }
26404
- function keywordOf(text) {
26405
- if (text === "true") return "true";
26406
- if (text === "false") return "false";
26407
- return "null";
26408
- }
26409
- function isSinglePunct(ch) {
26410
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
27089
+ function toDeviceSummary(device, addonId) {
27090
+ const config = {};
27091
+ for (const entry of device.config.entries()) config[entry.key] = entry.value;
27092
+ return {
27093
+ id: device.id,
27094
+ stableId: device.stableId,
27095
+ addonId,
27096
+ type: String(device.type),
27097
+ name: device.name,
27098
+ parentDeviceId: device.parentDeviceId,
27099
+ online: device.online,
27100
+ features: [...device.features],
27101
+ config,
27102
+ sourceInfo: device.sourceInfo
27103
+ };
26411
27104
  }
26412
27105
  /**
26413
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
27106
+ * Base class for device-provider addons (rtsp, onvif, frigate).
26414
27107
  *
26415
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
26416
- * → relational → additive → multiplicative → unary `! -` → call / primary.
26417
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
26418
- * string validated against the builtin table at parse time, so an unknown
26419
- * function is rejected immediately (author feedback) and a persisted expression
26420
- * that references a since-removed builtin degrades at read.
27108
+ * Provides default implementations for the common device-provider cap
27109
+ * methods (`start`, `stop`, `getStatus`, `getDevices`, `supportsDiscovery`,
27110
+ * `supportsManualCreation`, `toDeviceSummary`). Subclasses override the
27111
+ * methods that differ per provider.
26421
27112
  *
26422
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
26423
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
27113
+ * @example
27114
+ * ```ts
27115
+ * class RtspProvider extends BaseDeviceProvider {
27116
+ * protected readonly addonId = 'provider-rtsp'
27117
+ * protected readonly providerName = 'RTSP'
27118
+ *
27119
+ * protected async onCreateDevice(input) { ... }
27120
+ * protected async onGetCreationSchema(type) { ... }
27121
+ * protected async onRestoreDevices(saved) { ... }
27122
+ * }
27123
+ * ```
26424
27124
  */
26425
- /** Binary/logical operator precedence (higher binds tighter). */
26426
- var BINARY_PRECEDENCE = {
26427
- "||": 1,
26428
- "&&": 2,
26429
- "==": 3,
26430
- "!=": 3,
26431
- "<": 4,
26432
- "<=": 4,
26433
- ">": 4,
26434
- ">=": 4,
26435
- "+": 5,
26436
- "-": 5,
26437
- "*": 6,
26438
- "/": 6,
26439
- "%": 6
26440
- };
26441
- function isLogicalOp(op) {
26442
- return op === "&&" || op === "||";
26443
- }
26444
- function isBinaryOp(op) {
26445
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
26446
- }
26447
- var Parser = class {
26448
- tokens;
26449
- pos = 0;
26450
- nodeCount = 0;
26451
- identifiers = /* @__PURE__ */ new Set();
26452
- callees = /* @__PURE__ */ new Set();
26453
- constructor(tokens) {
26454
- this.tokens = tokens;
27125
+ var BaseDeviceProvider = class extends BaseAddon {
27126
+ async onInitialize() {
27127
+ this.ctx.logger.info(`${this.providerName} Provider initialized`);
27128
+ return [{
27129
+ capability: deviceProviderCapability,
27130
+ provider: this
27131
+ }];
26455
27132
  }
26456
- parse() {
26457
- const ast = this.parseTernary();
26458
- const tok = this.peek();
26459
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
27133
+ async onShutdown() {
27134
+ const devices = await this.ctx.kernel.devices?.getAll() ?? [];
27135
+ for (const device of devices) try {
27136
+ await this.ctx.kernel.devices?.decommission(device.id);
27137
+ } catch (err) {
27138
+ this.ctx.logger.warn(`${this.providerName}: decommission failed`, {
27139
+ tags: {
27140
+ deviceId: device.id,
27141
+ stableId: device.stableId
27142
+ },
27143
+ meta: { error: err instanceof Error ? err.message : String(err) }
27144
+ });
27145
+ }
27146
+ this.ctx.logger.info(`${this.providerName} Provider shut down`, { meta: { decommissionedCount: devices.length } });
27147
+ }
27148
+ async start() {}
27149
+ async stop() {}
27150
+ async getStatus() {
26460
27151
  return {
26461
- ast,
26462
- identifiers: this.identifiers,
26463
- callees: this.callees,
26464
- nodeCount: this.nodeCount
27152
+ connected: true,
27153
+ deviceCount: (await this.ctx.kernel.devices?.getAll() ?? []).length
26465
27154
  };
26466
27155
  }
26467
- peek() {
26468
- return this.tokens[this.pos];
26469
- }
26470
- next() {
26471
- return this.tokens[this.pos++];
26472
- }
26473
- /** Consume a punctuator token, erroring if the next token isn't it. */
26474
- expectPunct(punct) {
26475
- const tok = this.peek();
26476
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
26477
- this.pos += 1;
27156
+ async getDevices() {
27157
+ return (await this.ctx.kernel.devices?.getAll() ?? []).map((d) => ({
27158
+ id: d.stableId,
27159
+ name: d.name,
27160
+ type: String(d.type)
27161
+ }));
26478
27162
  }
26479
- matchPunct(punct) {
26480
- const tok = this.peek();
26481
- if (tok.type === "punct" && tok.punct === punct) {
26482
- this.pos += 1;
26483
- return true;
26484
- }
27163
+ async supportsDiscovery() {
26485
27164
  return false;
26486
27165
  }
26487
- countNode() {
26488
- this.nodeCount += 1;
26489
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
27166
+ async discoverDevices(_input) {
27167
+ return [];
26490
27168
  }
26491
- parseTernary() {
26492
- const test = this.parseBinary(1);
26493
- if (this.matchPunct("?")) {
26494
- const consequent = this.parseTernary();
26495
- this.expectPunct(":");
26496
- const alternate = this.parseTernary();
26497
- this.countNode();
26498
- return {
26499
- kind: "conditional",
26500
- test,
26501
- consequent,
26502
- alternate
26503
- };
26504
- }
26505
- return test;
27169
+ /** Extra per-scan input form (e.g. a broadcast address for another subnet).
27170
+ * Null = no extra params. Override in providers that support scoped scans. */
27171
+ async getDiscoveryParamsSchema() {
27172
+ return null;
26506
27173
  }
26507
- parseBinary(minPrec) {
26508
- let left = this.parseUnary();
26509
- for (;;) {
26510
- const tok = this.peek();
26511
- if (tok.type !== "punct") break;
26512
- const prec = BINARY_PRECEDENCE[tok.punct];
26513
- if (prec === void 0 || prec < minPrec) break;
26514
- const op = tok.punct;
26515
- this.pos += 1;
26516
- const right = this.parseBinary(prec + 1);
26517
- this.countNode();
26518
- if (isLogicalOp(op)) left = {
26519
- kind: "logical",
26520
- op,
26521
- left,
26522
- right
26523
- };
26524
- else if (isBinaryOp(op)) left = {
26525
- kind: "binary",
26526
- op,
26527
- left,
26528
- right
26529
- };
26530
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
26531
- }
26532
- return left;
27174
+ /**
27175
+ * The DeviceType this provider creates via manual add — derived from the
27176
+ * `deviceClasses` map (first registered type). `null` when manual creation is
27177
+ * unsupported. Lets the Add-Device dialog pick the right type per provider.
27178
+ */
27179
+ async getManualCreationType() {
27180
+ if (!await this.supportsManualCreation()) return { deviceType: null };
27181
+ return { deviceType: Object.values(DeviceType).find((t) => this.deviceClasses[t] !== void 0) ?? null };
26533
27182
  }
26534
- parseUnary() {
26535
- const tok = this.peek();
26536
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
26537
- const op = tok.punct;
26538
- this.pos += 1;
26539
- const operand = this.parseUnary();
26540
- this.countNode();
26541
- return {
26542
- kind: "unary",
26543
- op,
26544
- operand
26545
- };
26546
- }
26547
- return this.parsePrimary();
27183
+ async adoptDiscoveredDevice(_input) {
27184
+ throw new Error(`${this.providerName} provider does not support discovery-based adoption`);
26548
27185
  }
26549
- parsePrimary() {
26550
- const tok = this.next();
26551
- switch (tok.type) {
26552
- case "number":
26553
- this.countNode();
26554
- return {
26555
- kind: "literal",
26556
- value: tok.value
26557
- };
26558
- case "string":
26559
- this.countNode();
26560
- return {
26561
- kind: "literal",
26562
- value: tok.value
26563
- };
26564
- case "keyword":
26565
- this.countNode();
26566
- return {
26567
- kind: "literal",
26568
- value: tok.keyword === "null" ? null : tok.keyword === "true"
26569
- };
26570
- case "identifier": {
26571
- const nextTok = this.peek();
26572
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
26573
- this.identifiers.add(tok.name);
26574
- this.countNode();
26575
- return {
26576
- kind: "identifier",
26577
- name: tok.name
26578
- };
26579
- }
26580
- case "punct":
26581
- if (tok.punct === "(") {
26582
- const inner = this.parseTernary();
26583
- this.expectPunct(")");
26584
- return inner;
26585
- }
26586
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
26587
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
26588
- }
27186
+ async supportsManualCreation() {
27187
+ return true;
26589
27188
  }
26590
- parseCall(callee, pos) {
26591
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
26592
- this.expectPunct("(");
26593
- const args = [];
26594
- if (!this.matchPunct(")")) for (;;) {
26595
- args.push(this.parseTernary());
26596
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
26597
- if (this.matchPunct(",")) continue;
26598
- this.expectPunct(")");
26599
- break;
27189
+ async getChildCreationSchema(input) {
27190
+ return this.onGetCreationSchema(input.type);
27191
+ }
27192
+ /**
27193
+ * Default kernel-orchestrated `createDevice` implementation. The
27194
+ * subclass's `onCreateDevice` returns a declarative
27195
+ * `CreateDeviceSpec` (`{meta, config}`) — this method handles
27196
+ * stableId generation, class lookup, kernel.devices.create
27197
+ * dispatch, and DeviceSummary mapping. Subclasses should NOT
27198
+ * override this method; override `onCreateDevice` and
27199
+ * `deviceClasses` instead.
27200
+ */
27201
+ async createDevice(input) {
27202
+ const spec = await this.onCreateDevice(input.type, input.config);
27203
+ const Class = this.deviceClasses[spec.meta.type];
27204
+ if (!Class) throw new Error(`${this.providerName} provider: no device class registered for type "${spec.meta.type}" — add it to the deviceClasses map`);
27205
+ const stableId = this.generateStableId(spec.meta.type, spec.config);
27206
+ const device = await this.ctx.kernel.devices.create(stableId, Class, spec.config, null, spec.meta);
27207
+ if (spec.onAfterCreate) try {
27208
+ await spec.onAfterCreate(device);
27209
+ } catch (err) {
27210
+ this.ctx.logger.warn("createDevice: onAfterCreate hook threw — device is already registered", {
27211
+ tags: {
27212
+ deviceId: device.id,
27213
+ stableId
27214
+ },
27215
+ meta: { error: err instanceof Error ? err.message : String(err) }
27216
+ });
26600
27217
  }
26601
- this.callees.add(callee);
26602
- this.countNode();
26603
- return {
26604
- kind: "call",
26605
- callee,
26606
- args
26607
- };
27218
+ return this.toSummary(device);
26608
27219
  }
26609
- };
26610
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
26611
- * `ExpressionParseError` on any lexical or grammatical failure. */
26612
- function parseExpression(source) {
26613
- return new Parser(tokenize(source)).parse();
26614
- }
26615
- /**
26616
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
26617
- * by expr"). The cache stores BOTH successes and failures (negative caching),
26618
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
26619
- * one per read on a hot resolve path.
26620
- *
26621
- * The cache is a module-level singleton: entries are pure, content-addressed
26622
- * ASTs keyed by the raw source string, so sharing one instance across all
26623
- * callers is safe and maximises hit rate.
26624
- */
26625
- var cache = /* @__PURE__ */ new Map();
26626
- function getCached(source) {
26627
- const hit = cache.get(source);
26628
- if (hit !== void 0) {
26629
- cache.delete(source);
26630
- cache.set(source, hit);
26631
- return hit;
27220
+ /**
27221
+ * Generate a stableId for a newly-created device. Default uses the
27222
+ * `${addonId}-${Date.now()}` pattern as a unique-but-opaque
27223
+ * fallback; any provider that has access to durable hardware
27224
+ * identity (UID, MAC, serial) should override and derive from it
27225
+ * so re-adding the same physical device reuses its persisted row.
27226
+ *
27227
+ * `config` is the parsed CreateDeviceSpec.config the subclass
27228
+ * returned from `onCreateDevice` — the override has access to
27229
+ * every operator-supplied + autodetect-resolved field. Optional
27230
+ * for back-compat: existing overrides that take only `type`
27231
+ * keep working unchanged.
27232
+ */
27233
+ generateStableId(_type, _config) {
27234
+ return `${this.addonId}-${Date.now()}`;
26632
27235
  }
26633
- let result;
26634
- try {
26635
- result = {
26636
- ok: true,
26637
- parsed: parseExpression(source)
26638
- };
26639
- } catch (err) {
26640
- result = {
26641
- ok: false,
26642
- error: err instanceof ExpressionParseError ? err.message : String(err)
27236
+ async testCreationField(_input) {
27237
+ return {
27238
+ status: "ok",
27239
+ labels: ["probe not implemented"]
26643
27240
  };
26644
27241
  }
26645
- cache.set(source, result);
26646
- if (cache.size > 256) {
26647
- const oldest = cache.keys().next().value;
26648
- if (oldest !== void 0) cache.delete(oldest);
27242
+ async restoreDevices(savedDevices) {
27243
+ await this.onRestoreDevices(savedDevices);
27244
+ if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
26649
27245
  }
26650
- return result;
26651
- }
26652
- /** Compile `source`, returning a discriminated result instead of throwing.
26653
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
26654
- function compileExpressionSafe(source) {
26655
- return getCached(source);
26656
- }
26657
- Object.freeze({});
26658
- /**
26659
- * Author-time validation. Returns `null` when the source is valid, else a
26660
- * human-readable error message. Checks: the expression compiles; binding count
26661
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
26662
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
26663
- * FREE identifier of the AST is covered by a binding or the injected `now`.
26664
- */
26665
- function validateExpressionSource(src) {
26666
- const names = Object.keys(src.bindings);
26667
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
26668
- for (const name of names) {
26669
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
26670
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
26671
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
27246
+ /**
27247
+ * Restore devices from persisted state. Two-pass:
27248
+ *
27249
+ * 1. **Top-level pass** — invokes `kernel.devices.create()` for every
27250
+ * `parentDeviceId === null` row using the `deviceClasses` map.
27251
+ * The kernel's register flow handles `getAccessoryChildren()` for
27252
+ * each parent (siren / floodlight / PIR / etc).
27253
+ *
27254
+ * 2. **Hub-adopted children pass** — for rows with
27255
+ * `parentDeviceId !== null` whose `type` IS in `deviceClasses`
27256
+ * (e.g. Reolink hub-adopted cameras under an NVR), spawn them
27257
+ * explicitly with the persisted `parentDeviceId`. These are
27258
+ * NOT accessory children — they're first-class adopted devices
27259
+ * that just happen to have a parent. Without this pass, every
27260
+ * server restart would lose hub-adopted cameras (their type is
27261
+ * in `deviceClasses` but parent's `getAccessoryChildren` doesn't
27262
+ * spawn them — that callback is only for purpose-built
27263
+ * accessory roles).
27264
+ *
27265
+ * Rows whose `type` is NOT in `deviceClasses` are skipped — those
27266
+ * are accessory children (siren/light/sensor) that the kernel's
27267
+ * accessory-spawn flow handles via the parent's
27268
+ * `getAccessoryChildren()`. Override only when the default doesn't
27269
+ * fit.
27270
+ */
27271
+ async onRestoreDevices(savedDevices) {
27272
+ const restored = /* @__PURE__ */ new Set();
27273
+ for (const saved of savedDevices) {
27274
+ if (saved.parentDeviceId !== null) continue;
27275
+ const Class = this.deviceClasses[saved.type];
27276
+ if (!Class) {
27277
+ this.ctx.logger.warn("No device class registered for restored type — skipping", {
27278
+ tags: { stableId: saved.stableId },
27279
+ meta: { type: saved.type }
27280
+ });
27281
+ continue;
27282
+ }
27283
+ try {
27284
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {});
27285
+ restored.add(saved.id);
27286
+ } catch (err) {
27287
+ this.ctx.logger.warn("Failed to restore device", {
27288
+ tags: { stableId: saved.stableId },
27289
+ meta: {
27290
+ type: saved.type,
27291
+ error: err instanceof Error ? err.message : String(err)
27292
+ }
27293
+ });
27294
+ }
27295
+ }
27296
+ const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
27297
+ for (const saved of childRows) {
27298
+ const Class = this.deviceClasses[saved.type];
27299
+ if (!Class) continue;
27300
+ if (saved.parentDeviceId === null) continue;
27301
+ if (!restored.has(saved.parentDeviceId)) continue;
27302
+ try {
27303
+ await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
27304
+ restored.add(saved.id);
27305
+ } catch (err) {
27306
+ this.ctx.logger.warn("Failed to restore hub-adopted child", {
27307
+ tags: {
27308
+ stableId: saved.stableId,
27309
+ parentDeviceId: saved.parentDeviceId
27310
+ },
27311
+ meta: {
27312
+ type: saved.type,
27313
+ error: err instanceof Error ? err.message : String(err)
27314
+ }
27315
+ });
27316
+ }
27317
+ }
26672
27318
  }
26673
- const compiled = compileExpressionSafe(src.expr);
26674
- if (!compiled.ok) return compiled.error;
26675
- const bound = new Set(names);
26676
- for (const id of compiled.parsed.identifiers) {
26677
- if (id === "now") continue;
26678
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
27319
+ /** Convert an IDevice to the flat DeviceSummary for the cap router. */
27320
+ toSummary(device) {
27321
+ return toDeviceSummary(device, this.addonId);
26679
27322
  }
26680
- return null;
26681
- }
26682
- var ExpressionBindingSourceSchema = union([
26683
- object({
26684
- kind: literal("field").optional(),
26685
- sourceKey: string(),
26686
- cap: string(),
26687
- fieldPath: string()
26688
- }),
26689
- object({
26690
- kind: literal("literal"),
26691
- value: union([
26692
- string(),
26693
- number(),
26694
- boolean(),
26695
- _null()
26696
- ])
26697
- }),
26698
- object({
26699
- kind: literal("global"),
26700
- sourceStableId: string(),
26701
- cap: string(),
26702
- fieldPath: string()
26703
- })
26704
- ]);
26705
- object({
26706
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
26707
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
26708
- }).superRefine((src, ctx) => {
26709
- const err = validateExpressionSource(src);
26710
- if (err !== null) ctx.addIssue({
26711
- code: "custom",
26712
- message: err,
26713
- path: ["expr"]
26714
- });
26715
- });
27323
+ };
27324
+ 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;
27325
+ new Set(Object.values(DeviceType));
27326
+ DeviceFeature.BatteryOperated;
26716
27327
  Object.freeze({
26717
27328
  "accessories.setChildHidden": {
26718
27329
  capName: "accessories",
@@ -27488,6 +28099,18 @@ Object.freeze({
27488
28099
  addonId: null,
27489
28100
  access: "create"
27490
28101
  },
28102
+ "connectionTest.describeTest": {
28103
+ capName: "connection-test",
28104
+ capScope: "system",
28105
+ addonId: null,
28106
+ access: "view"
28107
+ },
28108
+ "connectionTest.testSettings": {
28109
+ capName: "connection-test",
28110
+ capScope: "system",
28111
+ addonId: null,
28112
+ access: "create"
28113
+ },
27491
28114
  "consumables.reset": {
27492
28115
  capName: "consumables",
27493
28116
  capScope: "device",
@@ -27878,6 +28501,12 @@ Object.freeze({
27878
28501
  addonId: null,
27879
28502
  access: "create"
27880
28503
  },
28504
+ "deviceManager.adoptionCancelJob": {
28505
+ capName: "device-manager",
28506
+ capScope: "system",
28507
+ addonId: null,
28508
+ access: "create"
28509
+ },
27881
28510
  "deviceManager.adoptionListCandidateFilters": {
27882
28511
  capName: "device-manager",
27883
28512
  capScope: "system",
@@ -27890,6 +28519,12 @@ Object.freeze({
27890
28519
  addonId: null,
27891
28520
  access: "view"
27892
28521
  },
28522
+ "deviceManager.adoptionListJobs": {
28523
+ capName: "device-manager",
28524
+ capScope: "system",
28525
+ addonId: null,
28526
+ access: "view"
28527
+ },
27893
28528
  "deviceManager.adoptionRefresh": {
27894
28529
  capName: "device-manager",
27895
28530
  capScope: "system",
@@ -27908,6 +28543,12 @@ Object.freeze({
27908
28543
  addonId: null,
27909
28544
  access: "create"
27910
28545
  },
28546
+ "deviceManager.adoptionStartJob": {
28547
+ capName: "device-manager",
28548
+ capScope: "system",
28549
+ addonId: null,
28550
+ access: "create"
28551
+ },
27911
28552
  "deviceManager.allocateDeviceId": {
27912
28553
  capName: "device-manager",
27913
28554
  capScope: "system",
@@ -31046,6 +31687,12 @@ Object.freeze({
31046
31687
  addonId: null,
31047
31688
  access: "view"
31048
31689
  },
31690
+ "snapshot.getSnapshotLinks": {
31691
+ capName: "snapshot",
31692
+ capScope: "device",
31693
+ addonId: null,
31694
+ access: "view"
31695
+ },
31049
31696
  "snapshot.getSnapshotOverview": {
31050
31697
  capName: "snapshot",
31051
31698
  capScope: "device",
@@ -56796,8 +57443,9 @@ var HmChildDevice = class extends BaseDevice {
56796
57443
  }
56797
57444
  /**
56798
57445
  * Attach a facade `valueChanged` listener filtered to this child's device
56799
- * address. The subclass-supplied `handle` runs only for matching events.
56800
- * No-ops when no facade is present (the child reconnects on next reconcile).
57446
+ * address AND — when the child owns one — to its channel. The
57447
+ * subclass-supplied `handle` runs only for matching events. No-ops when no
57448
+ * facade is present (the child reconnects on next reconcile).
56801
57449
  */
56802
57450
  attachValueChangedListener(handle) {
56803
57451
  this.valueHandler = handle;
@@ -56805,12 +57453,41 @@ var HmChildDevice = class extends BaseDevice {
56805
57453
  if (!facade) return;
56806
57454
  this.valueChangedUnsub = facade.on("valueChanged", (event) => {
56807
57455
  if (event.device !== this.address) return;
57456
+ if (!this.ownsEventChannel(event)) return;
56808
57457
  handle(event);
56809
57458
  });
56810
57459
  this.seedTriggerUnsubs = [facade.on("ready", () => this.seedCurrentValues()), facade.on("connection", () => this.seedCurrentValues())];
56811
57460
  this.seedCurrentValues();
56812
57461
  }
56813
57462
  /**
57463
+ * Whether an inbound value event belongs to THIS child's channel.
57464
+ *
57465
+ * One CCU device address can host SEVERAL independent functional entities —
57466
+ * an HmIP-BS2 carries two unrelated relays, on `<addr>:4` and `<addr>:8`, and
57467
+ * BOTH emit `STATE`. Filtering on the device address alone let either relay's
57468
+ * event write the other's slice: actuating one made both report ON while only
57469
+ * one was physically on. The seed path (`seedCurrentValues`) has always been
57470
+ * channel-scoped; this makes the live path agree with it.
57471
+ *
57472
+ * Channel-less children (event-emitter, firmware update) span the whole
57473
+ * device by design and accept every channel.
57474
+ */
57475
+ ownsEventChannel(event) {
57476
+ const owned = this.channel;
57477
+ if (owned === void 0) return true;
57478
+ const channel = event.channel;
57479
+ if (typeof channel === "string" && channel.length > 0) return channel === owned;
57480
+ this.ctx.logger.warn("Homematic value event without a channel — dropped", {
57481
+ tags: { deviceId: this.ctx.id },
57482
+ meta: {
57483
+ address: this.address,
57484
+ ownedChannel: owned,
57485
+ parameter: event.parameter
57486
+ }
57487
+ });
57488
+ return false;
57489
+ }
57490
+ /**
56814
57491
  * Replay this child's CURRENT cached datapoint values through the registered
56815
57492
  * value handler. Reads the live `HmDevice` snapshot (the library backfills the
56816
57493
  * seeded values onto its datapoints) and feeds each non-null value on THIS