@camstack/system 1.2.66 → 1.2.67

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 (53) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  8. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  9. package/dist/builtins/console-logging/index.js +1 -1
  10. package/dist/builtins/console-logging/index.mjs +1 -1
  11. package/dist/builtins/core-blocks/blocks-integration.d.ts +24 -0
  12. package/dist/builtins/core-blocks/core-blocks.addon.d.ts +14 -0
  13. package/dist/builtins/core-blocks/core-blocks.addon.js +99 -2
  14. package/dist/builtins/core-blocks/core-blocks.addon.mjs +99 -2
  15. package/dist/builtins/device-manager/device-manager.addon.js +3 -1
  16. package/dist/builtins/device-manager/device-manager.addon.mjs +3 -1
  17. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  18. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  19. package/dist/builtins/hub-forwarder/index.js +1 -1
  20. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  21. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  22. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  23. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  24. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  25. package/dist/builtins/local-network/local-network.addon.js +1 -1
  26. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  27. package/dist/builtins/loki-logging/index.js +1 -1
  28. package/dist/builtins/loki-logging/index.mjs +1 -1
  29. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  30. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  31. package/dist/builtins/platform-probe/index.js +1 -1
  32. package/dist/builtins/platform-probe/index.mjs +1 -1
  33. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  34. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  35. package/dist/builtins/snapshot/index.js +1 -1
  36. package/dist/builtins/snapshot/index.mjs +1 -1
  37. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  38. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  39. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
  40. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
  41. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  42. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  43. package/dist/builtins/system-config/system-config.addon.js +1 -1
  44. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  45. package/dist/builtins/winston-logging/index.js +1 -1
  46. package/dist/builtins/winston-logging/index.mjs +1 -1
  47. package/dist/{dist-DqPyKwVB.mjs → dist-CbS6uoSb.mjs} +2465 -2075
  48. package/dist/{dist-CIkGOAcz.js → dist-NRYjb8qK.js} +1891 -1489
  49. package/dist/index.js +28 -4
  50. package/dist/index.mjs +28 -4
  51. package/dist/kernel/cap-router-builder.d.ts +19 -0
  52. package/dist/kernel/capability-registry.d.ts +14 -1
  53. package/package.json +1 -1
@@ -4050,357 +4050,1157 @@ var ConvertResultSchema = zod.z.object({
4050
4050
  artifacts: zod.z.array(ConvertArtifactSchema).readonly()
4051
4051
  });
4052
4052
  /**
4053
- * `addon-pages` system-scoped singleton aggregator cap. Public-facing
4054
- * surface that admin-ui consumes through `useAddonPagesListPages()`.
4055
- *
4056
- * The provider iterates every `addon-pages-source` (collection) provider
4057
- * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
4058
- * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
4059
- * filesystem `mtime` cache-buster lets the browser pick up addon
4060
- * rebuilds without manual reload.
4061
- *
4062
- * The hub-local builtin `addon-pages-aggregator` (see
4063
- * `@camstack/system/builtins/addon-pages-aggregator`) registers the
4064
- * provider. Splitting the public aggregator from the raw collection
4065
- * keeps both ends in codegen — there's no hand-written
4066
- * `addon-pages.router.ts` wrapper anymore.
4067
- */
4068
- var AddonPageDeclarationSchema$1 = zod.z.object({
4069
- id: zod.z.string(),
4070
- label: zod.z.string(),
4071
- icon: zod.z.string(),
4072
- path: zod.z.string(),
4073
- remoteName: zod.z.string(),
4074
- bundle: zod.z.string(),
4075
- section: zod.z.string().optional(),
4076
- sectionLabel: zod.z.string().optional()
4077
- });
4078
- var AddonPageInfoSchema = zod.z.object({
4079
- addonId: zod.z.string(),
4080
- page: AddonPageDeclarationSchema$1,
4081
- bundleUrl: zod.z.string()
4082
- });
4083
- var addonPagesCapability = {
4084
- name: "addon-pages",
4085
- scope: "system",
4086
- mode: "singleton",
4087
- methods: { listPages: method(zod.z.void(), zod.z.array(AddonPageInfoSchema).readonly()) }
4088
- };
4089
- /**
4090
- * `addon-pages-source` — collection cap exposing per-provider raw page
4091
- * declarations. Every addon that contributes a UI page registers a
4092
- * provider here. The hub-side singleton aggregator (`addon-pages` cap,
4093
- * see `addon-pages.cap.ts`) walks this collection, stamps versioned
4094
- * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
4095
- * that admin-ui consumes.
4096
- *
4097
- * The split exists because the public listing has a different output
4098
- * shape than the per-provider raw declarations, and we want both ends
4099
- * to flow through codegen instead of relying on a hand-written wrapper.
4053
+ * Error types for the safe expression engine. Two distinct classes so callers
4054
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
4055
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
4100
4056
  */
