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