4101
- var AddonPageDeclarationSchema = zod.z.object({
4102
- id: zod.z.string(),
4103
- label: zod.z.string(),
4104
- icon: zod.z.string(),
4105
- path: zod.z.string(),
4106
- /**
4107
- * Module Federation remote name must match the `name` field on the
4108
- * page addon's `federation()` plugin config. Used by admin-ui's
4109
- * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
4110
- * Conventionally `addon_<id>_page` (snake_case; MF names cannot
4111
- * contain hyphens).
4112
- */
4113
- remoteName: zod.z.string(),
4114
- /**
4115
- * Bundle filename inside the addon's `dist/` dir served at
4116
- * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
4117
- * is always `'remoteEntry.js'`; the value is kept on the metadata so
4118
- * the static-file route can compute an mtime-based cache-buster URL
4119
- * without a separate filesystem stat.
4120
- */
4121
- bundle: zod.z.string(),
4122
- /**
4123
- * Sidebar section this page docks into. Well-known ids: `'detection'`,
4124
- * `'cluster'`, `'administration'` — the page renders inside that group.
4125
- * Any OTHER string creates (or joins) a custom section rendered after
4126
- * the built-in groups; its label comes from `sectionLabel` (first
4127
- * declaration wins), falling back to the id. Absent → the legacy
4128
- * "Addon Pages" group.
4129
- */
4130
- section: zod.z.string().optional(),
4131
- /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
4132
- sectionLabel: zod.z.string().optional()
4133
- });
4134
- var addonPagesSourceCapability = {
4135
- name: "addon-pages-source",
4136
- scope: "system",
4137
- mode: "collection",
4138
- internal: true,
4139
- methods: { listPages: method(zod.z.void(), zod.z.array(AddonPageDeclarationSchema).readonly()) }
4057
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
4058
+ * the failure is anchored to a character (author-facing inline feedback). */
4059
+ var ExpressionParseError = class extends Error {
4060
+ position;
4061
+ constructor(message, position) {
4062
+ super(message);
4063
+ this.name = "ExpressionParseError";
4064
+ this.position = position;
4065
+ }
4140
4066
  };
4141
- var AddonHttpRouteSchema = zod.z.object({
4142
- method: zod.z.enum([
4143
- "GET",
4144
- "POST",
4145
- "PUT",
4146
- "DELETE",
4147
- "PATCH"
4148
- ]),
4149
- path: zod.z.string(),
4150
- access: zod.z.enum([
4151
- "public",
4152
- "authenticated",
4153
- "admin"
4154
- ]).optional(),
4155
- description: zod.z.string().optional()
4156
- });
4157
- /**
4158
- * Cross-process route invocation envelope. The hub captures the
4159
- * request as plain data, ships it to the worker via Moleculer, and
4160
- * the worker runs the local handler against a capturing reply. The
4161
- * envelope returned describes what the handler intended (status,
4162
- * headers, body, or a redirect) so the hub can translate it back to
4163
- * the Fastify reply that's actually wired to the socket.
4164
- */
4165
- var InvokeRequestSchema = zod.z.object({
4166
- method: zod.z.string(),
4167
- path: zod.z.string(),
4168
- params: zod.z.record(zod.z.string(), zod.z.string()),
4169
- query: zod.z.record(zod.z.string(), zod.z.string()),
4170
- body: zod.z.unknown(),
4171
- headers: zod.z.record(zod.z.string(), zod.z.string()),
4172
- user: zod.z.object({
4173
- id: zod.z.string(),
4174
- username: zod.z.string(),
4175
- isAdmin: zod.z.boolean()
4176
- }).optional(),
4177
- scopedToken: zod.z.unknown().optional()
4178
- });
4179
- var InvokeReplyEnvelopeSchema = zod.z.object({
4180
- status: zod.z.number().int(),
4181
- headers: zod.z.record(zod.z.string(), zod.z.string()),
4182
- /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
4183
- * sending `body`. Status defaults to 302 when this is set unless
4184
- * the handler called `reply.code(...)` explicitly. */
4185
- redirectUrl: zod.z.string().nullable(),
4186
- /** JSON-serializable body. `undefined` is treated as "no body". */
4187
- body: zod.z.unknown().optional(),
4188
- /** Set when the handler called `reply.type(mime)`. */
4189
- contentType: zod.z.string().optional()
4190
- });
4191
- var addonRoutesCapability = {
4192
- name: "addon-routes",
4193
- scope: "system",
4194
- mode: "collection",
4195
- internal: true,
4196
- methods: {
4197
- getRoutes: method(zod.z.void(), zod.z.array(AddonHttpRouteSchema)),
4198
- /**
4199
- * Cross-process dispatch entry point. Forked addons implement this
4200
- * (via `buildAddonRouteProvider`) so the hub's Fastify catch-all
4201
- * can route through Moleculer when the handler lives in a worker.
4202
- *
4203
- * Local addons can implement it for free with the same helper;
4204
- * the hub bypasses the wire on co-located addons.
4205
- */
4206
- invoke: method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" })
4207
- },
4208
- /** BIG PLAN 2: declarative mount hint — read by `@camstack/system` `buildCapRouters`. */
4209
- mount: { kind: "skip" }
4067
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
4068
+ * result, unknown builtin, step-budget exceeded). */
4069
+ var ExpressionEvalError = class extends Error {
4070
+ constructor(message) {
4071
+ super(message);
4072
+ this.name = "ExpressionEvalError";
4073
+ }
4210
4074
  };
4211
- var ConfigTabDeclarationSchema = zod.z.object({
4212
- id: zod.z.string(),
4213
- label: zod.z.string(),
4214
- icon: zod.z.string(),
4215
- order: zod.z.number().optional()
4216
- });
4217
- var ConfigSectionWithValuesSchema = zod.z.object({
4218
- id: zod.z.string(),
4219
- title: zod.z.string(),
4220
- description: zod.z.string().optional(),
4221
- style: zod.z.enum(["card", "accordion"]).optional(),
4222
- defaultCollapsed: zod.z.boolean().optional(),
4223
- columns: zod.z.union([
4224
- zod.z.literal(1),
4225
- zod.z.literal(2),
4226
- zod.z.literal(3),
4227
- zod.z.literal(4)
4228
- ]).optional(),
4229
- tab: zod.z.string().optional(),
4230
- location: zod.z.enum(["settings", "top-tab"]).optional(),
4231
- order: zod.z.number().optional(),
4232
- fields: zod.z.array(zod.z.any())
4233
- });
4234
- var SettingsSchemaWithValuesSchema = zod.z.object({
4235
- tabs: zod.z.array(ConfigTabDeclarationSchema).optional(),
4236
- sections: zod.z.array(ConfigSectionWithValuesSchema)
4237
- });
4238
- /** Patch object — keys are field names, values are the new field values. */
4239
- var SettingsPatchSchema = zod.z.record(zod.z.string(), zod.z.unknown());
4240
- /** Standard success response for update operations. */
4241
- var SettingsUpdateResultSchema = zod.z.object({ success: zod.z.literal(true) });
4242
4075
  /**
4243
- * addon-settings singleton gateway for three-level addon settings.
4244
- *
4245
- * Works like `device-manager`: a single hub-side provider that resolves
4246
- * `addonId` to the target addon and delegates the call. For hub-local
4247
- * addons the call is direct; for remote agents it proxies via the
4248
- * per-addon Moleculer service.
4249
- *
4250
- * Replaces the `$addonHost` Moleculer service. Transport transparency
4251
- * is handled by the provider implementation — callers (admin UI, other
4252
- * addons via `ctx.api`) never know which node hosts the target addon.
4076
+ * Frozen, null-prototype builtin function table for the expression engine
4077
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
4078
+ * parser rejects any callee not in it, and the evaluator gates each call on an
4079
+ * own-property check against it.
4253
4080
  *
4254
- * Three levels:
4255
- * - **addon**: addon-scoped settings (installation config, API keys, )
4256
- * - **global**: settings applied to all devices by default
4257
- * - **device**: per-device overrides
4081
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
4082
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
4083
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
4084
+ * (there is no `Object.prototype` in the chain), so those names are not
4085
+ * callable — they are simply "unknown function" at parse time.
4258
4086
  *
4259
- * Optional `nodeId` allows explicit node targeting. When absent the
4260
- * provider resolves the addon's host node automatically.
4261
- */
4262
- /**
4263
- * `addon-settings` is a **hub-centric** cap: the hub hosts the single
4264
- * provider, and `nodeId` in the input is data for the hub provider's
4265
- * internal dispatcher, not a routing hint for the cap-router. See
4266
- * `CapabilityDefinition.nodeIdMode` for the contract.
4087
+ * Every numeric argument is validated as a finite number and every numeric
4088
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
4089
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
4090
+ * closed rather than emitting a garbage value.
4267
4091
  */
4268
- var addonSettingsCapability = {
4269
- name: "addon-settings",
4270
- scope: "system",
4271
- mode: "singleton",
4272
- nodeIdMode: "data",
4273
- methods: {
4274
- getGlobalSettings: method(zod.z.object({
4275
- addonId: zod.z.string(),
4276
- nodeId: zod.z.string().optional(),
4277
- overlay: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
4278
- cap: zod.z.string().optional()
4279
- }), SettingsSchemaWithValuesSchema.nullable()),
4280
- updateGlobalSettings: method(zod.z.object({
4281
- addonId: zod.z.string(),
4282
- nodeId: zod.z.string().optional(),
4283
- patch: SettingsPatchSchema
4284
- }), SettingsUpdateResultSchema, {
4285
- kind: "mutation",
4286
- auth: "admin"
4287
- }),
4288
- getDeviceSettings: method(zod.z.object({
4289
- addonId: zod.z.string(),
4290
- deviceId: zod.z.number(),
4291
- nodeId: zod.z.string().optional()
4292
- }), SettingsSchemaWithValuesSchema.nullable()),
4293
- updateDeviceSettings: method(zod.z.object({
4294
- addonId: zod.z.string(),
4295
- deviceId: zod.z.number(),
4296
- nodeId: zod.z.string().optional(),
4297
- patch: SettingsPatchSchema
4298
- }), SettingsUpdateResultSchema, {
4299
- kind: "mutation",
4300
- auth: "admin"
4301
- })
4092
+ function asFiniteNumber(value, name, index) {
4093
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
4094
+ return value;
4095
+ }
4096
+ function asString$1(value, name, index) {
4097
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
4098
+ return value;
4099
+ }
4100
+ function finiteResult(value, name) {
4101
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
4102
+ return value;
4103
+ }
4104
+ function allFiniteNumbers(args, name) {
4105
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
4106
+ }
4107
+ var INF = Number.POSITIVE_INFINITY;
4108
+ var table = {
4109
+ min: {
4110
+ minArgs: 1,
4111
+ maxArgs: INF,
4112
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
4113
+ },
4114
+ max: {
4115
+ minArgs: 1,
4116
+ maxArgs: INF,
4117
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
4118
+ },
4119
+ abs: {
4120
+ minArgs: 1,
4121
+ maxArgs: 1,
4122
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
4123
+ },
4124
+ floor: {
4125
+ minArgs: 1,
4126
+ maxArgs: 1,
4127
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
4128
+ },
4129
+ ceil: {
4130
+ minArgs: 1,
4131
+ maxArgs: 1,
4132
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
4133
+ },
4134
+ sqrt: {
4135
+ minArgs: 1,
4136
+ maxArgs: 1,
4137
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
4138
+ },
4139
+ round: {
4140
+ minArgs: 1,
4141
+ maxArgs: 2,
4142
+ apply: (args) => {
4143
+ const x = asFiniteNumber(args[0], "round", 0);
4144
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
4145
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
4146
+ const factor = 10 ** digits;
4147
+ return finiteResult(Math.round(x * factor) / factor, "round");
4148
+ }
4149
+ },
4150
+ pow: {
4151
+ minArgs: 2,
4152
+ maxArgs: 2,
4153
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
4154
+ },
4155
+ clamp: {
4156
+ minArgs: 3,
4157
+ maxArgs: 3,
4158
+ apply: (args) => {
4159
+ const x = asFiniteNumber(args[0], "clamp", 0);
4160
+ const lo = asFiniteNumber(args[1], "clamp", 1);
4161
+ const hi = asFiniteNumber(args[2], "clamp", 2);
4162
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
4163
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
4164
+ }
4165
+ },
4166
+ avg: {
4167
+ minArgs: 1,
4168
+ maxArgs: INF,
4169
+ apply: (args) => {
4170
+ const nums = allFiniteNumbers(args, "avg");
4171
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
4172
+ }
4173
+ },
4174
+ sum: {
4175
+ minArgs: 1,
4176
+ maxArgs: INF,
4177
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
4178
+ },
4179
+ coalesce: {
4180
+ minArgs: 1,
4181
+ maxArgs: INF,
4182
+ apply: (args) => {
4183
+ for (const a of args) if (a !== null) return a;
4184
+ return null;
4185
+ }
4186
+ },
4187
+ age: {
4188
+ minArgs: 2,
4189
+ maxArgs: 2,
4190
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
4191
+ },
4192
+ convert: {
4193
+ minArgs: 3,
4194
+ maxArgs: 3,
4195
+ apply: (args, hooks) => {
4196
+ const x = asFiniteNumber(args[0], "convert", 0);
4197
+ const from = asString$1(args[1], "convert", 1).trim();
4198
+ const to = asString$1(args[2], "convert", 2).trim();
4199
+ if (hooks.convert) {
4200
+ const out = hooks.convert(x, from, to);
4201
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
4202
+ return finiteResult(out, "convert");
4203
+ }
4204
+ if (from === to) return x;
4205
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
4206
+ }
4302
4207
  }
4303
4208
  };
4209
+ Object.freeze(Object.assign(Object.create(null), table));
4210
+ /** The set of valid builtin names — used by the parser to reject unknown
4211
+ * callees at parse time (immediate author feedback). */
4212
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
4304
4213
  /**
4305
- * `addon-widgets-source` collection cap exposing per-addon raw widget
4306
- * declarations. Mirrors the addon-pages split: every addon shipping
4307
- * widgets registers a provider on this collection cap; the hub-local
4308
- * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
4309
- * collection, stamps versioned `bundleUrl`s onto each declaration, and
4310
- * exposes the public listing surface that admin-ui consumes.
4311
- *
4312
- * The split exists because the public listing has a different output
4313
- * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
4314
- * per-provider raw declarations. Both ends flow through codegen.
4214
+ * Resource-bound constants for the safe expression engine.
4315
4215
  *
4316
- * Unified UI-contribution model (Task 10): a widget descriptor IS a
4317
- * `UiContribution` with `kind:'remote'`. The host renders it through the
4318
- * same `ContributionRenderer` / Module-Federation path as every other
4319
- * contributed UI surface no bespoke widget-rendering path. The widget-
4320
- * only metadata (sizing hints, `requires`) lives as extra fields on the
4321
- * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
4322
- * `kind` / `remote`) carries identity + placement + the MF remote.
4216
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
4217
+ * loops, recursion, lambdas or member access see `ast.ts`), so evaluation is
4218
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
4219
+ * work a single author-supplied expression can request, so a hostile or
4220
+ * accidental pathological string can never spend unbounded CPU/memory.
4323
4221
  */
4324
- /** Where the widget makes sense to render maps to a contribution `tab`. */
4325
- var WidgetHostEnum = zod.z.enum([
4326
- "device-tab",
4327
- "dashboard",
4328
- "integration-detail"
4329
- ]);
4330
- var WidgetSizeEnum = zod.z.enum([
4331
- "xs",
4332
- "sm",
4333
- "md",
4334
- "lg",
4335
- "xl"
4222
+ /** Max source length (chars) checked BEFORE tokenizing so a huge string is
4223
+ * rejected without allocation. */
4224
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
4225
+ /** A legal binding / identifier name. */
4226
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
4227
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
4228
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
4229
+ var RESERVED_BINDING_NAMES = new Set([
4230
+ "now",
4231
+ "true",
4232
+ "false",
4233
+ "null"
4336
4234
  ]);
4337
4235
  /**
4338
- * MF remote descriptor mirrors `UiContributionRemote` from
4339
- * `capability-definition.ts`. Widget remotes expose a single
4340
- * `'./widgets'` module whose default export is a
4341
- * `Record<componentKey, Component>` map; `componentKey` (the widget
4342
- * `stableId`) picks the entry the host mounts.
4343
- */
4344
- var WidgetRemoteSchema = zod.z.object({
4345
- remoteName: zod.z.string(),
4346
- exposedModule: zod.z.string(),
4347
- componentKey: zod.z.string().optional()
4348
- });
4349
- /**
4350
- * One widget declaration — a `UiContribution` (`kind:'remote'`) plus
4351
- * widget-only metadata. The `UiContribution` core fields:
4352
- *
4353
- * - `tab` — where the widget hosts. A widget that runs on the
4354
- * dashboard declares `tab:'dashboard'`; a device-tab
4355
- * widget declares the target device-detail tab id.
4356
- * - `subTab` — optional sub-tab within `tab`.
4357
- * - `label` — operator-facing label.
4358
- * - `order` — ordering within `(tab, subTab)`.
4359
- * - `kind` — always `'remote'` for widgets.
4360
- * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
4361
- *
4362
- * Widget-only fields retained alongside the contribution core:
4363
- *
4364
- * - `stableId` — stable identity within the addon (the MF
4365
- * `componentKey`; kept top-level so consumers have
4366
- * a stable key without reaching into `remote`).
4367
- * - `description` / `icon` — picker metadata.
4368
- * - `bundle` — entry filename inside the addon `dist/` dir; the
4369
- * aggregator stamps a versioned `bundleUrl` from it.
4370
- * - `hosts` — every host the widget supports (a widget can run
4371
- * both on the dashboard and a device tab). `tab`
4372
- * is the PRIMARY host; `hosts` is the full set the
4373
- * picker filters on.
4374
- * - `requires` — host-context requirements validated at mount.
4375
- * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
4376
- * — dashboard placement hints.
4236
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
4237
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
4238
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
4239
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
4240
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
4241
+ * is a parse error with a source position, so member access / assignment /
4242
+ * template literals are lexically impossible.
4377
4243
  */
4378
- var WidgetMetadataSchema = zod.z.object({
4379
- /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
4380
- tab: zod.z.string(),
4381
- /** Optional sub-tab within `tab`. */
4382
- subTab: zod.z.string().optional(),
4383
- /** Operator-facing label. */
4384
- label: zod.z.string(),
4385
- /** Ordering within `(tab, subTab)`, ascending. */
4386
- order: zod.z.number().optional(),
4387
- /** Always `'remote'` a widget is a Module Federation remote. */
4388
- kind: zod.z.literal("remote"),
4389
- /** MF remote descriptor. */
4390
- remote: WidgetRemoteSchema,
4391
- /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
4392
- stableId: zod.z.string(),
4393
- description: zod.z.string().optional(),
4394
- icon: zod.z.string().optional(),
4395
- /**
4396
- * Bundle filename inside the addon's `dist/` dir served at
4397
- * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
4398
- * this is always `'remoteEntry.js'` — the value is kept on the
4399
- * metadata so the static-file route can compute an mtime-based
4400
- * cache-buster URL without a separate filesystem stat.
4401
- */
4402
- bundle: zod.z.string(),
4403
- /** Every host the widget supports. The picker filters on this set. */
4244
+ var KEYWORDS = new Set([
4245
+ "true",
4246
+ "false",
4247
+ "null"
4248
+ ]);
4249
+ function isDigit(ch) {
4250
+ return ch >= "0" && ch <= "9";
4251
+ }
4252
+ function isIdentStart(ch) {
4253
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
4254
+ }
4255
+ function isIdentPart(ch) {
4256
+ return isIdentStart(ch) || isDigit(ch);
4257
+ }
4258
+ function isWhitespace(ch) {
4259
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
4260
+ }
4261
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
4262
+ * Throws `ExpressionParseError` on any illegal character or unterminated
4263
+ * string. */
4264
+ function tokenize(source) {
4265
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
4266
+ const tokens = [];
4267
+ let i = 0;
4268
+ const n = source.length;
4269
+ while (i < n) {
4270
+ const ch = source[i];
4271
+ if (isWhitespace(ch)) {
4272
+ i += 1;
4273
+ continue;
4274
+ }
4275
+ if (isDigit(ch)) {
4276
+ const start = i;
4277
+ while (i < n && isDigit(source[i])) i += 1;
4278
+ if (i < n && source[i] === ".") {
4279
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
4280
+ i += 1;
4281
+ while (i < n && isDigit(source[i])) i += 1;
4282
+ }
4283
+ const text = source.slice(start, i);
4284
+ const value = Number(text);
4285
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
4286
+ tokens.push({
4287
+ type: "number",
4288
+ value,
4289
+ pos: start
4290
+ });
4291
+ continue;
4292
+ }
4293
+ if (ch === "'" || ch === "\"") {
4294
+ const quote = ch;
4295
+ const start = i;
4296
+ i += 1;
4297
+ let out = "";
4298
+ let closed = false;
4299
+ while (i < n) {
4300
+ const c = source[i];
4301
+ if (c === "\\") {
4302
+ const next = i + 1 < n ? source[i + 1] : "";
4303
+ if (next === "\\" || next === "'" || next === "\"") {
4304
+ out += next;
4305
+ i += 2;
4306
+ continue;
4307
+ }
4308
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
4309
+ }
4310
+ if (c === quote) {
4311
+ closed = true;
4312
+ i += 1;
4313
+ break;
4314
+ }
4315
+ out += c;
4316
+ i += 1;
4317
+ }
4318
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
4319
+ tokens.push({
4320
+ type: "string",
4321
+ value: out,
4322
+ pos: start
4323
+ });
4324
+ continue;
4325
+ }
4326
+ if (isIdentStart(ch)) {
4327
+ const start = i;
4328
+ while (i < n && isIdentPart(source[i])) i += 1;
4329
+ const text = source.slice(start, i);
4330
+ if (KEYWORDS.has(text)) tokens.push({
4331
+ type: "keyword",
4332
+ keyword: keywordOf(text),
4333
+ pos: start
4334
+ });
4335
+ else tokens.push({
4336
+ type: "identifier",
4337
+ name: text,
4338
+ pos: start
4339
+ });
4340
+ continue;
4341
+ }
4342
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
4343
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
4344
+ tokens.push({
4345
+ type: "punct",
4346
+ punct: two,
4347
+ pos: i
4348
+ });
4349
+ i += 2;
4350
+ continue;
4351
+ }
4352
+ if (isSinglePunct(ch)) {
4353
+ tokens.push({
4354
+ type: "punct",
4355
+ punct: ch,
4356
+ pos: i
4357
+ });
4358
+ i += 1;
4359
+ continue;
4360
+ }
4361
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
4362
+ }
4363
+ tokens.push({
4364
+ type: "eof",
4365
+ pos: n
4366
+ });
4367
+ return tokens;
4368
+ }
4369
+ function keywordOf(text) {
4370
+ if (text === "true") return "true";
4371
+ if (text === "false") return "false";
4372
+ return "null";
4373
+ }
4374
+ function isSinglePunct(ch) {
4375
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
4376
+ }
4377
+ /**
4378
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
4379
+ *
4380
+ * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
4381
+ * → relational → additive → multiplicative → unary `! -` → call / primary.
4382
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
4383
+ * string validated against the builtin table at parse time, so an unknown
4384
+ * function is rejected immediately (author feedback) and a persisted expression
4385
+ * that references a since-removed builtin degrades at read.
4386
+ *
4387
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
4388
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
4389
+ */
4390
+ /** Binary/logical operator precedence (higher binds tighter). */
4391
+ var BINARY_PRECEDENCE = {
4392
+ "||": 1,
4393
+ "&&": 2,
4394
+ "==": 3,
4395
+ "!=": 3,
4396
+ "<": 4,
4397
+ "<=": 4,
4398
+ ">": 4,
4399
+ ">=": 4,
4400
+ "+": 5,
4401
+ "-": 5,
4402
+ "*": 6,
4403
+ "/": 6,
4404
+ "%": 6
4405
+ };
4406
+ function isLogicalOp(op) {
4407
+ return op === "&&" || op === "||";
4408
+ }
4409
+ function isBinaryOp(op) {
4410
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
4411
+ }
4412
+ var Parser = class {
4413
+ tokens;
4414
+ pos = 0;
4415
+ nodeCount = 0;
4416
+ identifiers = /* @__PURE__ */ new Set();
4417
+ callees = /* @__PURE__ */ new Set();
4418
+ constructor(tokens) {
4419
+ this.tokens = tokens;
4420
+ }
4421
+ parse() {
4422
+ const ast = this.parseTernary();
4423
+ const tok = this.peek();
4424
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
4425
+ return {
4426
+ ast,
4427
+ identifiers: this.identifiers,
4428
+ callees: this.callees,
4429
+ nodeCount: this.nodeCount
4430
+ };
4431
+ }
4432
+ peek() {
4433
+ return this.tokens[this.pos];
4434
+ }
4435
+ next() {
4436
+ return this.tokens[this.pos++];
4437
+ }
4438
+ /** Consume a punctuator token, erroring if the next token isn't it. */
4439
+ expectPunct(punct) {
4440
+ const tok = this.peek();
4441
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
4442
+ this.pos += 1;
4443
+ }
4444
+ matchPunct(punct) {
4445
+ const tok = this.peek();
4446
+ if (tok.type === "punct" && tok.punct === punct) {
4447
+ this.pos += 1;
4448
+ return true;
4449
+ }
4450
+ return false;
4451
+ }
4452
+ countNode() {
4453
+ this.nodeCount += 1;
4454
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
4455
+ }
4456
+ parseTernary() {
4457
+ const test = this.parseBinary(1);
4458
+ if (this.matchPunct("?")) {
4459
+ const consequent = this.parseTernary();
4460
+ this.expectPunct(":");
4461
+ const alternate = this.parseTernary();
4462
+ this.countNode();
4463
+ return {
4464
+ kind: "conditional",
4465
+ test,
4466
+ consequent,
4467
+ alternate
4468
+ };
4469
+ }
4470
+ return test;
4471
+ }
4472
+ parseBinary(minPrec) {
4473
+ let left = this.parseUnary();
4474
+ for (;;) {
4475
+ const tok = this.peek();
4476
+ if (tok.type !== "punct") break;
4477
+ const prec = BINARY_PRECEDENCE[tok.punct];
4478
+ if (prec === void 0 || prec < minPrec) break;
4479
+ const op = tok.punct;
4480
+ this.pos += 1;
4481
+ const right = this.parseBinary(prec + 1);
4482
+ this.countNode();
4483
+ if (isLogicalOp(op)) left = {
4484
+ kind: "logical",
4485
+ op,
4486
+ left,
4487
+ right
4488
+ };
4489
+ else if (isBinaryOp(op)) left = {
4490
+ kind: "binary",
4491
+ op,
4492
+ left,
4493
+ right
4494
+ };
4495
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
4496
+ }
4497
+ return left;
4498
+ }
4499
+ parseUnary() {
4500
+ const tok = this.peek();
4501
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
4502
+ const op = tok.punct;
4503
+ this.pos += 1;
4504
+ const operand = this.parseUnary();
4505
+ this.countNode();
4506
+ return {
4507
+ kind: "unary",
4508
+ op,
4509
+ operand
4510
+ };
4511
+ }
4512
+ return this.parsePrimary();
4513
+ }
4514
+ parsePrimary() {
4515
+ const tok = this.next();
4516
+ switch (tok.type) {
4517
+ case "number":
4518
+ this.countNode();
4519
+ return {
4520
+ kind: "literal",
4521
+ value: tok.value
4522
+ };
4523
+ case "string":
4524
+ this.countNode();
4525
+ return {
4526
+ kind: "literal",
4527
+ value: tok.value
4528
+ };
4529
+ case "keyword":
4530
+ this.countNode();
4531
+ return {
4532
+ kind: "literal",
4533
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
4534
+ };
4535
+ case "identifier": {
4536
+ const nextTok = this.peek();
4537
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
4538
+ this.identifiers.add(tok.name);
4539
+ this.countNode();
4540
+ return {
4541
+ kind: "identifier",
4542
+ name: tok.name
4543
+ };
4544
+ }
4545
+ case "punct":
4546
+ if (tok.punct === "(") {
4547
+ const inner = this.parseTernary();
4548
+ this.expectPunct(")");
4549
+ return inner;
4550
+ }
4551
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
4552
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
4553
+ }
4554
+ }
4555
+ parseCall(callee, pos) {
4556
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
4557
+ this.expectPunct("(");
4558
+ const args = [];
4559
+ if (!this.matchPunct(")")) for (;;) {
4560
+ args.push(this.parseTernary());
4561
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
4562
+ if (this.matchPunct(",")) continue;
4563
+ this.expectPunct(")");
4564
+ break;
4565
+ }
4566
+ this.callees.add(callee);
4567
+ this.countNode();
4568
+ return {
4569
+ kind: "call",
4570
+ callee,
4571
+ args
4572
+ };
4573
+ }
4574
+ };
4575
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
4576
+ * `ExpressionParseError` on any lexical or grammatical failure. */
4577
+ function parseExpression(source) {
4578
+ return new Parser(tokenize(source)).parse();
4579
+ }
4580
+ /**
4581
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
4582
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
4583
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
4584
+ * one per read on a hot resolve path.
4585
+ *
4586
+ * The cache is a module-level singleton: entries are pure, content-addressed
4587
+ * ASTs keyed by the raw source string, so sharing one instance across all
4588
+ * callers is safe and maximises hit rate.
4589
+ */
4590
+ var cache = /* @__PURE__ */ new Map();
4591
+ function getCached(source) {
4592
+ const hit = cache.get(source);
4593
+ if (hit !== void 0) {
4594
+ cache.delete(source);
4595
+ cache.set(source, hit);
4596
+ return hit;
4597
+ }
4598
+ let result;
4599
+ try {
4600
+ result = {
4601
+ ok: true,
4602
+ parsed: parseExpression(source)
4603
+ };
4604
+ } catch (err) {
4605
+ result = {
4606
+ ok: false,
4607
+ error: err instanceof ExpressionParseError ? err.message : String(err)
4608
+ };
4609
+ }
4610
+ cache.set(source, result);
4611
+ if (cache.size > 256) {
4612
+ const oldest = cache.keys().next().value;
4613
+ if (oldest !== void 0) cache.delete(oldest);
4614
+ }
4615
+ return result;
4616
+ }
4617
+ /** Compile `source`, returning a discriminated result instead of throwing.
4618
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
4619
+ function compileExpressionSafe(source) {
4620
+ return getCached(source);
4621
+ }
4622
+ Object.freeze({});
4623
+ /**
4624
+ * Author-time validation. Returns `null` when the source is valid, else a
4625
+ * human-readable error message. Checks: the expression compiles; binding count
4626
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
4627
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
4628
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
4629
+ */
4630
+ function validateExpressionSource(src) {
4631
+ const names = Object.keys(src.bindings);
4632
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
4633
+ for (const name of names) {
4634
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
4635
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
4636
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
4637
+ }
4638
+ const compiled = compileExpressionSafe(src.expr);
4639
+ if (!compiled.ok) return compiled.error;
4640
+ const bound = new Set(names);
4641
+ for (const id of compiled.parsed.identifiers) {
4642
+ if (id === "now") continue;
4643
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
4644
+ }
4645
+ return null;
4646
+ }
4647
+ /**
4648
+ * What an expression's named bindings READ from.
4649
+ *
4650
+ * Salvaged verbatim from the deleted device-link mechanism. Wiring's source
4651
+ * kinds were the one part of it worth keeping: addressing a device field by
4652
+ * re-sync-stable `stableId`, a per-device constant, and a sibling-accessory
4653
+ * read are the vocabulary any cross-device derivation needs, and they were
4654
+ * already correct. What wiring got wrong was the DESTINATION — a field on
4655
+ * somebody else's device, with no identity — not the source.
4656
+ *
4657
+ * These shapes are therefore kept, re-homed next to the engine that consumes
4658
+ * them, and are the binding type of a composition recipe (the source picker
4659
+ * stays `deviceManager.getWireableFields`). They deliberately do NOT nest: a
4660
+ * binding is a read, never another expression.
4661
+ *
4662
+ * Schemas are authoritative; every type is `z.infer` of one, so a wire shape and
4663
+ * a TypeScript shape cannot drift apart (`scripts/check-schema-type-twins.ts`).
4664
+ */
4665
+ /** Read a sibling accessory's status field, addressed by the sibling's key.
4666
+ * `kind` is optional for wire compatibility — absent means `'field'`. */
4667
+ var ExpressionFieldBindingSchema = zod.z.object({
4668
+ kind: zod.z.literal("field").optional(),
4669
+ sourceKey: zod.z.string(),
4670
+ cap: zod.z.string(),
4671
+ fieldPath: zod.z.string()
4672
+ });
4673
+ /** A constant. No device is read. */
4674
+ var ExpressionLiteralBindingSchema = zod.z.object({
4675
+ kind: zod.z.literal("literal"),
4676
+ value: zod.z.union([
4677
+ zod.z.string(),
4678
+ zod.z.number(),
4679
+ zod.z.boolean(),
4680
+ zod.z.null()
4681
+ ])
4682
+ });
4683
+ /** Read ANY device's status field, addressed by its re-sync-stable `stableId` —
4684
+ * never by numeric id, which a re-adoption reissues. */
4685
+ var ExpressionGlobalBindingSchema = zod.z.object({
4686
+ kind: zod.z.literal("global"),
4687
+ sourceStableId: zod.z.string(),
4688
+ cap: zod.z.string(),
4689
+ fieldPath: zod.z.string()
4690
+ });
4691
+ var ExpressionBindingSourceSchema = zod.z.union([
4692
+ ExpressionFieldBindingSchema,
4693
+ ExpressionLiteralBindingSchema,
4694
+ ExpressionGlobalBindingSchema
4695
+ ]);
4696
+ zod.z.object({
4697
+ expr: zod.z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
4698
+ bindings: zod.z.record(zod.z.string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
4699
+ }).superRefine((src, ctx) => {
4700
+ const err = validateExpressionSource(src);
4701
+ if (err !== null) ctx.addIssue({
4702
+ code: "custom",
4703
+ message: err,
4704
+ path: ["expr"]
4705
+ });
4706
+ });
4707
+ /** How a leaf compares a device field to a value. Derived from the field's
4708
+ * `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
4709
+ var AutomationConditionOperatorSchema = zod.z.enum([
4710
+ "eq",
4711
+ "ne",
4712
+ "gt",
4713
+ "gte",
4714
+ "lt",
4715
+ "lte",
4716
+ "contains",
4717
+ "in"
4718
+ ]);
4719
+ var AutomationConditionLeafSchema = zod.z.object({
4720
+ kind: zod.z.literal("condition"),
4721
+ deviceId: zod.z.number().int().nonnegative(),
4722
+ cap: zod.z.string().min(1),
4723
+ fieldPath: zod.z.string().min(1),
4724
+ operator: AutomationConditionOperatorSchema,
4725
+ value: zod.z.union([
4726
+ zod.z.string(),
4727
+ zod.z.number(),
4728
+ zod.z.boolean(),
4729
+ zod.z.array(zod.z.union([zod.z.string(), zod.z.number()]))
4730
+ ])
4731
+ });
4732
+ /**
4733
+ * The expression leaf, declared as a plain object rather than an intersection
4734
+ * with {@link ExpressionSourceSchema}: a discriminated union has to be able to
4735
+ * read `kind` off each option, and an intersection hides it. The author-time
4736
+ * validation is the SAME function `ExpressionSourceSchema` runs, so the two
4737
+ * cannot drift — an expression that one accepts, the other accepts.
4738
+ */
4739
+ var AutomationConditionExpressionSchema = zod.z.object({
4740
+ kind: zod.z.literal("expression"),
4741
+ expr: zod.z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
4742
+ bindings: zod.z.record(zod.z.string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
4743
+ }).superRefine((src, ctx) => {
4744
+ const err = validateExpressionSource(src);
4745
+ if (err !== null) ctx.addIssue({
4746
+ code: "custom",
4747
+ message: err,
4748
+ path: ["expr"]
4749
+ });
4750
+ });
4751
+ var AutomationConditionSchema = zod.z.lazy(() => zod.z.discriminatedUnion("kind", [
4752
+ zod.z.object({
4753
+ kind: zod.z.literal("all"),
4754
+ children: zod.z.array(AutomationConditionSchema)
4755
+ }),
4756
+ zod.z.object({
4757
+ kind: zod.z.literal("any"),
4758
+ children: zod.z.array(AutomationConditionSchema)
4759
+ }),
4760
+ zod.z.object({
4761
+ kind: zod.z.literal("not"),
4762
+ child: AutomationConditionSchema
4763
+ }),
4764
+ AutomationConditionLeafSchema,
4765
+ AutomationConditionExpressionSchema
4766
+ ]));
4767
+ /**
4768
+ * What starts a run.
4769
+ *
4770
+ * D8 compliance, and it is the reason `device-state` is not merely an event
4771
+ * subscription: the trigger evaluates against the **state mirror**, which is
4772
+ * reconciled, and an event only WAKES the evaluation. A dropped event therefore
4773
+ * DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
4774
+ * already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
4775
+ * not.
4776
+ */
4777
+ var AutomationTriggerSchema = zod.z.discriminatedUnion("kind", [
4778
+ zod.z.object({
4779
+ kind: zod.z.literal("device-state"),
4780
+ deviceId: zod.z.number().int().nonnegative(),
4781
+ cap: zod.z.string().min(1),
4782
+ fieldPath: zod.z.string().min(1),
4783
+ /** Fire when the field takes this value. Omit to fire on any change. */
4784
+ becomes: zod.z.union([
4785
+ zod.z.string(),
4786
+ zod.z.number(),
4787
+ zod.z.boolean()
4788
+ ]).optional(),
4789
+ /** Only on a CHANGE of value, not on every re-report. */
4790
+ edge: zod.z.boolean().optional(),
4791
+ /** The condition must hold this long before the run starts. */
4792
+ forMs: zod.z.number().int().min(0).max(864e5).optional(),
4793
+ /** Collapse a burst into one run. */
4794
+ debounceMs: zod.z.number().int().min(0).max(6e5).optional()
4795
+ }),
4796
+ zod.z.object({
4797
+ kind: zod.z.literal("device-event"),
4798
+ /** An `EventCategory` value. */
4799
+ category: zod.z.string().min(1),
4800
+ deviceId: zod.z.number().int().nonnegative().optional()
4801
+ }),
4802
+ zod.z.object({
4803
+ kind: zod.z.literal("schedule"),
4804
+ cron: zod.z.string().min(1).max(120)
4805
+ }),
4806
+ zod.z.object({ kind: zod.z.literal("manual") })
4807
+ ]);
4808
+ /**
4809
+ * One action step.
4810
+ *
4811
+ * `wait` and `cap` are `NcRuleActionSchema`'s two members, kept structurally
4812
+ * identical so `NcRuleActionRunner` runs them unchanged — its device-scope
4813
+ * check, stop-at-first-failure and per-sequence throttle are the whole reason
4814
+ * to reuse it, and none of them are re-implemented here.
4815
+ *
4816
+ * **The one divergence, and it is forced.** `NcRuleActionSchema.cap.deviceId` is
4817
+ * a literal `z.number().int()`, and the NC runner's own `RunSequencesInput`
4818
+ * documents its subject device as *"for the log tag, never for routing"*. So an
4819
+ * NC action can never target the device that triggered it — which is fine for
4820
+ * the NC (its rules already scope to a device) and fatal for an automation
4821
+ * ("sound the siren of the camera that saw the person"). `deviceId` therefore
4822
+ * also accepts `{ $var }`, resolved from the run's `vars` bag BEFORE the runner
4823
+ * is called. The runner still receives a number and is untouched; the
4824
+ * resolution is the recipe's job, not the runner's.
4825
+ */
4826
+ var AutomationActionSchema = zod.z.discriminatedUnion("kind", [
4827
+ zod.z.object({
4828
+ kind: zod.z.literal("wait"),
4829
+ seconds: zod.z.number().min(0).max(300)
4830
+ }),
4831
+ zod.z.object({
4832
+ kind: zod.z.literal("cap"),
4833
+ deviceId: zod.z.union([zod.z.number().int(), zod.z.object({ $var: zod.z.string().min(1) })]),
4834
+ cap: zod.z.string().min(1),
4835
+ method: zod.z.string().min(1),
4836
+ /** Values may carry `{{vars.x}}` slots, which SUBSTITUTE and do not
4837
+ * evaluate (§3.2.3). Anything beyond substitution is the expression leaf. */
4838
+ args: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
4839
+ }),
4840
+ zod.z.object({
4841
+ kind: zod.z.literal("code"),
4842
+ /** Compiled into the automation's OWN block by esbuild — not a third
4843
+ * runtime, not a `vm`, and not dynamically evaluated. */
4844
+ code: zod.z.string().min(1).max(2e4)
4845
+ })
4846
+ ]);
4847
+ zod.z.object({
4848
+ triggers: zod.z.array(AutomationTriggerSchema),
4849
+ conditions: AutomationConditionSchema.optional(),
4850
+ actions: zod.z.array(AutomationActionSchema)
4851
+ });
4852
+ /**
4853
+ * `addon-pages` — system-scoped singleton aggregator cap. Public-facing
4854
+ * surface that admin-ui consumes through `useAddonPagesListPages()`.
4855
+ *
4856
+ * The provider iterates every `addon-pages-source` (collection) provider
4857
+ * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
4858
+ * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
4859
+ * filesystem `mtime` cache-buster lets the browser pick up addon
4860
+ * rebuilds without manual reload.
4861
+ *
4862
+ * The hub-local builtin `addon-pages-aggregator` (see
4863
+ * `@camstack/system/builtins/addon-pages-aggregator`) registers the
4864
+ * provider. Splitting the public aggregator from the raw collection
4865
+ * keeps both ends in codegen — there's no hand-written
4866
+ * `addon-pages.router.ts` wrapper anymore.
4867
+ */
4868
+ var AddonPageDeclarationSchema$1 = zod.z.object({
4869
+ id: zod.z.string(),
4870
+ label: zod.z.string(),
4871
+ icon: zod.z.string(),
4872
+ path: zod.z.string(),
4873
+ remoteName: zod.z.string(),
4874
+ bundle: zod.z.string(),
4875
+ section: zod.z.string().optional(),
4876
+ sectionLabel: zod.z.string().optional()
4877
+ });
4878
+ var AddonPageInfoSchema = zod.z.object({
4879
+ addonId: zod.z.string(),
4880
+ page: AddonPageDeclarationSchema$1,
4881
+ bundleUrl: zod.z.string()
4882
+ });
4883
+ var addonPagesCapability = {
4884
+ name: "addon-pages",
4885
+ scope: "system",
4886
+ mode: "singleton",
4887
+ methods: { listPages: method(zod.z.void(), zod.z.array(AddonPageInfoSchema).readonly()) }
4888
+ };
4889
+ /**
4890
+ * `addon-pages-source` — collection cap exposing per-provider raw page
4891
+ * declarations. Every addon that contributes a UI page registers a
4892
+ * provider here. The hub-side singleton aggregator (`addon-pages` cap,
4893
+ * see `addon-pages.cap.ts`) walks this collection, stamps versioned
4894
+ * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
4895
+ * that admin-ui consumes.
4896
+ *
4897
+ * The split exists because the public listing has a different output
4898
+ * shape than the per-provider raw declarations, and we want both ends
4899
+ * to flow through codegen instead of relying on a hand-written wrapper.
4900
+ */
4901
+ var AddonPageDeclarationSchema = zod.z.object({
4902
+ id: zod.z.string(),
4903
+ label: zod.z.string(),
4904
+ icon: zod.z.string(),
4905
+ path: zod.z.string(),
4906
+ /**
4907
+ * Module Federation remote name — must match the `name` field on the
4908
+ * page addon's `federation()` plugin config. Used by admin-ui's
4909
+ * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
4910
+ * Conventionally `addon_<id>_page` (snake_case; MF names cannot
4911
+ * contain hyphens).
4912
+ */
4913
+ remoteName: zod.z.string(),
4914
+ /**
4915
+ * Bundle filename inside the addon's `dist/` dir served at
4916
+ * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
4917
+ * is always `'remoteEntry.js'`; the value is kept on the metadata so
4918
+ * the static-file route can compute an mtime-based cache-buster URL
4919
+ * without a separate filesystem stat.
4920
+ */
4921
+ bundle: zod.z.string(),
4922
+ /**
4923
+ * Sidebar section this page docks into. Well-known ids: `'detection'`,
4924
+ * `'cluster'`, `'administration'` — the page renders inside that group.
4925
+ * Any OTHER string creates (or joins) a custom section rendered after
4926
+ * the built-in groups; its label comes from `sectionLabel` (first
4927
+ * declaration wins), falling back to the id. Absent → the legacy
4928
+ * "Addon Pages" group.
4929
+ */
4930
+ section: zod.z.string().optional(),
4931
+ /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
4932
+ sectionLabel: zod.z.string().optional()
4933
+ });
4934
+ var addonPagesSourceCapability = {
4935
+ name: "addon-pages-source",
4936
+ scope: "system",
4937
+ mode: "collection",
4938
+ internal: true,
4939
+ methods: { listPages: method(zod.z.void(), zod.z.array(AddonPageDeclarationSchema).readonly()) }
4940
+ };
4941
+ var AddonHttpRouteSchema = zod.z.object({
4942
+ method: zod.z.enum([
4943
+ "GET",
4944
+ "POST",
4945
+ "PUT",
4946
+ "DELETE",
4947
+ "PATCH"
4948
+ ]),
4949
+ path: zod.z.string(),
4950
+ access: zod.z.enum([
4951
+ "public",
4952
+ "authenticated",
4953
+ "admin"
4954
+ ]).optional(),
4955
+ description: zod.z.string().optional()
4956
+ });
4957
+ /**
4958
+ * Cross-process route invocation envelope. The hub captures the
4959
+ * request as plain data, ships it to the worker via Moleculer, and
4960
+ * the worker runs the local handler against a capturing reply. The
4961
+ * envelope returned describes what the handler intended (status,
4962
+ * headers, body, or a redirect) so the hub can translate it back to
4963
+ * the Fastify reply that's actually wired to the socket.
4964
+ */
4965
+ var InvokeRequestSchema = zod.z.object({
4966
+ method: zod.z.string(),
4967
+ path: zod.z.string(),
4968
+ params: zod.z.record(zod.z.string(), zod.z.string()),
4969
+ query: zod.z.record(zod.z.string(), zod.z.string()),
4970
+ body: zod.z.unknown(),
4971
+ headers: zod.z.record(zod.z.string(), zod.z.string()),
4972
+ user: zod.z.object({
4973
+ id: zod.z.string(),
4974
+ username: zod.z.string(),
4975
+ isAdmin: zod.z.boolean()
4976
+ }).optional(),
4977
+ scopedToken: zod.z.unknown().optional()
4978
+ });
4979
+ var InvokeReplyEnvelopeSchema = zod.z.object({
4980
+ status: zod.z.number().int(),
4981
+ headers: zod.z.record(zod.z.string(), zod.z.string()),
4982
+ /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
4983
+ * sending `body`. Status defaults to 302 when this is set unless
4984
+ * the handler called `reply.code(...)` explicitly. */
4985
+ redirectUrl: zod.z.string().nullable(),
4986
+ /** JSON-serializable body. `undefined` is treated as "no body". */
4987
+ body: zod.z.unknown().optional(),
4988
+ /** Set when the handler called `reply.type(mime)`. */
4989
+ contentType: zod.z.string().optional()
4990
+ });
4991
+ var addonRoutesCapability = {
4992
+ name: "addon-routes",
4993
+ scope: "system",
4994
+ mode: "collection",
4995
+ internal: true,
4996
+ methods: {
4997
+ getRoutes: method(zod.z.void(), zod.z.array(AddonHttpRouteSchema)),
4998
+ /**
4999
+ * Cross-process dispatch entry point. Forked addons implement this
5000
+ * (via `buildAddonRouteProvider`) so the hub's Fastify catch-all
5001
+ * can route through Moleculer when the handler lives in a worker.
5002
+ *
5003
+ * Local addons can implement it for free with the same helper;
5004
+ * the hub bypasses the wire on co-located addons.
5005
+ */
5006
+ invoke: method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" })
5007
+ },
5008
+ /** BIG PLAN 2: declarative mount hint — read by `@camstack/system` `buildCapRouters`. */
5009
+ mount: { kind: "skip" }
5010
+ };
5011
+ var ConfigTabDeclarationSchema = zod.z.object({
5012
+ id: zod.z.string(),
5013
+ label: zod.z.string(),
5014
+ icon: zod.z.string(),
5015
+ order: zod.z.number().optional()
5016
+ });
5017
+ var ConfigSectionWithValuesSchema = zod.z.object({
5018
+ id: zod.z.string(),
5019
+ title: zod.z.string(),
5020
+ description: zod.z.string().optional(),
5021
+ style: zod.z.enum(["card", "accordion"]).optional(),
5022
+ defaultCollapsed: zod.z.boolean().optional(),
5023
+ columns: zod.z.union([
5024
+ zod.z.literal(1),
5025
+ zod.z.literal(2),
5026
+ zod.z.literal(3),
5027
+ zod.z.literal(4)
5028
+ ]).optional(),
5029
+ tab: zod.z.string().optional(),
5030
+ location: zod.z.enum(["settings", "top-tab"]).optional(),
5031
+ order: zod.z.number().optional(),
5032
+ fields: zod.z.array(zod.z.any())
5033
+ });
5034
+ var SettingsSchemaWithValuesSchema = zod.z.object({
5035
+ tabs: zod.z.array(ConfigTabDeclarationSchema).optional(),
5036
+ sections: zod.z.array(ConfigSectionWithValuesSchema)
5037
+ });
5038
+ /** Patch object — keys are field names, values are the new field values. */
5039
+ var SettingsPatchSchema = zod.z.record(zod.z.string(), zod.z.unknown());
5040
+ /** Standard success response for update operations. */
5041
+ var SettingsUpdateResultSchema = zod.z.object({ success: zod.z.literal(true) });
5042
+ /**
5043
+ * addon-settings — singleton gateway for three-level addon settings.
5044
+ *
5045
+ * Works like `device-manager`: a single hub-side provider that resolves
5046
+ * `addonId` to the target addon and delegates the call. For hub-local
5047
+ * addons the call is direct; for remote agents it proxies via the
5048
+ * per-addon Moleculer service.
5049
+ *
5050
+ * Replaces the `$addonHost` Moleculer service. Transport transparency
5051
+ * is handled by the provider implementation — callers (admin UI, other
5052
+ * addons via `ctx.api`) never know which node hosts the target addon.
5053
+ *
5054
+ * Three levels:
5055
+ * - **addon**: addon-scoped settings (installation config, API keys, …)
5056
+ * - **global**: settings applied to all devices by default
5057
+ * - **device**: per-device overrides
5058
+ *
5059
+ * Optional `nodeId` allows explicit node targeting. When absent the
5060
+ * provider resolves the addon's host node automatically.
5061
+ */
5062
+ /**
5063
+ * `addon-settings` is a **hub-centric** cap: the hub hosts the single
5064
+ * provider, and `nodeId` in the input is data for the hub provider's
5065
+ * internal dispatcher, not a routing hint for the cap-router. See
5066
+ * `CapabilityDefinition.nodeIdMode` for the contract.
5067
+ */
5068
+ var addonSettingsCapability = {
5069
+ name: "addon-settings",
5070
+ scope: "system",
5071
+ mode: "singleton",
5072
+ nodeIdMode: "data",
5073
+ methods: {
5074
+ getGlobalSettings: method(zod.z.object({
5075
+ addonId: zod.z.string(),
5076
+ nodeId: zod.z.string().optional(),
5077
+ overlay: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
5078
+ cap: zod.z.string().optional()
5079
+ }), SettingsSchemaWithValuesSchema.nullable()),
5080
+ updateGlobalSettings: method(zod.z.object({
5081
+ addonId: zod.z.string(),
5082
+ nodeId: zod.z.string().optional(),
5083
+ patch: SettingsPatchSchema
5084
+ }), SettingsUpdateResultSchema, {
5085
+ kind: "mutation",
5086
+ auth: "admin"
5087
+ }),
5088
+ getDeviceSettings: method(zod.z.object({
5089
+ addonId: zod.z.string(),
5090
+ deviceId: zod.z.number(),
5091
+ nodeId: zod.z.string().optional()
5092
+ }), SettingsSchemaWithValuesSchema.nullable()),
5093
+ updateDeviceSettings: method(zod.z.object({
5094
+ addonId: zod.z.string(),
5095
+ deviceId: zod.z.number(),
5096
+ nodeId: zod.z.string().optional(),
5097
+ patch: SettingsPatchSchema
5098
+ }), SettingsUpdateResultSchema, {
5099
+ kind: "mutation",
5100
+ auth: "admin"
5101
+ })
5102
+ }
5103
+ };
5104
+ /**
5105
+ * `addon-widgets-source` — collection cap exposing per-addon raw widget
5106
+ * declarations. Mirrors the addon-pages split: every addon shipping
5107
+ * widgets registers a provider on this collection cap; the hub-local
5108
+ * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
5109
+ * collection, stamps versioned `bundleUrl`s onto each declaration, and
5110
+ * exposes the public listing surface that admin-ui consumes.
5111
+ *
5112
+ * The split exists because the public listing has a different output
5113
+ * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
5114
+ * per-provider raw declarations. Both ends flow through codegen.
5115
+ *
5116
+ * Unified UI-contribution model (Task 10): a widget descriptor IS a
5117
+ * `UiContribution` with `kind:'remote'`. The host renders it through the
5118
+ * same `ContributionRenderer` / Module-Federation path as every other
5119
+ * contributed UI surface — no bespoke widget-rendering path. The widget-
5120
+ * only metadata (sizing hints, `requires`) lives as extra fields on the
5121
+ * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
5122
+ * `kind` / `remote`) carries identity + placement + the MF remote.
5123
+ */
5124
+ /** Where the widget makes sense to render — maps to a contribution `tab`. */
5125
+ var WidgetHostEnum = zod.z.enum([
5126
+ "device-tab",
5127
+ "dashboard",
5128
+ "integration-detail"
5129
+ ]);
5130
+ var WidgetSizeEnum = zod.z.enum([
5131
+ "xs",
5132
+ "sm",
5133
+ "md",
5134
+ "lg",
5135
+ "xl"
5136
+ ]);
5137
+ /**
5138
+ * MF remote descriptor — mirrors `UiContributionRemote` from
5139
+ * `capability-definition.ts`. Widget remotes expose a single
5140
+ * `'./widgets'` module whose default export is a
5141
+ * `Record<componentKey, Component>` map; `componentKey` (the widget
5142
+ * `stableId`) picks the entry the host mounts.
5143
+ */
5144
+ var WidgetRemoteSchema = zod.z.object({
5145
+ remoteName: zod.z.string(),
5146
+ exposedModule: zod.z.string(),
5147
+ componentKey: zod.z.string().optional()
5148
+ });
5149
+ /**
5150
+ * One widget declaration — a `UiContribution` (`kind:'remote'`) plus
5151
+ * widget-only metadata. The `UiContribution` core fields:
5152
+ *
5153
+ * - `tab` — where the widget hosts. A widget that runs on the
5154
+ * dashboard declares `tab:'dashboard'`; a device-tab
5155
+ * widget declares the target device-detail tab id.
5156
+ * - `subTab` — optional sub-tab within `tab`.
5157
+ * - `label` — operator-facing label.
5158
+ * - `order` — ordering within `(tab, subTab)`.
5159
+ * - `kind` — always `'remote'` for widgets.
5160
+ * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
5161
+ *
5162
+ * Widget-only fields retained alongside the contribution core:
5163
+ *
5164
+ * - `stableId` — stable identity within the addon (the MF
5165
+ * `componentKey`; kept top-level so consumers have
5166
+ * a stable key without reaching into `remote`).
5167
+ * - `description` / `icon` — picker metadata.
5168
+ * - `bundle` — entry filename inside the addon `dist/` dir; the
5169
+ * aggregator stamps a versioned `bundleUrl` from it.
5170
+ * - `hosts` — every host the widget supports (a widget can run
5171
+ * both on the dashboard and a device tab). `tab`
5172
+ * is the PRIMARY host; `hosts` is the full set the
5173
+ * picker filters on.
5174
+ * - `requires` — host-context requirements validated at mount.
5175
+ * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
5176
+ * — dashboard placement hints.
5177
+ */
5178
+ var WidgetMetadataSchema = zod.z.object({
5179
+ /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
5180
+ tab: zod.z.string(),
5181
+ /** Optional sub-tab within `tab`. */
5182
+ subTab: zod.z.string().optional(),
5183
+ /** Operator-facing label. */
5184
+ label: zod.z.string(),
5185
+ /** Ordering within `(tab, subTab)`, ascending. */
5186
+ order: zod.z.number().optional(),
5187
+ /** Always `'remote'` — a widget is a Module Federation remote. */
5188
+ kind: zod.z.literal("remote"),
5189
+ /** MF remote descriptor. */
5190
+ remote: WidgetRemoteSchema,
5191
+ /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
5192
+ stableId: zod.z.string(),
5193
+ description: zod.z.string().optional(),
5194
+ icon: zod.z.string().optional(),
5195
+ /**
5196
+ * Bundle filename inside the addon's `dist/` dir served at
5197
+ * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
5198
+ * this is always `'remoteEntry.js'` — the value is kept on the
5199
+ * metadata so the static-file route can compute an mtime-based
5200
+ * cache-buster URL without a separate filesystem stat.
5201
+ */
5202
+ bundle: zod.z.string(),
5203
+ /** Every host the widget supports. The picker filters on this set. */
4404
5204
  hosts: zod.z.array(WidgetHostEnum).readonly(),
4405
5205
  /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
4406
5206
  requires: zod.z.object({
@@ -6598,6 +7398,23 @@ function kebabToCamel(s) {
6598
7398
  * too many — a drift would show as a logs pane that is simply always empty.
6599
7399
  */
6600
7400
  var CORE_BLOCK_ADDON_PREFIX = "core-block-";
7401
+ /**
7402
+ * The builtin's own addon id — and, because of that, the OWNER of the
7403
+ * integration every block's devices hang from.
7404
+ *
7405
+ * A block cannot own one. Every integration read is gated on the installed-addon
7406
+ * set (`addon-registry.service.ts` → `createFilteredRegistry`) and a block's
7407
+ * `core-block-<uuid>` is never in it, so its `getIntegrationByAddonId` answers
7408
+ * null forever while the write succeeds — one invisible, undeletable row per
7409
+ * start. `core-blocks` IS installed, so it reconciles one shared integration and
7410
+ * a block names it by id (`DeclaredDevicesSpec.integrationId`), minting nothing.
7411
+ *
7412
+ * Exported from the contract, next to the runner prefix and for the same reason:
7413
+ * a block reads it as
7414
+ * `ctx.api.integrations.getByAddonId.query({ addonId: CORE_BLOCKS_ADDON_ID })`,
7415
+ * so a second copy of the string is a block that silently declares into nowhere.
7416
+ */
7417
+ var CORE_BLOCKS_ADDON_ID = "core-blocks";
6601
7418
  /** The addon/runner id a block's process runs under. */
6602
7419
  function coreBlockAddonId(blockId) {
6603
7420
  return `${CORE_BLOCK_ADDON_PREFIX}${blockId}`;
@@ -9728,7 +10545,7 @@ var motionDetectionCapability = {
9728
10545
  * Why: pub/sub routing over the system event-bus loses fidelity
9729
10546
  * (callback shape, QoS guarantees, will/retain semantics) and adds
9730
10547
  * refcount bookkeeping that addons would rather own themselves. The
9731
- * canonical consumer (`addon-export-ha-mqtt`) needs raw `mqtt.js`
10548
+ * canonical consumer needs raw `mqtt.js`
9732
10549
  * features anyway — give it the connection config, get out of the way.
9733
10550
  *
9734
10551
  * Consumer flow:
@@ -12471,8 +13288,26 @@ var TrackSchema = zod.z.object({
12471
13288
  /** Periodic snapshots at snapshotIntervalMs cadence (subject to
12472
13289
  * saveThumbnails policy). */
12473
13290
  snapshots: zod.z.array(TrackSnapshotSchema).readonly(),
12474
- /** Deduplicated zones the track has entered at least once. */
13291
+ /** Deduplicated zones the track has entered at least once. Zone IDS. */
12475
13292
  zonesVisited: zod.z.array(zod.z.string()).readonly(),
13293
+ /**
13294
+ * Human NAMES for {@link zonesVisited}, resolved at READ time against the
13295
+ * `zones` capability.
13296
+ *
13297
+ * `zonesVisited` persists ids (`cfeec78c-8d69-…`), which no operator can type
13298
+ * and no card can render — so every free-text search surface was structurally
13299
+ * unable to answer "show me the tracks in Uscio", and did not fail loudly, it
13300
+ * just returned nothing. Resolving here rather than in each client keeps ONE
13301
+ * derivation and costs the clients no extra call (the `zones` cap is
13302
+ * per-device, so a client-side resolve would be a per-camera fan-out on a
13303
+ * surface built to avoid exactly that).
13304
+ *
13305
+ * Resolved, never invented: a zone deleted since the track was written has no
13306
+ * name and is DROPPED, so this array can be shorter than `zonesVisited` — the
13307
+ * two are not positionally aligned. Absent when the track visited no zone, or
13308
+ * when the zone catalogue could not be read.
13309
+ */
13310
+ zoneNames: zod.z.array(zod.z.string()).readonly().optional(),
12476
13311
  /** Deduplicated set of detector classes observed for this track over its
12477
13312
  * life (a track may be reclassified, e.g. person→vehicle). Absent on
12478
13313
  * legacy rows written before class accumulation shipped. */
@@ -25245,1230 +26080,785 @@ var ValveStatusSchema = zod.z.object({
25245
26080
  state: ValveStateSchema,
25246
26081
  /** 0 = fully closed, 100 = fully open. Null when the device has no
25247
26082
  * intermediate position surface. */
25248
- position: zod.z.number().min(0).max(100).nullable(),
25249
- /** Ms epoch when the slice was last updated. */
25250
- lastChangedAt: zod.z.number()
25251
- });
25252
- var valveCapability = {
25253
- name: "valve",
25254
- scope: "device",
25255
- deviceNative: true,
25256
- mode: "singleton",
25257
- deviceTypes: [DeviceType.Valve],
25258
- methods: {
25259
- open: method(zod.z.object({ deviceId: zod.z.number().int().nonnegative() }), zod.z.void(), {
25260
- kind: "mutation",
25261
- auth: "admin"
25262
- }),
25263
- close: method(zod.z.object({ deviceId: zod.z.number().int().nonnegative() }), zod.z.void(), {
25264
- kind: "mutation",
25265
- auth: "admin"
25266
- }),
25267
- stop: method(zod.z.object({ deviceId: zod.z.number().int().nonnegative() }), zod.z.void(), {
25268
- kind: "mutation",
25269
- auth: "admin"
25270
- }),
25271
- setPosition: method(zod.z.object({
25272
- deviceId: zod.z.number().int().nonnegative(),
25273
- position: zod.z.number().min(0).max(100)
25274
- }), zod.z.void(), {
25275
- kind: "mutation",
25276
- auth: "admin"
25277
- })
25278
- },
25279
- status: {
25280
- schema: ValveStatusSchema,
25281
- kind: "push"
25282
- },
25283
- /**
25284
- * Runtime-state slice — mirrored by the kernel. UI controls watch the
25285
- * slice for live position changes during a move.
25286
- */
25287
- runtimeState: ValveStatusSchema
25288
- };
25289
- /**
25290
- * Vibration / shake / impact sensor. Drives Home Assistant
25291
- * `binary_sensor` entries with `device_class: vibration`. Push-driven.
25292
- */
25293
- var VibrationStatusSchema = zod.z.object({
25294
- detected: zod.z.boolean(),
25295
- /** Ms epoch of the last transition. 0 if never observed. */
25296
- lastChangedAt: zod.z.number()
25297
- });
25298
- var vibrationCapability = {
25299
- name: "vibration",
25300
- scope: "device",
25301
- deviceNative: true,
25302
- mode: "singleton",
25303
- deviceTypes: [DeviceType.Sensor],
25304
- methods: {},
25305
- status: {
25306
- schema: VibrationStatusSchema,
25307
- kind: "push"
25308
- },
25309
- runtimeState: VibrationStatusSchema
25310
- };
25311
- /**
25312
- * Water heater / boiler cap. Models HA `water_heater.*` entities — a
25313
- * climate-family actuator with a target temperature, an operation-mode
25314
- * selector (`eco` / `electric` / `gas` / `heat_pump` / `high_demand` /
25315
- * `performance` / `off`), and an optional away-mode toggle.
25316
- *
25317
- * The slice carries the current + target temperature, the active
25318
- * operation mode (HA's `state`) and its available set (HA's
25319
- * `operation_list`), the away flag (HA `away_mode` 'on'/'off' → bool,
25320
- * null when unsupported), plus the `min_temp` / `max_temp` bounds.
25321
- *
25322
- * Providers populate only what the hardware reports — temperature and
25323
- * away fields stay null when the device has no such surface.
25324
- */
25325
- var WaterHeaterStatusSchema = zod.z.object({
25326
- /** Current measured temperature. Null when not reported. */
25327
- currentTemp: zod.z.number().nullable(),
25328
- /** Target temperature setpoint. Null when no setpoint surface. */
25329
- targetTemp: zod.z.number().nullable(),
25330
- /** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
25331
- * `heat_pump` / `high_demand` / `performance` / `off`). Null when the
25332
- * device reports an unknown state. */
25333
- operationMode: zod.z.string().nullable(),
25334
- /** Available operation modes = HA `operation_list`. */
25335
- availableModes: zod.z.array(zod.z.string()),
25336
- /** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
25337
- * has no away surface. */
25338
- away: zod.z.boolean().nullable(),
25339
- /** HA `min_temp` attribute. Null when not reported. */
25340
- minTemp: zod.z.number().nullable(),
25341
- /** HA `max_temp` attribute. Null when not reported. */
25342
- maxTemp: zod.z.number().nullable(),
25343
- /** Ms epoch when the slice was last updated. */
25344
- lastChangedAt: zod.z.number()
25345
- });
25346
- var waterHeaterCapability = {
25347
- name: "water-heater",
25348
- scope: "device",
25349
- deviceNative: true,
25350
- mode: "singleton",
25351
- deviceTypes: [DeviceType.WaterHeater],
25352
- methods: {
25353
- setTargetTemp: method(zod.z.object({
25354
- deviceId: zod.z.number().int().nonnegative(),
25355
- temp: zod.z.number().finite()
25356
- }), zod.z.void(), {
25357
- kind: "mutation",
25358
- auth: "admin"
25359
- }),
25360
- setOperationMode: method(zod.z.object({
25361
- deviceId: zod.z.number().int().nonnegative(),
25362
- mode: zod.z.string().min(1)
25363
- }), zod.z.void(), {
25364
- kind: "mutation",
25365
- auth: "admin"
25366
- }),
25367
- setAway: method(zod.z.object({
25368
- deviceId: zod.z.number().int().nonnegative(),
25369
- on: zod.z.boolean()
25370
- }), zod.z.void(), {
25371
- kind: "mutation",
25372
- auth: "admin"
25373
- })
25374
- },
25375
- status: {
25376
- schema: WaterHeaterStatusSchema,
25377
- kind: "push"
25378
- },
25379
- /**
25380
- * Runtime-state slice — mirrored by the kernel. UI controls watch the
25381
- * slice for live temperature / mode / away changes.
25382
- */
25383
- runtimeState: WaterHeaterStatusSchema
25384
- };
25385
- /**
25386
- * Weather provider cap. Models HA `weather.*` entities — a read-only
25387
- * snapshot of the CURRENT conditions a weather integration reports.
25388
- *
25389
- * Read-only: there are no setters. The slice is populated from upstream
25390
- * pushes (the HA weather entity's state + attributes) and rendered by
25391
- * the UI as a sky scene + readouts.
25392
- *
25393
- * `condition` is the verbatim HA state string (`sunny` / `cloudy` /
25394
- * `rainy` / `snowy` / `partlycloudy` / `pouring` / `lightning` /
25395
- * `lightning-rainy` / `fog` / `windy` / `windy-variant` / `hail` /
25396
- * `clear-night` / `exceptional` / …). The UI maps it to a glyph + tint;
25397
- * unknown strings fall back to a neutral cloud.
25398
- *
25399
- * Every numeric reading is nullable — a given weather integration only
25400
- * populates the metrics it actually provides.
25401
- *
25402
- * Forecast deferred — current conditions only for v1.
25403
- */
25404
- var WeatherStatusSchema = zod.z.object({
25405
- /** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
25406
- * when no condition has been reported yet. */
25407
- condition: zod.z.string().nullable(),
25408
- /** Current temperature in the reported unit. Null when not provided. */
25409
- temperature: zod.z.number().nullable(),
25410
- /** Temperature unit string (e.g. `°C` / `°F`). Null when not provided. */
25411
- temperatureUnit: zod.z.string().nullable(),
25412
- /** Relative humidity (0..100). Null when not provided. */
25413
- humidity: zod.z.number().min(0).max(100).nullable(),
25414
- /** Barometric pressure in the reported unit. Null when not provided. */
25415
- pressure: zod.z.number().nullable(),
25416
- /** Pressure unit string (e.g. `hPa` / `inHg`). Null when not provided. */
25417
- pressureUnit: zod.z.string().nullable(),
25418
- /** Wind speed in the reported unit. Null when not provided. */
25419
- windSpeed: zod.z.number().nullable(),
25420
- /** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
25421
- windSpeedUnit: zod.z.string().nullable(),
25422
- /** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
25423
- windBearing: zod.z.number().nullable(),
26083
+ position: zod.z.number().min(0).max(100).nullable(),
25424
26084
  /** Ms epoch when the slice was last updated. */
25425
- lastFetchedAt: zod.z.number()
26085
+ lastChangedAt: zod.z.number()
25426
26086
  });
25427
- var weatherCapability = {
25428
- name: "weather",
26087
+ var valveCapability = {
26088
+ name: "valve",
25429
26089
  scope: "device",
25430
26090
  deviceNative: true,
25431
26091
  mode: "singleton",
25432
- deviceTypes: [DeviceType.Weather],
25433
- methods: {},
26092
+ deviceTypes: [DeviceType.Valve],
26093
+ methods: {
26094
+ open: method(zod.z.object({ deviceId: zod.z.number().int().nonnegative() }), zod.z.void(), {
26095
+ kind: "mutation",
26096
+ auth: "admin"
26097
+ }),
26098
+ close: method(zod.z.object({ deviceId: zod.z.number().int().nonnegative() }), zod.z.void(), {
26099
+ kind: "mutation",
26100
+ auth: "admin"
26101
+ }),
26102
+ stop: method(zod.z.object({ deviceId: zod.z.number().int().nonnegative() }), zod.z.void(), {
26103
+ kind: "mutation",
26104
+ auth: "admin"
26105
+ }),
26106
+ setPosition: method(zod.z.object({
26107
+ deviceId: zod.z.number().int().nonnegative(),
26108
+ position: zod.z.number().min(0).max(100)
26109
+ }), zod.z.void(), {
26110
+ kind: "mutation",
26111
+ auth: "admin"
26112
+ })
26113
+ },
25434
26114
  status: {
25435
- schema: WeatherStatusSchema,
26115
+ schema: ValveStatusSchema,
25436
26116
  kind: "push"
25437
26117
  },
25438
26118
  /**
25439
- * Runtime-state slice — mirrored by the kernel. The UI reads the
25440
- * current conditions directly from the slice on each weather push.
26119
+ * Runtime-state slice — mirrored by the kernel. UI controls watch the
26120
+ * slice for live position changes during a move.
25441
26121
  */
25442
- runtimeState: WeatherStatusSchema
26122
+ runtimeState: ValveStatusSchema
25443
26123
  };
25444
26124
  /**
25445
- * Per-zone occupancy aggregation produced by the analytics frame
25446
- * processor on every inference result. Covers the full combinatorial
25447
- * matrix the operator UI needs: total objects everywhere, total
25448
- * objects per zone, single class everywhere, single class per zone,
25449
- * objects outside any zone.
25450
- *
25451
- * Counts are derived from the analytics tracker (tracked detections
25452
- * with stable trackIds), not raw detector hits — this filters out
25453
- * one-off detector flickers and gives counts that match what the user
25454
- * sees on the live overlay.
25455
- *
25456
- * Overlap policy: a detection that intersects two zones counts in
25457
- * BOTH zones' `byClass` and `totalObjects` (count-in-each). The
25458
- * `frame` aggregate de-duplicates trivially since it's frame-wide.
25459
- * `unzoned` counts only detections that landed in zero zones.
25460
- */
25461
- var PerScopeBreakdownSchema = zod.z.object({
25462
- /** Total tracked objects in this scope (frame / zone / unzoned). */
25463
- totalObjects: zod.z.number().int().nonnegative(),
25464
- /** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
25465
- byClass: zod.z.record(zod.z.string(), zod.z.number().int().nonnegative())
25466
- });
25467
- var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
25468
- zoneId: zod.z.string(),
25469
- zoneName: zod.z.string(),
25470
- /** TrackIds of objects currently inside this zone — for cross-reference
25471
- * with the per-track detail panel and live overlay. */
25472
- trackIds: zod.z.array(zod.z.string()).readonly()
25473
- });
25474
- /**
25475
- * A parked ("stationary") object surfaced alongside occupancy — an object that
25476
- * settled and stopped moving. It is NO LONGER a tracked object (the tracker was
25477
- * told to forget it so it stops re-spawning tracks/events), but it IS still
25478
- * physically present, so it keeps counting toward `frame` occupancy and is
25479
- * listed here so the UI can show it in a dedicated "Stationary" section instead
25480
- * of flooding the live event feed.
25481
- */
25482
- var StationaryObjectSchema = zod.z.object({
25483
- id: zod.z.string(),
25484
- className: zod.z.string(),
25485
- bbox: zod.z.object({
25486
- x: zod.z.number(),
25487
- y: zod.z.number(),
25488
- w: zod.z.number(),
25489
- h: zod.z.number()
25490
- }),
25491
- frameWidth: zod.z.number().int().nonnegative(),
25492
- frameHeight: zod.z.number().int().nonnegative(),
25493
- /** When the source track was first seen. */
25494
- firstSeenAt: zod.z.number().int(),
25495
- /** When the object was recognised as parked (promotion time). */
25496
- becameStationaryAt: zod.z.number().int(),
25497
- /** Last frame a detection confirmed the object is still there. */
25498
- lastConfirmedAt: zod.z.number().int(),
25499
- /** Enrichment label carried from the source track (identity / plate). */
25500
- label: zod.z.string().optional(),
25501
- /** Native-resolution key-frame media key for the parked object's best image. */
25502
- keyFrameMediaKey: zod.z.string().optional()
25503
- });
25504
- var CameraOccupancySnapshotSchema = zod.z.object({
25505
- /** Frame timestamp of the inference result that produced this snapshot. */
25506
- ts: zod.z.number().int(),
25507
- /** Frame width/height in pixels — let the UI normalize bbox coords. */
25508
- frameWidth: zod.z.number().int().nonnegative(),
25509
- frameHeight: zod.z.number().int().nonnegative(),
25510
- /** Per-zone breakdown — one entry per defined zone (user + onboard). */
25511
- zones: zod.z.array(ZoneScopeBreakdownSchema).readonly(),
25512
- /** Frame-wide aggregate (everywhere, regardless of zone membership).
25513
- * INCLUDES currently-confirmed stationary objects (they are still present). */
25514
- frame: PerScopeBreakdownSchema,
25515
- /** Detections that landed outside every zone. Empty when no zones defined. */
25516
- unzoned: PerScopeBreakdownSchema,
25517
- /** Parked objects on this camera (additive — absent on legacy snapshots).
25518
- * Surfaced separately so the UI shows them in a dedicated section rather
25519
- * than as repeated tracks/events. */
25520
- stationaryObjects: zod.z.array(StationaryObjectSchema).readonly().optional()
25521
- });
25522
- /**
25523
- * Time-series resolution. The history methods return one bucket per
25524
- * step over the requested range. Smaller resolutions cost more
25525
- * memory + bandwidth; bound to discrete steps so caller cannot ask
25526
- * for arbitrary fractional buckets.
26125
+ * Vibration / shake / impact sensor. Drives Home Assistant
26126
+ * `binary_sensor` entries with `device_class: vibration`. Push-driven.
25527
26127
  */
25528
- var HistoryResolutionEnum = zod.z.enum([
25529
- "minute",
25530
- "5min",
25531
- "hour"
25532
- ]);
25533
- var HistoryRangeSchema = zod.z.object({
25534
- /** Range start (epoch ms, inclusive). */
25535
- from: zod.z.number().int(),
25536
- /** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
25537
- to: zod.z.number().int(),
25538
- resolution: HistoryResolutionEnum
25539
- });
25540
- var HistoryPointSchema = zod.z.object({
25541
- /** Bucket midpoint (epoch ms). */
25542
- ts: zod.z.number().int(),
25543
- /** Object count averaged over the bucket (rounded to nearest integer). */
25544
- count: zod.z.number().int().nonnegative()
26128
+ var VibrationStatusSchema = zod.z.object({
26129
+ detected: zod.z.boolean(),
26130
+ /** Ms epoch of the last transition. 0 if never observed. */
26131
+ lastChangedAt: zod.z.number()
25545
26132
  });
25546
- /**
25547
- * Zone Analytics capability — spatial occupancy aggregations over
25548
- * tracked detections. Hosted by `addon-pipeline-analytics`.
25549
- *
25550
- * Live snapshot is mirrored into the `zone-occupancy` device-state
25551
- * slice on every frame; the cap method `getCurrentSnapshot` is a
25552
- * direct read for callers that don't want to subscribe.
25553
- *
25554
- * History methods are sourced from a 60-minute rolling in-memory
25555
- * ring of frame-level samples (sub-second resolution). Queries
25556
- * outside the ring window return empty arrays — durable storage
25557
- * is a follow-up (Step 4b: persisted samples → SQL store).
25558
- *
25559
- * Every history method takes an optional `className` filter:
25560
- * - omitted → bucket count includes ALL classes in scope
25561
- * - present → bucket count restricted to that class
25562
- *
25563
- * The `getZoneHistory` / `getCameraHistory` / `getUnzonedHistory`
25564
- * triple covers the three spatial scopes (per-zone, frame-wide,
25565
- * outside-any-zone). Crossing them with `className?` gives the full
25566
- * combinatorial coverage the operator UI requested.
25567
- */
25568
- var zoneAnalyticsCapability = {
25569
- name: "zone-analytics",
26133
+ var vibrationCapability = {
26134
+ name: "vibration",
25570
26135
  scope: "device",
26136
+ deviceNative: true,
25571
26137
  mode: "singleton",
25572
- deviceTypes: [DeviceType.Camera],
25573
- methods: {
25574
- /** Latest computed occupancy snapshot for this camera. Null when
25575
- * the analytics pipeline hasn't seen a frame for this device yet
25576
- * (no inference result emitted since boot or since binding was
25577
- * activated). */
25578
- getCurrentSnapshot: method(zod.z.object({ deviceId: zod.z.number() }), CameraOccupancySnapshotSchema.nullable()),
25579
- /** Time-series object count inside one zone. `className` optional —
25580
- * omit to count every class in the zone. */
25581
- getZoneHistory: method(zod.z.object({
25582
- deviceId: zod.z.number(),
25583
- zoneId: zod.z.string(),
25584
- className: zod.z.string().optional()
25585
- }).extend(HistoryRangeSchema.shape), zod.z.array(HistoryPointSchema).readonly()),
25586
- /** Time-series frame-wide object count (everywhere). */
25587
- getCameraHistory: method(zod.z.object({
25588
- deviceId: zod.z.number(),
25589
- className: zod.z.string().optional()
25590
- }).extend(HistoryRangeSchema.shape), zod.z.array(HistoryPointSchema).readonly()),
25591
- /** Time-series count of objects outside every zone. */
25592
- getUnzonedHistory: method(zod.z.object({
25593
- deviceId: zod.z.number(),
25594
- className: zod.z.string().optional()
25595
- }).extend(HistoryRangeSchema.shape), zod.z.array(HistoryPointSchema).readonly())
26138
+ deviceTypes: [DeviceType.Sensor],
26139
+ methods: {},
26140
+ status: {
26141
+ schema: VibrationStatusSchema,
26142
+ kind: "push"
25596
26143
  },
25597
- /**
25598
- * Runtime-state slice — the latest occupancy snapshot mirrored by
25599
- * the analytics frame processor on every inference result. Consumers
25600
- * read via `device.state.zoneAnalytics.value` and stay in sync
25601
- * automatically; the explicit `getCurrentSnapshot` cap method is
25602
- * still useful for one-off polls without a subscription.
25603
- */
25604
- runtimeState: CameraOccupancySnapshotSchema
26144
+ runtimeState: VibrationStatusSchema
25605
26145
  };
25606
26146
  /**
25607
- * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
25608
- * cap so a single CRUD surface backs every consumer; each stage has
25609
- * its own dev-state mirror slice (`motion-zone-rules`,
25610
- * `detection-zone-rules`, ) so consumer addons subscribe independently.
25611
- *
25612
- * Extend the enum here when a new gating consumer comes online (audio
25613
- * gating, alert filtering, …) — no other surface needs to change.
25614
- */
25615
- var ZoneRuleStageEnum = zod.z.enum([
25616
- "motion",
25617
- "detection",
25618
- "package"
25619
- ]);
25620
- /**
25621
- * Zone rules capability — per-camera CRUD over the {@link ZoneRule}
25622
- * arrays that decide how each pipeline stage uses the polygon zones.
26147
+ * Water heater / boiler cap. Models HA `water_heater.*` entities a
26148
+ * climate-family actuator with a target temperature, an operation-mode
26149
+ * selector (`eco` / `electric` / `gas` / `heat_pump` / `high_demand` /
26150
+ * `performance` / `off`), and an optional away-mode toggle.
25623
26151
  *
25624
- * Hosted by `addon-pipeline-orchestrator` alongside the zones provider
25625
- * so the operator has a single hub-side source of truth for both
25626
- * geometry and behaviour. Per-stage rules are stored under the
25627
- * `zoneRules.<stage>` key in the orchestrator's per-device store and
25628
- * mirrored to the device-state slice `<stage>-zone-rules` on every
25629
- * mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
25630
- * subscribe to that slice and refresh their gating without
25631
- * round-tripping the cap.
26152
+ * The slice carries the current + target temperature, the active
26153
+ * operation mode (HA's `state`) and its available set (HA's
26154
+ * `operation_list`), the away flag (HA `away_mode` 'on'/'off' bool,
26155
+ * null when unsupported), plus the `min_temp` / `max_temp` bounds.
25632
26156
  *
25633
- * Sets are bulk-replace the operator UI sends the new rule list
25634
- * wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
25635
- * naturally in the rule editor without per-rule mutation chatter.
26157
+ * Providers populate only what the hardware reports temperature and
26158
+ * away fields stay null when the device has no such surface.
25636
26159
  */
25637
- var zoneRulesCapability = {
25638
- name: "zone-rules",
26160
+ var WaterHeaterStatusSchema = zod.z.object({
26161
+ /** Current measured temperature. Null when not reported. */
26162
+ currentTemp: zod.z.number().nullable(),
26163
+ /** Target temperature setpoint. Null when no setpoint surface. */
26164
+ targetTemp: zod.z.number().nullable(),
26165
+ /** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
26166
+ * `heat_pump` / `high_demand` / `performance` / `off`). Null when the
26167
+ * device reports an unknown state. */
26168
+ operationMode: zod.z.string().nullable(),
26169
+ /** Available operation modes = HA `operation_list`. */
26170
+ availableModes: zod.z.array(zod.z.string()),
26171
+ /** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
26172
+ * has no away surface. */
26173
+ away: zod.z.boolean().nullable(),
26174
+ /** HA `min_temp` attribute. Null when not reported. */
26175
+ minTemp: zod.z.number().nullable(),
26176
+ /** HA `max_temp` attribute. Null when not reported. */
26177
+ maxTemp: zod.z.number().nullable(),
26178
+ /** Ms epoch when the slice was last updated. */
26179
+ lastChangedAt: zod.z.number()
26180
+ });
26181
+ var waterHeaterCapability = {
26182
+ name: "water-heater",
25639
26183
  scope: "device",
26184
+ deviceNative: true,
25640
26185
  mode: "singleton",
25641
- deviceTypes: [DeviceType.Camera],
26186
+ deviceTypes: [DeviceType.WaterHeater],
25642
26187
  methods: {
25643
- /** Read the full rule list for a given stage (empty when no rules
25644
- * are defined yet). */
25645
- listRules: method(zod.z.object({
25646
- deviceId: zod.z.number(),
25647
- stage: ZoneRuleStageEnum
25648
- }), zod.z.array(ZoneRuleSchema).readonly()),
25649
- /** Bulk-replace the rule list for one stage. The provider validates
25650
- * each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
25651
- * thresholds in range) and rejects the whole patch if any entry
25652
- * is invalid — partial writes are a configuration footgun. */
25653
- setRules: method(zod.z.object({
25654
- deviceId: zod.z.number(),
25655
- stage: ZoneRuleStageEnum,
25656
- rules: zod.z.array(ZoneRuleSchema).readonly()
26188
+ setTargetTemp: method(zod.z.object({
26189
+ deviceId: zod.z.number().int().nonnegative(),
26190
+ temp: zod.z.number().finite()
26191
+ }), zod.z.void(), {
26192
+ kind: "mutation",
26193
+ auth: "admin"
26194
+ }),
26195
+ setOperationMode: method(zod.z.object({
26196
+ deviceId: zod.z.number().int().nonnegative(),
26197
+ mode: zod.z.string().min(1)
26198
+ }), zod.z.void(), {
26199
+ kind: "mutation",
26200
+ auth: "admin"
26201
+ }),
26202
+ setAway: method(zod.z.object({
26203
+ deviceId: zod.z.number().int().nonnegative(),
26204
+ on: zod.z.boolean()
25657
26205
  }), zod.z.void(), {
25658
26206
  kind: "mutation",
25659
26207
  auth: "admin"
25660
26208
  })
25661
26209
  },
26210
+ status: {
26211
+ schema: WaterHeaterStatusSchema,
26212
+ kind: "push"
26213
+ },
25662
26214
  /**
25663
- * Runtime-state slice — every stage mirrored together so consumers
25664
- * see one reactive handle (`device.state.zoneRules.value`) instead
25665
- * of one per stage. Bulk-replace mutations on any stage write the full
25666
- * `{motion, detection, package}` shape, so subscribers always get the
25667
- * complete current set. Consumers that only care about one stage
25668
- * just read the matching property.
25669
- *
25670
- * `package` backs the package-drop detector — a package zone is a
25671
- * `ZoneRule` on the `'package'` stage referencing drawn polygons
25672
- * (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
25673
- * The orchestrator provider writes this stage as a first-class slice
25674
- * (Phase 4): every mutation mirrors the full `{motion, detection,
25675
- * package}` shape, so consumers read the current package rules directly
25676
- * off `device.state.zoneRules.value.package`.
26215
+ * Runtime-state slice — mirrored by the kernel. UI controls watch the
26216
+ * slice for live temperature / mode / away changes.
25677
26217
  */
25678
- runtimeState: zod.z.object({
25679
- motion: zod.z.array(ZoneRuleSchema).readonly(),
25680
- detection: zod.z.array(ZoneRuleSchema).readonly(),
25681
- package: zod.z.array(ZoneRuleSchema).readonly()
25682
- })
26218
+ runtimeState: WaterHeaterStatusSchema
25683
26219
  };
25684
26220
  /**
25685
- * Runtime defaults -- used by ConfigManager.get() for backward compatibility
25686
- * until Plan B wires all runtime settings to the system_settings SQL table.
26221
+ * Weather provider cap. Models HA `weather.*` entities a read-only
26222
+ * snapshot of the CURRENT conditions a weather integration reports.
25687
26223
  *
25688
- * Moved from @camstack/system/config-schema to @camstack/types so that
25689
- * @camstack/system can reference it without a cross-package kernel import.
25690
- */
25691
- var RUNTIME_DEFAULTS = {
25692
- "features.streaming": true,
25693
- "features.notifications": true,
25694
- "features.objectDetection": false,
25695
- "features.remoteAccess": true,
25696
- "features.agentCluster": false,
25697
- "features.smartHome": true,
25698
- "features.recordings": true,
25699
- "features.backup": true,
25700
- "features.repl": true,
25701
- "retention.detectionEventsDays": 30,
25702
- "retention.audioLevelsDays": 7,
25703
- "logging.level": "info",
25704
- "logging.retentionDays": 30,
25705
- "eventBus.ringBufferSize": 1e4,
25706
- "storage.provider": "sqlite-storage",
25707
- "storage.locations": {
25708
- data: "camstack-data/data",
25709
- media: "camstack-data/media",
25710
- recordings: "camstack-data/recordings",
25711
- cache: "/tmp/camstack-cache",
25712
- logs: "camstack-data/logs",
25713
- models: "camstack-data/models"
25714
- },
25715
- providers: [],
25716
- "ffmpeg.binaryPath": "ffmpeg",
25717
- "ffmpeg.hwAccel": "auto",
25718
- "ffmpeg.threadCount": 0,
25719
- "auth.tokenExpiry": "7d"
25720
- };
25721
- /**
25722
- * Accessory device helpers — shared across drivers.
26224
+ * Read-only: there are no setters. The slice is populated from upstream
26225
+ * pushes (the HA weather entity's state + attributes) and rendered by
26226
+ * the UI as a sky scene + readouts.
25723
26227
  *
25724
- * Many vendor-specific drivers register accessory child devices on
25725
- * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
25726
- * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
25727
- * driver picks the right `DeviceType` + `DeviceRole` explicitly when
25728
- * spawning, builds a name derived from the parent, and produces a
25729
- * stableId tied to the parent so boot-restore can reconstruct the
25730
- * relationship.
26228
+ * `condition` is the verbatim HA state string (`sunny` / `cloudy` /
26229
+ * `rainy` / `snowy` / `partlycloudy` / `pouring` / `lightning` /
26230
+ * `lightning-rainy` / `fog` / `windy` / `windy-variant` / `hail` /
26231
+ * `clear-night` / `exceptional` / …). The UI maps it to a glyph + tint;
26232
+ * unknown strings fall back to a neutral cloud.
25731
26233
  *
25732
- * Centralised `(kind DeviceType)` mapping was dropped on purpose:
25733
- * drivers may reasonably disagree on the right type for an accessory
25734
- * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
25735
- * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
25736
- * one canonical mapping was over-prescriptive and added a layer of
25737
- * indirection without saving meaningful code at call sites — the
25738
- * driver knows its own hardware best.
25739
- */
25740
- /**
25741
- * Subset of `DeviceRole` values that drivers register as child
25742
- * accessories of a parent device. Sourced verbatim from `DeviceRole`
25743
- * — `AccessoryKind` is the alias drivers use when building accessory
25744
- * children, so the call site reads as
25745
- * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
25746
- * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
25747
- * any role works, including non-accessory ones like Doorbell).
26234
+ * Every numeric reading is nullable a given weather integration only
26235
+ * populates the metrics it actually provides.
26236
+ *
26237
+ * Forecast deferred current conditions only for v1.
25748
26238
  */
25749
- var AccessoryKind = {
25750
- Siren: DeviceRole.Siren,
25751
- Floodlight: DeviceRole.Floodlight,
25752
- Spotlight: DeviceRole.Spotlight,
25753
- PirSensor: DeviceRole.PirSensor,
25754
- Chime: DeviceRole.Chime,
25755
- Autotrack: DeviceRole.Autotrack,
25756
- Nightvision: DeviceRole.Nightvision,
25757
- PrivacyMask: DeviceRole.PrivacyMask
26239
+ var WeatherStatusSchema = zod.z.object({
26240
+ /** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
26241
+ * when no condition has been reported yet. */
26242
+ condition: zod.z.string().nullable(),
26243
+ /** Current temperature in the reported unit. Null when not provided. */
26244
+ temperature: zod.z.number().nullable(),
26245
+ /** Temperature unit string (e.g. `°C` / `°F`). Null when not provided. */
26246
+ temperatureUnit: zod.z.string().nullable(),
26247
+ /** Relative humidity (0..100). Null when not provided. */
26248
+ humidity: zod.z.number().min(0).max(100).nullable(),
26249
+ /** Barometric pressure in the reported unit. Null when not provided. */
26250
+ pressure: zod.z.number().nullable(),
26251
+ /** Pressure unit string (e.g. `hPa` / `inHg`). Null when not provided. */
26252
+ pressureUnit: zod.z.string().nullable(),
26253
+ /** Wind speed in the reported unit. Null when not provided. */
26254
+ windSpeed: zod.z.number().nullable(),
26255
+ /** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
26256
+ windSpeedUnit: zod.z.string().nullable(),
26257
+ /** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
26258
+ windBearing: zod.z.number().nullable(),
26259
+ /** Ms epoch when the slice was last updated. */
26260
+ lastFetchedAt: zod.z.number()
26261
+ });
26262
+ var weatherCapability = {
26263
+ name: "weather",
26264
+ scope: "device",
26265
+ deviceNative: true,
26266
+ mode: "singleton",
26267
+ deviceTypes: [DeviceType.Weather],
26268
+ methods: {},
26269
+ status: {
26270
+ schema: WeatherStatusSchema,
26271
+ kind: "push"
26272
+ },
26273
+ /**
26274
+ * Runtime-state slice — mirrored by the kernel. The UI reads the
26275
+ * current conditions directly from the slice on each weather push.
26276
+ */
26277
+ runtimeState: WeatherStatusSchema
25758
26278
  };
25759
- AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
25760
- 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;
25761
- new Set(Object.values(DeviceType));
25762
- DeviceFeature.BatteryOperated;
25763
- /** Unwrap ZodNullable / ZodOptional / ZodDefault wrappers to reach the inner type.
25764
- * Zod v4 exposes `.unwrap()` on all three wrapper classes. */
25765
- function unwrap(schema) {
25766
- let s = schema;
25767
- while (s instanceof zod.z.ZodNullable || s instanceof zod.z.ZodOptional || s instanceof zod.z.ZodDefault) s = s.unwrap();
25768
- return s;
25769
- }
25770
- function leafKind(schema) {
25771
- const s = unwrap(schema);
25772
- if (s instanceof zod.z.ZodEnum) return {
25773
- kind: "enum",
25774
- enumValues: s.options
25775
- };
25776
- if (s instanceof zod.z.ZodNumber) return { kind: "number" };
25777
- if (s instanceof zod.z.ZodString) return { kind: "string" };
25778
- if (s instanceof zod.z.ZodBoolean) return { kind: "boolean" };
25779
- return null;
25780
- }
25781
- /** Walk a cap status schema into flat wireable leaf fields (dotted paths).
25782
- * Recurses into nested ZodObject (unwrapping nullable/optional/default first),
25783
- * so fields with null live values are still offered. Arrays / unknown shapes
25784
- * are skipped — never throws. */
25785
- function enumerateSchemaFields(schema, prefix = "") {
25786
- const root = unwrap(schema);
25787
- if (!(root instanceof zod.z.ZodObject)) return [];
25788
- const out = [];
25789
- const shape = root.shape;
25790
- for (const [key, field] of Object.entries(shape)) {
25791
- const path = prefix ? `${prefix}.${key}` : key;
25792
- const inner = unwrap(field);
25793
- if (inner instanceof zod.z.ZodObject) {
25794
- out.push(...enumerateSchemaFields(inner, path));
25795
- continue;
25796
- }
25797
- const leaf = leafKind(field);
25798
- if (leaf) out.push({
25799
- path,
25800
- kind: leaf.kind,
25801
- ...leaf.enumValues ? { enumValues: leaf.enumValues } : {}
25802
- });
25803
- }
25804
- return out;
25805
- }
25806
- /** Enumerate the per-item wireable fields of an item-array cap (see
25807
- * `CapabilityStatusItemArray`): the item schema's leaf fields, minus the
25808
- * `keyField` (the key comes from the link's `itemKey`, never from a wired
25809
- * source), each tagged `item: true` so the authoring UI collects an
25810
- * `itemKey` alongside the field. Never throws. */
25811
- function enumerateItemArrayFields(itemArray) {
25812
- return enumerateSchemaFields(itemArray.itemSchema).filter((f) => f.path !== itemArray.keyField).map((f) => ({
25813
- ...f,
25814
- item: true
25815
- }));
25816
- }
25817
26279
  /**
25818
- * Error types for the safe expression engine. Two distinct classes so callers
25819
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
25820
- * failure both are non-fatal to the host: read paths degrade to "skip link".
26280
+ * Per-zone occupancy aggregation produced by the analytics frame
26281
+ * processor on every inference result. Covers the full combinatorial
26282
+ * matrix the operator UI needs: total objects everywhere, total
26283
+ * objects per zone, single class everywhere, single class per zone,
26284
+ * objects outside any zone.
26285
+ *
26286
+ * Counts are derived from the analytics tracker (tracked detections
26287
+ * with stable trackIds), not raw detector hits — this filters out
26288
+ * one-off detector flickers and gives counts that match what the user
26289
+ * sees on the live overlay.
26290
+ *
26291
+ * Overlap policy: a detection that intersects two zones counts in
26292
+ * BOTH zones' `byClass` and `totalObjects` (count-in-each). The
26293
+ * `frame` aggregate de-duplicates trivially since it's frame-wide.
26294
+ * `unzoned` counts only detections that landed in zero zones.
26295
+ */
26296
+ var PerScopeBreakdownSchema = zod.z.object({
26297
+ /** Total tracked objects in this scope (frame / zone / unzoned). */
26298
+ totalObjects: zod.z.number().int().nonnegative(),
26299
+ /** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
26300
+ byClass: zod.z.record(zod.z.string(), zod.z.number().int().nonnegative())
26301
+ });
26302
+ var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
26303
+ zoneId: zod.z.string(),
26304
+ zoneName: zod.z.string(),
26305
+ /** TrackIds of objects currently inside this zone — for cross-reference
26306
+ * with the per-track detail panel and live overlay. */
26307
+ trackIds: zod.z.array(zod.z.string()).readonly()
26308
+ });
26309
+ /**
26310
+ * A parked ("stationary") object surfaced alongside occupancy — an object that
26311
+ * settled and stopped moving. It is NO LONGER a tracked object (the tracker was
26312
+ * told to forget it so it stops re-spawning tracks/events), but it IS still
26313
+ * physically present, so it keeps counting toward `frame` occupancy and is
26314
+ * listed here so the UI can show it in a dedicated "Stationary" section instead
26315
+ * of flooding the live event feed.
25821
26316
  */
25822
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
25823
- * the failure is anchored to a character (author-facing inline feedback). */
25824
- var ExpressionParseError = class extends Error {
25825
- position;
25826
- constructor(message, position) {
25827
- super(message);
25828
- this.name = "ExpressionParseError";
25829
- this.position = position;
25830
- }
25831
- };
25832
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
25833
- * result, unknown builtin, step-budget exceeded). */
25834
- var ExpressionEvalError = class extends Error {
25835
- constructor(message) {
25836
- super(message);
25837
- this.name = "ExpressionEvalError";
25838
- }
25839
- };
26317
+ var StationaryObjectSchema = zod.z.object({
26318
+ id: zod.z.string(),
26319
+ className: zod.z.string(),
26320
+ bbox: zod.z.object({
26321
+ x: zod.z.number(),
26322
+ y: zod.z.number(),
26323
+ w: zod.z.number(),
26324
+ h: zod.z.number()
26325
+ }),
26326
+ frameWidth: zod.z.number().int().nonnegative(),
26327
+ frameHeight: zod.z.number().int().nonnegative(),
26328
+ /** When the source track was first seen. */
26329
+ firstSeenAt: zod.z.number().int(),
26330
+ /** When the object was recognised as parked (promotion time). */
26331
+ becameStationaryAt: zod.z.number().int(),
26332
+ /** Last frame a detection confirmed the object is still there. */
26333
+ lastConfirmedAt: zod.z.number().int(),
26334
+ /** Enrichment label carried from the source track (identity / plate). */
26335
+ label: zod.z.string().optional(),
26336
+ /** Native-resolution key-frame media key for the parked object's best image. */
26337
+ keyFrameMediaKey: zod.z.string().optional()
26338
+ });
26339
+ var CameraOccupancySnapshotSchema = zod.z.object({
26340
+ /** Frame timestamp of the inference result that produced this snapshot. */
26341
+ ts: zod.z.number().int(),
26342
+ /** Frame width/height in pixels — let the UI normalize bbox coords. */
26343
+ frameWidth: zod.z.number().int().nonnegative(),
26344
+ frameHeight: zod.z.number().int().nonnegative(),
26345
+ /** Per-zone breakdown — one entry per defined zone (user + onboard). */
26346
+ zones: zod.z.array(ZoneScopeBreakdownSchema).readonly(),
26347
+ /** Frame-wide aggregate (everywhere, regardless of zone membership).
26348
+ * INCLUDES currently-confirmed stationary objects (they are still present). */
26349
+ frame: PerScopeBreakdownSchema,
26350
+ /** Detections that landed outside every zone. Empty when no zones defined. */
26351
+ unzoned: PerScopeBreakdownSchema,
26352
+ /** Parked objects on this camera (additive — absent on legacy snapshots).
26353
+ * Surfaced separately so the UI shows them in a dedicated section rather
26354
+ * than as repeated tracks/events. */
26355
+ stationaryObjects: zod.z.array(StationaryObjectSchema).readonly().optional()
26356
+ });
25840
26357
  /**
25841
- * Frozen, null-prototype builtin function table for the expression engine
25842
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
25843
- * parser rejects any callee not in it, and the evaluator gates each call on an
25844
- * own-property check against it.
26358
+ * Time-series resolution. The history methods return one bucket per
26359
+ * step over the requested range. Smaller resolutions cost more
26360
+ * memory + bandwidth; bound to discrete steps so caller cannot ask
26361
+ * for arbitrary fractional buckets.
26362
+ */
26363
+ var HistoryResolutionEnum = zod.z.enum([
26364
+ "minute",
26365
+ "5min",
26366
+ "hour"
26367
+ ]);
26368
+ var HistoryRangeSchema = zod.z.object({
26369
+ /** Range start (epoch ms, inclusive). */
26370
+ from: zod.z.number().int(),
26371
+ /** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
26372
+ to: zod.z.number().int(),
26373
+ resolution: HistoryResolutionEnum
26374
+ });
26375
+ var HistoryPointSchema = zod.z.object({
26376
+ /** Bucket midpoint (epoch ms). */
26377
+ ts: zod.z.number().int(),
26378
+ /** Object count averaged over the bucket (rounded to nearest integer). */
26379
+ count: zod.z.number().int().nonnegative()
26380
+ });
26381
+ /**
26382
+ * Zone Analytics capability — spatial occupancy aggregations over
26383
+ * tracked detections. Hosted by `addon-pipeline-analytics`.
25845
26384
  *
25846
- * Because the object has a NULL prototype AND is `Object.freeze`d:
25847
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
25848
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
25849
- * (there is no `Object.prototype` in the chain), so those names are not
25850
- * callable — they are simply "unknown function" at parse time.
26385
+ * Live snapshot is mirrored into the `zone-occupancy` device-state
26386
+ * slice on every frame; the cap method `getCurrentSnapshot` is a
26387
+ * direct read for callers that don't want to subscribe.
25851
26388
  *
25852
- * Every numeric argument is validated as a finite number and every numeric
25853
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
25854
- * (`pow(10,400)` Infinity) all raise `ExpressionEvalError` and fail the link
25855
- * closed rather than emitting a garbage value.
26389
+ * History methods are sourced from a 60-minute rolling in-memory
26390
+ * ring of frame-level samples (sub-second resolution). Queries
26391
+ * outside the ring window return empty arrays durable storage
26392
+ * is a follow-up (Step 4b: persisted samples → SQL store).
26393
+ *
26394
+ * Every history method takes an optional `className` filter:
26395
+ * - omitted → bucket count includes ALL classes in scope
26396
+ * - present → bucket count restricted to that class
26397
+ *
26398
+ * The `getZoneHistory` / `getCameraHistory` / `getUnzonedHistory`
26399
+ * triple covers the three spatial scopes (per-zone, frame-wide,
26400
+ * outside-any-zone). Crossing them with `className?` gives the full
26401
+ * combinatorial coverage the operator UI requested.
25856
26402
  */
25857
- function asFiniteNumber(value, name, index) {
25858
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
25859
- return value;
25860
- }
25861
- function asString$1(value, name, index) {
25862
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
25863
- return value;
25864
- }
25865
- function finiteResult(value, name) {
25866
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
25867
- return value;
25868
- }
25869
- function allFiniteNumbers(args, name) {
25870
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
25871
- }
25872
- var INF = Number.POSITIVE_INFINITY;
25873
- var table = {
25874
- min: {
25875
- minArgs: 1,
25876
- maxArgs: INF,
25877
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
25878
- },
25879
- max: {
25880
- minArgs: 1,
25881
- maxArgs: INF,
25882
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
25883
- },
25884
- abs: {
25885
- minArgs: 1,
25886
- maxArgs: 1,
25887
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
25888
- },
25889
- floor: {
25890
- minArgs: 1,
25891
- maxArgs: 1,
25892
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
25893
- },
25894
- ceil: {
25895
- minArgs: 1,
25896
- maxArgs: 1,
25897
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
25898
- },
25899
- sqrt: {
25900
- minArgs: 1,
25901
- maxArgs: 1,
25902
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
25903
- },
25904
- round: {
25905
- minArgs: 1,
25906
- maxArgs: 2,
25907
- apply: (args) => {
25908
- const x = asFiniteNumber(args[0], "round", 0);
25909
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
25910
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
25911
- const factor = 10 ** digits;
25912
- return finiteResult(Math.round(x * factor) / factor, "round");
25913
- }
25914
- },
25915
- pow: {
25916
- minArgs: 2,
25917
- maxArgs: 2,
25918
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
25919
- },
25920
- clamp: {
25921
- minArgs: 3,
25922
- maxArgs: 3,
25923
- apply: (args) => {
25924
- const x = asFiniteNumber(args[0], "clamp", 0);
25925
- const lo = asFiniteNumber(args[1], "clamp", 1);
25926
- const hi = asFiniteNumber(args[2], "clamp", 2);
25927
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
25928
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
25929
- }
25930
- },
25931
- avg: {
25932
- minArgs: 1,
25933
- maxArgs: INF,
25934
- apply: (args) => {
25935
- const nums = allFiniteNumbers(args, "avg");
25936
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
25937
- }
25938
- },
25939
- sum: {
25940
- minArgs: 1,
25941
- maxArgs: INF,
25942
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
25943
- },
25944
- coalesce: {
25945
- minArgs: 1,
25946
- maxArgs: INF,
25947
- apply: (args) => {
25948
- for (const a of args) if (a !== null) return a;
25949
- return null;
25950
- }
25951
- },
25952
- age: {
25953
- minArgs: 2,
25954
- maxArgs: 2,
25955
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
26403
+ var zoneAnalyticsCapability = {
26404
+ name: "zone-analytics",
26405
+ scope: "device",
26406
+ mode: "singleton",
26407
+ deviceTypes: [DeviceType.Camera],
26408
+ methods: {
26409
+ /** Latest computed occupancy snapshot for this camera. Null when
26410
+ * the analytics pipeline hasn't seen a frame for this device yet
26411
+ * (no inference result emitted since boot or since binding was
26412
+ * activated). */
26413
+ getCurrentSnapshot: method(zod.z.object({ deviceId: zod.z.number() }), CameraOccupancySnapshotSchema.nullable()),
26414
+ /** Time-series object count inside one zone. `className` optional —
26415
+ * omit to count every class in the zone. */
26416
+ getZoneHistory: method(zod.z.object({
26417
+ deviceId: zod.z.number(),
26418
+ zoneId: zod.z.string(),
26419
+ className: zod.z.string().optional()
26420
+ }).extend(HistoryRangeSchema.shape), zod.z.array(HistoryPointSchema).readonly()),
26421
+ /** Time-series frame-wide object count (everywhere). */
26422
+ getCameraHistory: method(zod.z.object({
26423
+ deviceId: zod.z.number(),
26424
+ className: zod.z.string().optional()
26425
+ }).extend(HistoryRangeSchema.shape), zod.z.array(HistoryPointSchema).readonly()),
26426
+ /** Time-series count of objects outside every zone. */
26427
+ getUnzonedHistory: method(zod.z.object({
26428
+ deviceId: zod.z.number(),
26429
+ className: zod.z.string().optional()
26430
+ }).extend(HistoryRangeSchema.shape), zod.z.array(HistoryPointSchema).readonly())
25956
26431
  },
25957
- convert: {
25958
- minArgs: 3,
25959
- maxArgs: 3,
25960
- apply: (args, hooks) => {
25961
- const x = asFiniteNumber(args[0], "convert", 0);
25962
- const from = asString$1(args[1], "convert", 1).trim();
25963
- const to = asString$1(args[2], "convert", 2).trim();
25964
- if (hooks.convert) {
25965
- const out = hooks.convert(x, from, to);
25966
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
25967
- return finiteResult(out, "convert");
25968
- }
25969
- if (from === to) return x;
25970
- throw new ExpressionEvalError("convert: unit conversion table not installed");
25971
- }
25972
- }
26432
+ /**
26433
+ * Runtime-state slice — the latest occupancy snapshot mirrored by
26434
+ * the analytics frame processor on every inference result. Consumers
26435
+ * read via `device.state.zoneAnalytics.value` and stay in sync
26436
+ * automatically; the explicit `getCurrentSnapshot` cap method is
26437
+ * still useful for one-off polls without a subscription.
26438
+ */
26439
+ runtimeState: CameraOccupancySnapshotSchema
25973
26440
  };
25974
- Object.freeze(Object.assign(Object.create(null), table));
25975
- /** The set of valid builtin names — used by the parser to reject unknown
25976
- * callees at parse time (immediate author feedback). */
25977
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
25978
26441
  /**
25979
- * Resource-bound constants for the safe expression engine.
26442
+ * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
26443
+ * cap so a single CRUD surface backs every consumer; each stage has
26444
+ * its own dev-state mirror slice (`motion-zone-rules`,
26445
+ * `detection-zone-rules`, …) so consumer addons subscribe independently.
25980
26446
  *
25981
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
25982
- * loops, recursion, lambdas or member access see `ast.ts`), so evaluation is
25983
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
25984
- * work a single author-supplied expression can request, so a hostile or
25985
- * accidental pathological string can never spend unbounded CPU/memory.
26447
+ * Extend the enum here when a new gating consumer comes online (audio
26448
+ * gating, alert filtering, …) no other surface needs to change.
25986
26449
  */
25987
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
25988
- * rejected without allocation. */
25989
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
25990
- /** A legal binding / identifier name. */
25991
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
25992
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
25993
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
25994
- var RESERVED_BINDING_NAMES = new Set([
25995
- "now",
25996
- "true",
25997
- "false",
25998
- "null"
26450
+ var ZoneRuleStageEnum = zod.z.enum([
26451
+ "motion",
26452
+ "detection",
26453
+ "package"
25999
26454
  ]);
26000
26455
  /**
26001
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
26002
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
26003
- * single/double-quoted strings with a tiny escape set, identifiers, the three
26004
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
26005
- * outside that a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
26006
- * is a parse error with a source position, so member access / assignment /
26007
- * template literals are lexically impossible.
26456
+ * Zone rules capability per-camera CRUD over the {@link ZoneRule}
26457
+ * arrays that decide how each pipeline stage uses the polygon zones.
26458
+ *
26459
+ * Hosted by `addon-pipeline-orchestrator` alongside the zones provider
26460
+ * so the operator has a single hub-side source of truth for both
26461
+ * geometry and behaviour. Per-stage rules are stored under the
26462
+ * `zoneRules.<stage>` key in the orchestrator's per-device store and
26463
+ * mirrored to the device-state slice `<stage>-zone-rules` on every
26464
+ * mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
26465
+ * subscribe to that slice and refresh their gating without
26466
+ * round-tripping the cap.
26467
+ *
26468
+ * Sets are bulk-replace — the operator UI sends the new rule list
26469
+ * wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
26470
+ * naturally in the rule editor without per-rule mutation chatter.
26008
26471
  */
26009
- var KEYWORDS = new Set([
26010
- "true",
26011
- "false",
26012
- "null"
26013
- ]);
26014
- function isDigit(ch) {
26015
- return ch >= "0" && ch <= "9";
26016
- }
26017
- function isIdentStart(ch) {
26018
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
26019
- }
26020
- function isIdentPart(ch) {
26021
- return isIdentStart(ch) || isDigit(ch);
26022
- }
26023
- function isWhitespace(ch) {
26024
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
26025
- }
26026
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
26027
- * Throws `ExpressionParseError` on any illegal character or unterminated
26028
- * string. */
26029
- function tokenize(source) {
26030
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
26031
- const tokens = [];
26032
- let i = 0;
26033
- const n = source.length;
26034
- while (i < n) {
26035
- const ch = source[i];
26036
- if (isWhitespace(ch)) {
26037
- i += 1;
26038
- continue;
26039
- }
26040
- if (isDigit(ch)) {
26041
- const start = i;
26042
- while (i < n && isDigit(source[i])) i += 1;
26043
- if (i < n && source[i] === ".") {
26044
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
26045
- i += 1;
26046
- while (i < n && isDigit(source[i])) i += 1;
26047
- }
26048
- const text = source.slice(start, i);
26049
- const value = Number(text);
26050
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
26051
- tokens.push({
26052
- type: "number",
26053
- value,
26054
- pos: start
26055
- });
26056
- continue;
26057
- }
26058
- if (ch === "'" || ch === "\"") {
26059
- const quote = ch;
26060
- const start = i;
26061
- i += 1;
26062
- let out = "";
26063
- let closed = false;
26064
- while (i < n) {
26065
- const c = source[i];
26066
- if (c === "\\") {
26067
- const next = i + 1 < n ? source[i + 1] : "";
26068
- if (next === "\\" || next === "'" || next === "\"") {
26069
- out += next;
26070
- i += 2;
26071
- continue;
26072
- }
26073
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
26074
- }
26075
- if (c === quote) {
26076
- closed = true;
26077
- i += 1;
26078
- break;
26079
- }
26080
- out += c;
26081
- i += 1;
26082
- }
26083
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
26084
- tokens.push({
26085
- type: "string",
26086
- value: out,
26087
- pos: start
26088
- });
26089
- continue;
26090
- }
26091
- if (isIdentStart(ch)) {
26092
- const start = i;
26093
- while (i < n && isIdentPart(source[i])) i += 1;
26094
- const text = source.slice(start, i);
26095
- if (KEYWORDS.has(text)) tokens.push({
26096
- type: "keyword",
26097
- keyword: keywordOf(text),
26098
- pos: start
26099
- });
26100
- else tokens.push({
26101
- type: "identifier",
26102
- name: text,
26103
- pos: start
26104
- });
26105
- continue;
26106
- }
26107
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
26108
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
26109
- tokens.push({
26110
- type: "punct",
26111
- punct: two,
26112
- pos: i
26113
- });
26114
- i += 2;
26115
- continue;
26116
- }
26117
- if (isSinglePunct(ch)) {
26118
- tokens.push({
26119
- type: "punct",
26120
- punct: ch,
26121
- pos: i
26122
- });
26123
- i += 1;
26124
- continue;
26125
- }
26126
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
26127
- }
26128
- tokens.push({
26129
- type: "eof",
26130
- pos: n
26131
- });
26132
- return tokens;
26133
- }
26134
- function keywordOf(text) {
26135
- if (text === "true") return "true";
26136
- if (text === "false") return "false";
26137
- return "null";
26138
- }
26139
- function isSinglePunct(ch) {
26140
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
26141
- }
26472
+ var zoneRulesCapability = {
26473
+ name: "zone-rules",
26474
+ scope: "device",
26475
+ mode: "singleton",
26476
+ deviceTypes: [DeviceType.Camera],
26477
+ methods: {
26478
+ /** Read the full rule list for a given stage (empty when no rules
26479
+ * are defined yet). */
26480
+ listRules: method(zod.z.object({
26481
+ deviceId: zod.z.number(),
26482
+ stage: ZoneRuleStageEnum
26483
+ }), zod.z.array(ZoneRuleSchema).readonly()),
26484
+ /** Bulk-replace the rule list for one stage. The provider validates
26485
+ * each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
26486
+ * thresholds in range) and rejects the whole patch if any entry
26487
+ * is invalid partial writes are a configuration footgun. */
26488
+ setRules: method(zod.z.object({
26489
+ deviceId: zod.z.number(),
26490
+ stage: ZoneRuleStageEnum,
26491
+ rules: zod.z.array(ZoneRuleSchema).readonly()
26492
+ }), zod.z.void(), {
26493
+ kind: "mutation",
26494
+ auth: "admin"
26495
+ })
26496
+ },
26497
+ /**
26498
+ * Runtime-state slice — every stage mirrored together so consumers
26499
+ * see one reactive handle (`device.state.zoneRules.value`) instead
26500
+ * of one per stage. Bulk-replace mutations on any stage write the full
26501
+ * `{motion, detection, package}` shape, so subscribers always get the
26502
+ * complete current set. Consumers that only care about one stage
26503
+ * just read the matching property.
26504
+ *
26505
+ * `package` backs the package-drop detector a package zone is a
26506
+ * `ZoneRule` on the `'package'` stage referencing drawn polygons
26507
+ * (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
26508
+ * The orchestrator provider writes this stage as a first-class slice
26509
+ * (Phase 4): every mutation mirrors the full `{motion, detection,
26510
+ * package}` shape, so consumers read the current package rules directly
26511
+ * off `device.state.zoneRules.value.package`.
26512
+ */
26513
+ runtimeState: zod.z.object({
26514
+ motion: zod.z.array(ZoneRuleSchema).readonly(),
26515
+ detection: zod.z.array(ZoneRuleSchema).readonly(),
26516
+ package: zod.z.array(ZoneRuleSchema).readonly()
26517
+ })
26518
+ };
26142
26519
  /**
26143
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
26520
+ * Runtime defaults -- used by ConfigManager.get() for backward compatibility
26521
+ * until Plan B wires all runtime settings to the system_settings SQL table.
26144
26522
  *
26145
- * Precedence (low → high): ternary `?:` (right-assoc) `||` `&&` → equality
26146
- * relational additive multiplicative unary `! -` → call / primary.
26147
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
26148
- * string validated against the builtin table at parse time, so an unknown
26149
- * function is rejected immediately (author feedback) and a persisted expression
26150
- * that references a since-removed builtin degrades at read.
26523
+ * Moved from @camstack/system/config-schema to @camstack/types so that
26524
+ * @camstack/system can reference it without a cross-package kernel import.
26525
+ */
26526
+ var RUNTIME_DEFAULTS = {
26527
+ "features.streaming": true,
26528
+ "features.notifications": true,
26529
+ "features.objectDetection": false,
26530
+ "features.remoteAccess": true,
26531
+ "features.agentCluster": false,
26532
+ "features.smartHome": true,
26533
+ "features.recordings": true,
26534
+ "features.backup": true,
26535
+ "features.repl": true,
26536
+ "retention.detectionEventsDays": 30,
26537
+ "retention.audioLevelsDays": 7,
26538
+ "logging.level": "info",
26539
+ "logging.retentionDays": 30,
26540
+ "eventBus.ringBufferSize": 1e4,
26541
+ "storage.provider": "sqlite-storage",
26542
+ "storage.locations": {
26543
+ data: "camstack-data/data",
26544
+ media: "camstack-data/media",
26545
+ recordings: "camstack-data/recordings",
26546
+ cache: "/tmp/camstack-cache",
26547
+ logs: "camstack-data/logs",
26548
+ models: "camstack-data/models"
26549
+ },
26550
+ providers: [],
26551
+ "ffmpeg.binaryPath": "ffmpeg",
26552
+ "ffmpeg.hwAccel": "auto",
26553
+ "ffmpeg.threadCount": 0,
26554
+ "auth.tokenExpiry": "7d"
26555
+ };
26556
+ /**
26557
+ * Accessory device helpers — shared across drivers.
26151
26558
  *
26152
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
26153
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) both raise `ExpressionParseError`.
26559
+ * Many vendor-specific drivers register accessory child devices on
26560
+ * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
26561
+ * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
26562
+ * driver picks the right `DeviceType` + `DeviceRole` explicitly when
26563
+ * spawning, builds a name derived from the parent, and produces a
26564
+ * stableId tied to the parent so boot-restore can reconstruct the
26565
+ * relationship.
26566
+ *
26567
+ * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
26568
+ * drivers may reasonably disagree on the right type for an accessory
26569
+ * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
26570
+ * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
26571
+ * one canonical mapping was over-prescriptive and added a layer of
26572
+ * indirection without saving meaningful code at call sites — the
26573
+ * driver knows its own hardware best.
26154
26574
  */
26155
- /** Binary/logical operator precedence (higher binds tighter). */
26156
- var BINARY_PRECEDENCE = {
26157
- "||": 1,
26158
- "&&": 2,
26159
- "==": 3,
26160
- "!=": 3,
26161
- "<": 4,
26162
- "<=": 4,
26163
- ">": 4,
26164
- ">=": 4,
26165
- "+": 5,
26166
- "-": 5,
26167
- "*": 6,
26168
- "/": 6,
26169
- "%": 6
26575
+ /**
26576
+ * Subset of `DeviceRole` values that drivers register as child
26577
+ * accessories of a parent device. Sourced verbatim from `DeviceRole`
26578
+ * — `AccessoryKind` is the alias drivers use when building accessory
26579
+ * children, so the call site reads as
26580
+ * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
26581
+ * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
26582
+ * any role works, including non-accessory ones like Doorbell).
26583
+ */
26584
+ var AccessoryKind = {
26585
+ Siren: DeviceRole.Siren,
26586
+ Floodlight: DeviceRole.Floodlight,
26587
+ Spotlight: DeviceRole.Spotlight,
26588
+ PirSensor: DeviceRole.PirSensor,
26589
+ Chime: DeviceRole.Chime,
26590
+ Autotrack: DeviceRole.Autotrack,
26591
+ Nightvision: DeviceRole.Nightvision,
26592
+ PrivacyMask: DeviceRole.PrivacyMask
26170
26593
  };
26171
- function isLogicalOp(op) {
26172
- return op === "&&" || op === "||";
26173
- }
26174
- function isBinaryOp(op) {
26175
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
26176
- }
26177
- var Parser = class {
26178
- tokens;
26179
- pos = 0;
26180
- nodeCount = 0;
26181
- identifiers = /* @__PURE__ */ new Set();
26182
- callees = /* @__PURE__ */ new Set();
26183
- constructor(tokens) {
26184
- this.tokens = tokens;
26185
- }
26186
- parse() {
26187
- const ast = this.parseTernary();
26188
- const tok = this.peek();
26189
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
26190
- return {
26191
- ast,
26192
- identifiers: this.identifiers,
26193
- callees: this.callees,
26194
- nodeCount: this.nodeCount
26195
- };
26196
- }
26197
- peek() {
26198
- return this.tokens[this.pos];
26199
- }
26200
- next() {
26201
- return this.tokens[this.pos++];
26202
- }
26203
- /** Consume a punctuator token, erroring if the next token isn't it. */
26204
- expectPunct(punct) {
26205
- const tok = this.peek();
26206
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
26207
- this.pos += 1;
26208
- }
26209
- matchPunct(punct) {
26210
- const tok = this.peek();
26211
- if (tok.type === "punct" && tok.punct === punct) {
26212
- this.pos += 1;
26213
- return true;
26214
- }
26215
- return false;
26216
- }
26217
- countNode() {
26218
- this.nodeCount += 1;
26219
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
26220
- }
26221
- parseTernary() {
26222
- const test = this.parseBinary(1);
26223
- if (this.matchPunct("?")) {
26224
- const consequent = this.parseTernary();
26225
- this.expectPunct(":");
26226
- const alternate = this.parseTernary();
26227
- this.countNode();
26228
- return {
26229
- kind: "conditional",
26230
- test,
26231
- consequent,
26232
- alternate
26233
- };
26234
- }
26235
- return test;
26236
- }
26237
- parseBinary(minPrec) {
26238
- let left = this.parseUnary();
26239
- for (;;) {
26240
- const tok = this.peek();
26241
- if (tok.type !== "punct") break;
26242
- const prec = BINARY_PRECEDENCE[tok.punct];
26243
- if (prec === void 0 || prec < minPrec) break;
26244
- const op = tok.punct;
26245
- this.pos += 1;
26246
- const right = this.parseBinary(prec + 1);
26247
- this.countNode();
26248
- if (isLogicalOp(op)) left = {
26249
- kind: "logical",
26250
- op,
26251
- left,
26252
- right
26253
- };
26254
- else if (isBinaryOp(op)) left = {
26255
- kind: "binary",
26256
- op,
26257
- left,
26258
- right
26259
- };
26260
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
26261
- }
26262
- return left;
26263
- }
26264
- parseUnary() {
26265
- const tok = this.peek();
26266
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
26267
- const op = tok.punct;
26268
- this.pos += 1;
26269
- const operand = this.parseUnary();
26270
- this.countNode();
26271
- return {
26272
- kind: "unary",
26273
- op,
26274
- operand
26275
- };
26276
- }
26277
- return this.parsePrimary();
26278
- }
26279
- parsePrimary() {
26280
- const tok = this.next();
26281
- switch (tok.type) {
26282
- case "number":
26283
- this.countNode();
26284
- return {
26285
- kind: "literal",
26286
- value: tok.value
26287
- };
26288
- case "string":
26289
- this.countNode();
26290
- return {
26291
- kind: "literal",
26292
- value: tok.value
26293
- };
26294
- case "keyword":
26295
- this.countNode();
26296
- return {
26297
- kind: "literal",
26298
- value: tok.keyword === "null" ? null : tok.keyword === "true"
26299
- };
26300
- case "identifier": {
26301
- const nextTok = this.peek();
26302
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
26303
- this.identifiers.add(tok.name);
26304
- this.countNode();
26594
+ AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
26595
+ /** Marker written to a declared integration's `info`. */
26596
+ var DECLARED_INTEGRATION_FIXED_KEY = "fixed";
26597
+ /**
26598
+ * Strip the `<node>/<addon>` suffix a forked child carries.
26599
+ *
26600
+ * Comparing `ctx.kernel.localNodeId` raw skipped EVERY node — including the one
26601
+ * that was supposed to act — because on the hub it reads `hub/<addon>`.
26602
+ */
26603
+ function declarationOwnerNodeId(localNodeId) {
26604
+ const raw = localNodeId ?? "hub";
26605
+ if (!raw.includes("/")) return raw;
26606
+ return raw.split("/")[0] ?? "hub";
26607
+ }
26608
+ /**
26609
+ * The one way an addon owns a device it declares.
26610
+ *
26611
+ * Construct once with the addon's ports, then call {@link reconcile} on boot and
26612
+ * on every convergence tick. There is no second get-or-create helper a guard
26613
+ * in `scripts/` enforces that.
26614
+ */
26615
+ var DeclaredDevices = class {
26616
+ ports;
26617
+ constructor(ports) {
26618
+ const addonId = ports.addonId;
26619
+ if (typeof addonId !== "string" || addonId.length === 0) throw new Error(`DeclaredDevices: addonId must be the declaring addon's id, got ${JSON.stringify(addonId)}. On an addon context it is \`ctx.id\` — there is no \`ctx.addonId\`.`);
26620
+ this.ports = ports;
26621
+ }
26622
+ /**
26623
+ * Converge the declared set. Idempotent, and safe to call repeatedly.
26624
+ *
26625
+ * Throws only what the ports throw on the FIRST index read; every other
26626
+ * failure is per-device and logged, so one bad declaration never takes the
26627
+ * others down.
26628
+ */
26629
+ async reconcile(spec) {
26630
+ if ((spec.placement ?? "hub") === "hub") {
26631
+ const nodeId = declarationOwnerNodeId(this.ports.localNodeId);
26632
+ if (nodeId !== "hub") {
26633
+ this.ports.logger.info("declared devices are hub-owned — skipping on this node", { meta: {
26634
+ nodeId,
26635
+ rawNodeId: this.ports.localNodeId ?? null
26636
+ } });
26305
26637
  return {
26306
- kind: "identifier",
26307
- name: tok.name
26638
+ integrationId: null,
26639
+ devices: [],
26640
+ removed: [],
26641
+ owned: false
26308
26642
  };
26309
26643
  }
26310
- case "punct":
26311
- if (tok.punct === "(") {
26312
- const inner = this.parseTernary();
26313
- this.expectPunct(")");
26314
- return inner;
26315
- }
26316
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
26317
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
26318
26644
  }
26319
- }
26320
- parseCall(callee, pos) {
26321
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
26322
- this.expectPunct("(");
26323
- const args = [];
26324
- if (!this.matchPunct(")")) for (;;) {
26325
- args.push(this.parseTernary());
26326
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
26327
- if (this.matchPunct(",")) continue;
26328
- this.expectPunct(")");
26329
- break;
26645
+ const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
26646
+ const index = await this.readIndex();
26647
+ const outcomes = [];
26648
+ for (const declaration of spec.devices) {
26649
+ const outcome = await this.applyDeclaration(declaration, integrationId, index);
26650
+ if (outcome !== null) outcomes.push(outcome);
26330
26651
  }
26331
- this.callees.add(callee);
26332
- this.countNode();
26333
26652
  return {
26334
- kind: "call",
26335
- callee,
26336
- args
26653
+ integrationId,
26654
+ devices: outcomes,
26655
+ removed: await this.sweepWithdrawn(spec.devices, integrationId, index),
26656
+ owned: true
26337
26657
  };
26338
26658
  }
26339
- };
26340
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
26341
- * `ExpressionParseError` on any lexical or grammatical failure. */
26342
- function parseExpression(source) {
26343
- return new Parser(tokenize(source)).parse();
26344
- }
26345
- /**
26346
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
26347
- * by expr"). The cache stores BOTH successes and failures (negative caching),
26348
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
26349
- * one per read on a hot resolve path.
26350
- *
26351
- * The cache is a module-level singleton: entries are pure, content-addressed
26352
- * ASTs keyed by the raw source string, so sharing one instance across all
26353
- * callers is safe and maximises hit rate.
26354
- */
26355
- var cache = /* @__PURE__ */ new Map();
26356
- function getCached(source) {
26357
- const hit = cache.get(source);
26358
- if (hit !== void 0) {
26359
- cache.delete(source);
26360
- cache.set(source, hit);
26361
- return hit;
26659
+ /**
26660
+ * Get-or-create the FIXED integration, and RE-ASSERT the flag every pass.
26661
+ *
26662
+ * The re-assertion is the fix for the defect the hand-rolled version shipped
26663
+ * with: writing `info.fixed` only on the create path left every pre-existing
26664
+ * install without it, and the kernel kept offering to delete an integration
26665
+ * the addon owns.
26666
+ */
26667
+ async ensureIntegration(integrationName) {
26668
+ const existing = await this.ports.getIntegration(this.ports.addonId);
26669
+ if (existing === null) {
26670
+ const created = await this.ports.createIntegration({
26671
+ addonId: this.ports.addonId,
26672
+ name: integrationName,
26673
+ info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
26674
+ });
26675
+ this.ports.logger.info("declared a fixed integration", { meta: {
26676
+ integrationId: created.id,
26677
+ name: integrationName
26678
+ } });
26679
+ return created.id;
26680
+ }
26681
+ if (existing.info?.["fixed"] !== true) {
26682
+ await this.ports.updateIntegration({
26683
+ id: existing.id,
26684
+ info: { [DECLARED_INTEGRATION_FIXED_KEY]: true }
26685
+ });
26686
+ this.ports.logger.info("re-asserted `fixed` on a declared integration", { meta: { integrationId: existing.id } });
26687
+ }
26688
+ return existing.id;
26362
26689
  }
26363
- let result;
26364
- try {
26365
- result = {
26366
- ok: true,
26367
- parsed: parseExpression(source)
26368
- };
26369
- } catch (err) {
26370
- result = {
26371
- ok: false,
26372
- error: err instanceof ExpressionParseError ? err.message : String(err)
26373
- };
26690
+ async readIndex() {
26691
+ const rows = await this.ports.listOwnDevices();
26692
+ return new Map(rows.map((row) => [row.stableId, row]));
26374
26693
  }
26375
- cache.set(source, result);
26376
- if (cache.size > 256) {
26377
- const oldest = cache.keys().next().value;
26378
- if (oldest !== void 0) cache.delete(oldest);
26694
+ /**
26695
+ * One declaration: adopt what exists, create what does not.
26696
+ *
26697
+ * The create branch is the destructive one — it seeds `initialMeta`, and
26698
+ * `initialMeta.name` lands as an unconditional `setName`. A transiently empty
26699
+ * index therefore looks exactly like a first boot and would silently re-stamp
26700
+ * the declared name over the operator's rename. D49: that branch needs a
26701
+ * second read to agree.
26702
+ */
26703
+ async applyDeclaration(declaration, integrationId, index) {
26704
+ try {
26705
+ let existing = index.get(declaration.stableId);
26706
+ if (existing === void 0) {
26707
+ existing = (await this.readIndex()).get(declaration.stableId);
26708
+ if (existing !== void 0) this.ports.logger.warn("device index disagreed with itself — adopting instead of re-creating", {
26709
+ tags: { deviceId: existing.id },
26710
+ meta: {
26711
+ stableId: declaration.stableId,
26712
+ addonId: this.ports.addonId
26713
+ }
26714
+ });
26715
+ }
26716
+ if (existing !== void 0) {
26717
+ const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, {}, null, void 0);
26718
+ this.ports.logger.info("declared device adopted", {
26719
+ tags: { deviceId: device.id },
26720
+ meta: {
26721
+ stableId: declaration.stableId,
26722
+ integrationId
26723
+ }
26724
+ });
26725
+ return {
26726
+ stableId: declaration.stableId,
26727
+ deviceId: device.id,
26728
+ device,
26729
+ created: false
26730
+ };
26731
+ }
26732
+ const device = await this.ports.devices.create(declaration.stableId, declaration.DeviceClass, declaration.config ?? {}, null, {
26733
+ type: declaration.type,
26734
+ name: declaration.name,
26735
+ integrationId,
26736
+ ...declaration.role === void 0 ? {} : { role: declaration.role }
26737
+ });
26738
+ this.ports.logger.info("declared device created", {
26739
+ tags: { deviceId: device.id },
26740
+ meta: {
26741
+ stableId: declaration.stableId,
26742
+ integrationId
26743
+ }
26744
+ });
26745
+ return {
26746
+ stableId: declaration.stableId,
26747
+ deviceId: device.id,
26748
+ device,
26749
+ created: true
26750
+ };
26751
+ } catch (err) {
26752
+ this.ports.logger.warn("a declared device could not be brought up", { meta: {
26753
+ stableId: declaration.stableId,
26754
+ error: err instanceof Error ? err.message : String(err)
26755
+ } });
26756
+ return null;
26757
+ }
26379
26758
  }
26380
- return result;
26759
+ /**
26760
+ * Remove rows under the addon's FIXED integration whose declaration is gone.
26761
+ *
26762
+ * Bounded to that integration: a declared integration has no operator
26763
+ * add-flow, so every row under it got there by declaration. Devices this
26764
+ * addon owns OUTSIDE it (a provider's adopted devices) are never candidates.
26765
+ *
26766
+ * Bounded in count, and every deletion is logged with its `deviceId` — a
26767
+ * withdrawal that removes an operator-visible row silently is the failure
26768
+ * mode, not the removal itself.
26769
+ */
26770
+ async sweepWithdrawn(declarations, integrationId, index) {
26771
+ const declared = new Set(declarations.map((d) => d.stableId));
26772
+ const candidates = [...index.values()].filter((row) => row.integrationId === integrationId && !declared.has(row.stableId));
26773
+ if (candidates.length === 0) return [];
26774
+ if (candidates.length > 32) {
26775
+ this.ports.logger.warn("withdrawal sweep exceeded its bound — removing nothing", { meta: {
26776
+ integrationId,
26777
+ candidates: candidates.length,
26778
+ bound: 32
26779
+ } });
26780
+ return [];
26781
+ }
26782
+ const removed = [];
26783
+ for (const row of candidates) try {
26784
+ await this.ports.devices.remove(row.id);
26785
+ removed.push(row.id);
26786
+ this.ports.logger.info("declared device removed — its declaration was withdrawn", {
26787
+ tags: { deviceId: row.id },
26788
+ meta: {
26789
+ stableId: row.stableId,
26790
+ integrationId
26791
+ }
26792
+ });
26793
+ } catch (err) {
26794
+ this.ports.logger.warn("a withdrawn declared device could not be removed", {
26795
+ tags: { deviceId: row.id },
26796
+ meta: {
26797
+ stableId: row.stableId,
26798
+ error: err instanceof Error ? err.message : String(err)
26799
+ }
26800
+ });
26801
+ }
26802
+ return removed;
26803
+ }
26804
+ };
26805
+ 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;
26806
+ new Set(Object.values(DeviceType));
26807
+ DeviceFeature.BatteryOperated;
26808
+ /** Unwrap ZodNullable / ZodOptional / ZodDefault wrappers to reach the inner type.
26809
+ * Zod v4 exposes `.unwrap()` on all three wrapper classes. */
26810
+ function unwrap(schema) {
26811
+ let s = schema;
26812
+ while (s instanceof zod.z.ZodNullable || s instanceof zod.z.ZodOptional || s instanceof zod.z.ZodDefault) s = s.unwrap();
26813
+ return s;
26381
26814
  }
26382
- /** Compile `source`, returning a discriminated result instead of throwing.
26383
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
26384
- function compileExpressionSafe(source) {
26385
- return getCached(source);
26815
+ function leafKind(schema) {
26816
+ const s = unwrap(schema);
26817
+ if (s instanceof zod.z.ZodEnum) return {
26818
+ kind: "enum",
26819
+ enumValues: s.options
26820
+ };
26821
+ if (s instanceof zod.z.ZodNumber) return { kind: "number" };
26822
+ if (s instanceof zod.z.ZodString) return { kind: "string" };
26823
+ if (s instanceof zod.z.ZodBoolean) return { kind: "boolean" };
26824
+ return null;
26386
26825
  }
26387
- Object.freeze({});
26388
- /**
26389
- * Author-time validation. Returns `null` when the source is valid, else a
26390
- * human-readable error message. Checks: the expression compiles; binding count
26391
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
26392
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
26393
- * FREE identifier of the AST is covered by a binding or the injected `now`.
26394
- */
26395
- function validateExpressionSource(src) {
26396
- const names = Object.keys(src.bindings);
26397
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
26398
- for (const name of names) {
26399
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
26400
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
26401
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
26402
- }
26403
- const compiled = compileExpressionSafe(src.expr);
26404
- if (!compiled.ok) return compiled.error;
26405
- const bound = new Set(names);
26406
- for (const id of compiled.parsed.identifiers) {
26407
- if (id === "now") continue;
26408
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
26826
+ /** Walk a cap status schema into flat wireable leaf fields (dotted paths).
26827
+ * Recurses into nested ZodObject (unwrapping nullable/optional/default first),
26828
+ * so fields with null live values are still offered. Arrays / unknown shapes
26829
+ * are skipped never throws. */
26830
+ function enumerateSchemaFields(schema, prefix = "") {
26831
+ const root = unwrap(schema);
26832
+ if (!(root instanceof zod.z.ZodObject)) return [];
26833
+ const out = [];
26834
+ const shape = root.shape;
26835
+ for (const [key, field] of Object.entries(shape)) {
26836
+ const path = prefix ? `${prefix}.${key}` : key;
26837
+ const inner = unwrap(field);
26838
+ if (inner instanceof zod.z.ZodObject) {
26839
+ out.push(...enumerateSchemaFields(inner, path));
26840
+ continue;
26841
+ }
26842
+ const leaf = leafKind(field);
26843
+ if (leaf) out.push({
26844
+ path,
26845
+ kind: leaf.kind,
26846
+ ...leaf.enumValues ? { enumValues: leaf.enumValues } : {}
26847
+ });
26409
26848
  }
26410
- return null;
26849
+ return out;
26850
+ }
26851
+ /** Enumerate the per-item wireable fields of an item-array cap (see
26852
+ * `CapabilityStatusItemArray`): the item schema's leaf fields, minus the
26853
+ * `keyField` (the key comes from the link's `itemKey`, never from a wired
26854
+ * source), each tagged `item: true` so the authoring UI collects an
26855
+ * `itemKey` alongside the field. Never throws. */
26856
+ function enumerateItemArrayFields(itemArray) {
26857
+ return enumerateSchemaFields(itemArray.itemSchema).filter((f) => f.path !== itemArray.keyField).map((f) => ({
26858
+ ...f,
26859
+ item: true
26860
+ }));
26411
26861
  }
26412
- /**
26413
- * What an expression's named bindings READ from.
26414
- *
26415
- * Salvaged verbatim from the deleted device-link mechanism. Wiring's source
26416
- * kinds were the one part of it worth keeping: addressing a device field by
26417
- * re-sync-stable `stableId`, a per-device constant, and a sibling-accessory
26418
- * read are the vocabulary any cross-device derivation needs, and they were
26419
- * already correct. What wiring got wrong was the DESTINATION — a field on
26420
- * somebody else's device, with no identity — not the source.
26421
- *
26422
- * These shapes are therefore kept, re-homed next to the engine that consumes
26423
- * them, and are the binding type of a composition recipe (the source picker
26424
- * stays `deviceManager.getWireableFields`). They deliberately do NOT nest: a
26425
- * binding is a read, never another expression.
26426
- *
26427
- * Schemas are authoritative; every type is `z.infer` of one, so a wire shape and
26428
- * a TypeScript shape cannot drift apart (`scripts/check-schema-type-twins.ts`).
26429
- */
26430
- /** Read a sibling accessory's status field, addressed by the sibling's key.
26431
- * `kind` is optional for wire compatibility — absent means `'field'`. */
26432
- var ExpressionFieldBindingSchema = zod.z.object({
26433
- kind: zod.z.literal("field").optional(),
26434
- sourceKey: zod.z.string(),
26435
- cap: zod.z.string(),
26436
- fieldPath: zod.z.string()
26437
- });
26438
- /** A constant. No device is read. */
26439
- var ExpressionLiteralBindingSchema = zod.z.object({
26440
- kind: zod.z.literal("literal"),
26441
- value: zod.z.union([
26442
- zod.z.string(),
26443
- zod.z.number(),
26444
- zod.z.boolean(),
26445
- zod.z.null()
26446
- ])
26447
- });
26448
- /** Read ANY device's status field, addressed by its re-sync-stable `stableId` —
26449
- * never by numeric id, which a re-adoption reissues. */
26450
- var ExpressionGlobalBindingSchema = zod.z.object({
26451
- kind: zod.z.literal("global"),
26452
- sourceStableId: zod.z.string(),
26453
- cap: zod.z.string(),
26454
- fieldPath: zod.z.string()
26455
- });
26456
- var ExpressionBindingSourceSchema = zod.z.union([
26457
- ExpressionFieldBindingSchema,
26458
- ExpressionLiteralBindingSchema,
26459
- ExpressionGlobalBindingSchema
26460
- ]);
26461
- zod.z.object({
26462
- expr: zod.z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
26463
- bindings: zod.z.record(zod.z.string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
26464
- }).superRefine((src, ctx) => {
26465
- const err = validateExpressionSource(src);
26466
- if (err !== null) ctx.addIssue({
26467
- code: "custom",
26468
- message: err,
26469
- path: ["expr"]
26470
- });
26471
- });
26472
26862
  /**
26473
26863
  * Runtime list of cap names with status. Used by the settings
26474
26864
  * aggregator to enumerate caps whose `status` should be polled +
@@ -32513,6 +32903,12 @@ Object.defineProperty(exports, "CAP_NAMES_WITH_STATUS", {
32513
32903
  return CAP_NAMES_WITH_STATUS;
32514
32904
  }
32515
32905
  });
32906
+ Object.defineProperty(exports, "CORE_BLOCKS_ADDON_ID", {
32907
+ enumerable: true,
32908
+ get: function() {
32909
+ return CORE_BLOCKS_ADDON_ID;
32910
+ }
32911
+ });
32516
32912
  Object.defineProperty(exports, "CORE_BLOCK_ADDON_PREFIX", {
32517
32913
  enumerable: true,
32518
32914
  get: function() {
@@ -32543,6 +32939,12 @@ Object.defineProperty(exports, "DEVICE_STATUS_METHOD", {
32543
32939
  return DEVICE_STATUS_METHOD;
32544
32940
  }
32545
32941
  });
32942
+ Object.defineProperty(exports, "DeclaredDevices", {
32943
+ enumerable: true,
32944
+ get: function() {
32945
+ return DeclaredDevices;
32946
+ }
32947
+ });
32546
32948
  Object.defineProperty(exports, "DeviceFeature", {
32547
32949
  enumerable: true,
32548
32950
  get: function() {