@camstack/system 1.2.65 → 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,1526 +4050,2326 @@ var ConvertResultSchema = z.object({
4050
4050
  artifacts: 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 = z.object({
4069
- id: z.string(),
4070
- label: z.string(),
4071
- icon: z.string(),
4072
- path: z.string(),
4073
- remoteName: z.string(),
4074
- bundle: z.string(),
4075
- section: z.string().optional(),
4076
- sectionLabel: z.string().optional()
4077
- });
4078
- var AddonPageInfoSchema = z.object({
4079
- addonId: z.string(),
4080
- page: AddonPageDeclarationSchema$1,
4081
- bundleUrl: z.string()
4082
- });
4083
- var addonPagesCapability = {
4084
- name: "addon-pages",
4085
- scope: "system",
4086
- mode: "singleton",
4087
- methods: { listPages: method(z.void(), 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 = z.object({
4102
- id: z.string(),
4103
- label: z.string(),
4104
- icon: z.string(),
4105
- path: 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: 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: 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: z.string().optional(),
4131
- /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
4132
- sectionLabel: 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(z.void(), 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 = z.object({
4142
- method: z.enum([
4143
- "GET",
4144
- "POST",
4145
- "PUT",
4146
- "DELETE",
4147
- "PATCH"
4148
- ]),
4149
- path: z.string(),
4150
- access: z.enum([
4151
- "public",
4152
- "authenticated",
4153
- "admin"
4154
- ]).optional(),
4155
- description: 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 = z.object({
4166
- method: z.string(),
4167
- path: z.string(),
4168
- params: z.record(z.string(), z.string()),
4169
- query: z.record(z.string(), z.string()),
4170
- body: z.unknown(),
4171
- headers: z.record(z.string(), z.string()),
4172
- user: z.object({
4173
- id: z.string(),
4174
- username: z.string(),
4175
- isAdmin: z.boolean()
4176
- }).optional(),
4177
- scopedToken: z.unknown().optional()
4178
- });
4179
- var InvokeReplyEnvelopeSchema = z.object({
4180
- status: z.number().int(),
4181
- headers: z.record(z.string(), 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: z.string().nullable(),
4186
- /** JSON-serializable body. `undefined` is treated as "no body". */
4187
- body: z.unknown().optional(),
4188
- /** Set when the handler called `reply.type(mime)`. */
4189
- contentType: 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(z.void(), 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 = z.object({
4212
- id: z.string(),
4213
- label: z.string(),
4214
- icon: z.string(),
4215
- order: z.number().optional()
4216
- });
4217
- var ConfigSectionWithValuesSchema = z.object({
4218
- id: z.string(),
4219
- title: z.string(),
4220
- description: z.string().optional(),
4221
- style: z.enum(["card", "accordion"]).optional(),
4222
- defaultCollapsed: z.boolean().optional(),
4223
- columns: z.union([
4224
- z.literal(1),
4225
- z.literal(2),
4226
- z.literal(3),
4227
- z.literal(4)
4228
- ]).optional(),
4229
- tab: z.string().optional(),
4230
- location: z.enum(["settings", "top-tab"]).optional(),
4231
- order: z.number().optional(),
4232
- fields: z.array(z.any())
4233
- });
4234
- var SettingsSchemaWithValuesSchema = z.object({
4235
- tabs: z.array(ConfigTabDeclarationSchema).optional(),
4236
- sections: z.array(ConfigSectionWithValuesSchema)
4237
- });
4238
- /** Patch object — keys are field names, values are the new field values. */
4239
- var SettingsPatchSchema = z.record(z.string(), z.unknown());
4240
- /** Standard success response for update operations. */
4241
- var SettingsUpdateResultSchema = z.object({ success: 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(z.object({
4275
- addonId: z.string(),
4276
- nodeId: z.string().optional(),
4277
- overlay: z.record(z.string(), z.unknown()).optional(),
4278
- cap: z.string().optional()
4279
- }), SettingsSchemaWithValuesSchema.nullable()),
4280
- updateGlobalSettings: method(z.object({
4281
- addonId: z.string(),
4282
- nodeId: z.string().optional(),
4283
- patch: SettingsPatchSchema
4284
- }), SettingsUpdateResultSchema, {
4285
- kind: "mutation",
4286
- auth: "admin"
4287
- }),
4288
- getDeviceSettings: method(z.object({
4289
- addonId: z.string(),
4290
- deviceId: z.number(),
4291
- nodeId: z.string().optional()
4292
- }), SettingsSchemaWithValuesSchema.nullable()),
4293
- updateDeviceSettings: method(z.object({
4294
- addonId: z.string(),
4295
- deviceId: z.number(),
4296
- nodeId: 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 = z.enum([
4326
- "device-tab",
4327
- "dashboard",
4328
- "integration-detail"
4329
- ]);
4330
- var WidgetSizeEnum = 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 = z.object({
4345
- remoteName: z.string(),
4346
- exposedModule: z.string(),
4347
- componentKey: 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 = z.object({
4379
- /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
4380
- tab: z.string(),
4381
- /** Optional sub-tab within `tab`. */
4382
- subTab: z.string().optional(),
4383
- /** Operator-facing label. */
4384
- label: z.string(),
4385
- /** Ordering within `(tab, subTab)`, ascending. */
4386
- order: z.number().optional(),
4387
- /** Always `'remote'` a widget is a Module Federation remote. */
4388
- kind: z.literal("remote"),
4389
- /** MF remote descriptor. */
4390
- remote: WidgetRemoteSchema,
4391
- /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
4392
- stableId: z.string(),
4393
- description: z.string().optional(),
4394
- icon: 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: z.string(),
4403
- /** Every host the widget supports. The picker filters on this set. */
4404
- hosts: z.array(WidgetHostEnum).readonly(),
4405
- /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
4406
- requires: z.object({
4407
- deviceContext: z.boolean().default(false),
4408
- integrationContext: z.boolean().default(false)
4409
- }),
4410
- /**
4411
- * Loadable BEFORE authentication. The normal widget registry listing
4412
- * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
4413
- * (the login page) cannot discover a widget through it. A widget that
4414
- * declares `preAuth: true` marks itself as safe to mount on a pre-auth
4415
- * screen it is surfaced through the PUBLIC `auth.listLoginMethods`
4416
- * login-method contribution channel (see `login-method.cap.ts`) rather
4417
- * than the authenticated registry, and its bundle is served by the
4418
- * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
4419
- */
4420
- preAuth: z.boolean().optional().default(false),
4421
- /** Dashboard placement HINTS (operator can override per instance). */
4422
- defaultSize: WidgetSizeEnum.default("md"),
4423
- allowedSizes: z.array(WidgetSizeEnum).readonly().default([
4424
- "sm",
4425
- "md",
4426
- "lg"
4427
- ]),
4428
- defaultColumns: z.number().int().min(1).max(12).default(6),
4429
- defaultRows: z.number().int().min(1).max(12).default(1)
4430
- });
4431
- var addonWidgetsSourceCapability = {
4432
- name: "addon-widgets-source",
4433
- scope: "system",
4434
- mode: "collection",
4435
- internal: true,
4436
- methods: { listWidgets: method(z.void(), z.array(WidgetMetadataSchema).readonly()) }
4437
- };
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
+ }
4438
4377
  /**
4439
- * `addon-widgets` system-scoped singleton aggregator cap. Public-facing
4440
- * surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
4378
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
4441
4379
  *
4442
- * The provider iterates every `addon-widgets-source` (collection)
4443
- * provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
4444
- * `bundleUrl` strings pointing at
4445
- * `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
4446
- * `mtime` cache-buster lets the browser pick up addon rebuilds without
4447
- * manual reload same scheme used by `addon-pages`.
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.
4448
4386
  *
4449
- * The hub-local builtin `addon-widgets-aggregator` (see
4450
- * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
4451
- * provider. Splitting the public aggregator from the raw collection
4452
- * keeps both ends in codegen — there's no hand-written wrapper.
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`.
4453
4389
  */
4454
- var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
4455
- addonId: z.string(),
4456
- bundleUrl: z.string()
4457
- });
4458
- var addonWidgetsCapability = {
4459
- name: "addon-widgets",
4460
- scope: "system",
4461
- mode: "singleton",
4462
- methods: { listWidgets: method(z.void(), z.array(EnrichedWidgetMetadataSchema).readonly()) }
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
4463
4405
  };
4464
- /**
4465
- * Alerts capability collection-based internal alert system.
4466
- *
4467
- * Multiple providers can register. Each provider filters by EventBus category
4468
- * and creates/updates alerts. The built-in Alert Center addon persists alerts
4469
- * in the DB and serves them to the admin UI.
4470
- */
4471
- var AlertSeveritySchema = z.enum([
4472
- "info",
4473
- "success",
4474
- "warning",
4475
- "error"
4476
- ]);
4477
- var AlertStatusSchema = z.enum([
4478
- "active",
4479
- "in-progress",
4480
- "completed",
4481
- "failed",
4482
- "dismissed"
4483
- ]);
4484
- var AlertSourceSchema = z.object({
4485
- type: z.string(),
4486
- id: z.string()
4487
- });
4488
- var AlertSchema = z.object({
4489
- id: z.string(),
4490
- category: z.string(),
4491
- severity: AlertSeveritySchema,
4492
- title: z.string(),
4493
- message: z.string(),
4494
- status: AlertStatusSchema,
4495
- progress: z.number().optional(),
4496
- read: z.boolean(),
4497
- createdAt: z.number(),
4498
- updatedAt: z.number(),
4499
- source: AlertSourceSchema.optional(),
4500
- metadata: z.record(z.string(), z.unknown()).optional()
4501
- });
4502
- var alertsCapability = {
4503
- name: "alerts",
4504
- scope: "system",
4505
- mode: "singleton",
4506
- methods: {
4507
- emit: method(AlertSchema, z.void(), { kind: "mutation" }),
4508
- update: method(z.object({
4509
- alertId: z.string(),
4510
- patch: AlertSchema.partial()
4511
- }), z.void(), { kind: "mutation" }),
4512
- list: method(z.object({
4513
- unreadOnly: z.boolean().optional(),
4514
- limit: z.number().optional()
4515
- }).optional(), z.array(AlertSchema).readonly()),
4516
- getUnreadCount: method(z.void(), z.number()),
4517
- markRead: method(z.object({ alertId: z.string() }), z.void(), { kind: "mutation" }),
4518
- markAllRead: method(z.void(), z.void(), { kind: "mutation" }),
4519
- dismiss: method(z.object({ alertId: z.string() }), z.void(), { kind: "mutation" })
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
+ };
4520
4573
  }
4521
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
+ }
4522
4580
  /**
4523
- * audio-analysis device-scoped facade over the system `audio-analyzer`.
4524
- *
4525
- * Pairs with `audio-analyzer` (system, singleton) the way `camera-streams`
4526
- * pairs with `stream-broker`: the system cap owns the compute path
4527
- * (`processChunk`, engine lifecycle) while `audio-analysis` exposes the
4528
- * per-device surface — settings resolution, device-settings contribution,
4529
- * audio-level events — and is the binding row every camera sees in the
4530
- * device-manager bindings UI.
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.
4531
4585
  *
4532
- * The cap definition itself declares `kind: 'wrapper'` and `defaultActive: true`,
4533
- * so every camera picks up audio analysis automatically without any runtime flag
4534
- * in the addon's registration. Operators can disable per-device via
4535
- * `device-manager.setWrapperActive` when they don't want a given camera to be
4536
- * analysed (e.g. muted / motion-only cams).
4537
- */
4538
- var audioAnalysisCapability = {
4539
- name: "audio-analysis",
4540
- scope: "device",
4541
- mode: "singleton",
4542
- kind: "wrapper",
4543
- defaultActive: true,
4544
- deviceTypes: [DeviceType.Camera],
4545
- exposesDeviceSettings: true,
4546
- methods: {
4547
- /**
4548
- * Resolve per-device audio analysis settings (minConfidence,
4549
- * allowedClasses) from the addon settings store for a given camera.
4550
- * Orchestrator callers use this before handing chunks to
4551
- * `audio-analyzer.processChunk`.
4552
- */
4553
- resolveDeviceSettings: method(z.object({ deviceId: z.number() }), z.custom()) },
4554
- events: { onAudioLevel: event(z.object({
4555
- deviceId: z.number(),
4556
- rms: z.number(),
4557
- dbfs: z.number()
4558
- })) }
4559
- };
4560
- /** Shared Zod schemas used across detection capabilities. */
4561
- /**
4562
- * Canonical frame-format enum mirrored on `FrameFormat` in
4563
- * `packages/types/src/types/io.ts`. Kept inline (vs imported) so the
4564
- * Zod runtime schema and TypeScript type stay in sync at the call site
4565
- * — adding a new format requires changing both this enum and the
4566
- * `FrameFormat` type alias together.
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.
4567
4589
  */
4568
- var FrameFormatSchema = z.enum([
4569
- "jpeg",
4570
- "rgb",
4571
- "bgr",
4572
- "yuv420",
4573
- "gray"
4574
- ]);
4575
- var FrameInputSchema = z.object({
4576
- data: z.custom(),
4577
- format: FrameFormatSchema,
4578
- width: z.number(),
4579
- height: z.number(),
4580
- timestamp: z.number()
4581
- });
4582
- var BoundingBoxSchema = z.object({
4583
- x: z.number(),
4584
- y: z.number(),
4585
- w: z.number(),
4586
- h: z.number()
4587
- });
4588
- z.object({
4589
- class: z.string(),
4590
- originalClass: z.string(),
4591
- score: z.number(),
4592
- bbox: BoundingBoxSchema
4593
- });
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({});
4594
4623
  /**
4595
- * `data` carries the raw f32le bytes of the PCM samples (4 bytes per
4596
- * sample, little-endian IEEE 754). `Uint8Array` is the wire-safe choice
4597
- * because `@msgpack/msgpack` serialises it as a MsgPack `bin` type that
4598
- * round-trips losslessly over the UDS transport. `Float32Array` is NOT
4599
- * preserved the encoder serialises it as `bin` (its raw bytes) but
4600
- * the decoder returns `Uint8Array`, so treating a `Float32Array` as the
4601
- * wire type causes receivers to read bytes as sample values, producing
4602
- * wildly wrong RMS/dBFS results (~+43 dBFS instead of ≤0).
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.
4603
4649
  *
4604
- * Callers that need float arithmetic reconstruct the view with:
4605
- * `new Float32Array(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength / 4)`
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`).
4606
4664
  */
4607
- var AudioChunkInputSchema = z.object({
4608
- data: z.instanceof(Uint8Array),
4609
- sampleRate: z.number(),
4610
- channels: z.number(),
4611
- timestamp: z.number(),
4612
- /** Originating device id — used by the classifier for per-camera concurrency tracking. */
4613
- deviceId: z.number().optional()
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 = z.object({
4668
+ kind: z.literal("field").optional(),
4669
+ sourceKey: z.string(),
4670
+ cap: z.string(),
4671
+ fieldPath: z.string()
4614
4672
  });
4615
- var AudioLevelSchema = z.object({
4616
- rms: z.number(),
4617
- dbfs: z.number()
4673
+ /** A constant. No device is read. */
4674
+ var ExpressionLiteralBindingSchema = z.object({
4675
+ kind: z.literal("literal"),
4676
+ value: z.union([
4677
+ z.string(),
4678
+ z.number(),
4679
+ z.boolean(),
4680
+ z.null()
4681
+ ])
4618
4682
  });
4619
- var AudioClassificationLabelSchema = z.object({
4620
- /**
4621
- * Primary display class. Depending on how the label was produced
4622
- * this is either the macro category (e.g. `dog`) or the raw
4623
- * backend label (e.g. `Dog bark`). Mirrors `class` on
4624
- * `SpatialDetection`.
4625
- */
4626
- className: z.string(),
4627
- /**
4628
- * Raw backend-native label the classifier actually emitted (e.g.
4629
- * `Dog bark` for YAMNet, `dog_bark` for Apple SoundAnalysis). For
4630
- * macro-aggregated entries this is the top raw contributor. Mirrors
4631
- * `originalClass` on `SpatialDetection`.
4632
- */
4633
- originalClass: z.string().optional(),
4634
- score: z.number()
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 = z.object({
4686
+ kind: z.literal("global"),
4687
+ sourceStableId: z.string(),
4688
+ cap: z.string(),
4689
+ fieldPath: z.string()
4635
4690
  });
4636
- var AudioAnalysisResultSchema = z.object({
4637
- level: AudioLevelSchema,
4638
- classification: z.object({
4639
- labels: z.array(AudioClassificationLabelSchema).readonly(),
4640
- inferenceMs: z.number()
4641
- }).optional(),
4642
- timestamp: z.number()
4691
+ var ExpressionBindingSourceSchema = z.union([
4692
+ ExpressionFieldBindingSchema,
4693
+ ExpressionLiteralBindingSchema,
4694
+ ExpressionGlobalBindingSchema
4695
+ ]);
4696
+ z.object({
4697
+ expr: z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
4698
+ bindings: z.record(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
+ });
4643
4706
  });
4644
- var AudioAnalysisSettingsSchema = z.object({
4645
- minConfidence: z.number().min(0).max(1).default(.3),
4646
- allowedClasses: z.array(z.string()).default([])
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 = z.enum([
4710
+ "eq",
4711
+ "ne",
4712
+ "gt",
4713
+ "gte",
4714
+ "lt",
4715
+ "lte",
4716
+ "contains",
4717
+ "in"
4718
+ ]);
4719
+ var AutomationConditionLeafSchema = z.object({
4720
+ kind: z.literal("condition"),
4721
+ deviceId: z.number().int().nonnegative(),
4722
+ cap: z.string().min(1),
4723
+ fieldPath: z.string().min(1),
4724
+ operator: AutomationConditionOperatorSchema,
4725
+ value: z.union([
4726
+ z.string(),
4727
+ z.number(),
4728
+ z.boolean(),
4729
+ z.array(z.union([z.string(), z.number()]))
4730
+ ])
4647
4731
  });
4648
- var AudioClassificationResultSchema = z.object({
4649
- labels: z.array(AudioClassificationLabelSchema).readonly(),
4650
- rawLabels: z.array(AudioClassificationLabelSchema).readonly().optional(),
4651
- inferenceMs: z.number()
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 = z.object({
4740
+ kind: z.literal("expression"),
4741
+ expr: z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
4742
+ bindings: z.record(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
+ });
4652
4750
  });
4653
- var audioAnalyzerCapability = {
4654
- name: "audio-analyzer",
4751
+ var AutomationConditionSchema = z.lazy(() => z.discriminatedUnion("kind", [
4752
+ z.object({
4753
+ kind: z.literal("all"),
4754
+ children: z.array(AutomationConditionSchema)
4755
+ }),
4756
+ z.object({
4757
+ kind: z.literal("any"),
4758
+ children: z.array(AutomationConditionSchema)
4759
+ }),
4760
+ z.object({
4761
+ kind: 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 = z.discriminatedUnion("kind", [
4778
+ z.object({
4779
+ kind: z.literal("device-state"),
4780
+ deviceId: z.number().int().nonnegative(),
4781
+ cap: z.string().min(1),
4782
+ fieldPath: z.string().min(1),
4783
+ /** Fire when the field takes this value. Omit to fire on any change. */
4784
+ becomes: z.union([
4785
+ z.string(),
4786
+ z.number(),
4787
+ z.boolean()
4788
+ ]).optional(),
4789
+ /** Only on a CHANGE of value, not on every re-report. */
4790
+ edge: z.boolean().optional(),
4791
+ /** The condition must hold this long before the run starts. */
4792
+ forMs: z.number().int().min(0).max(864e5).optional(),
4793
+ /** Collapse a burst into one run. */
4794
+ debounceMs: z.number().int().min(0).max(6e5).optional()
4795
+ }),
4796
+ z.object({
4797
+ kind: z.literal("device-event"),
4798
+ /** An `EventCategory` value. */
4799
+ category: z.string().min(1),
4800
+ deviceId: z.number().int().nonnegative().optional()
4801
+ }),
4802
+ z.object({
4803
+ kind: z.literal("schedule"),
4804
+ cron: z.string().min(1).max(120)
4805
+ }),
4806
+ z.object({ kind: 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 = z.discriminatedUnion("kind", [
4827
+ z.object({
4828
+ kind: z.literal("wait"),
4829
+ seconds: z.number().min(0).max(300)
4830
+ }),
4831
+ z.object({
4832
+ kind: z.literal("cap"),
4833
+ deviceId: z.union([z.number().int(), z.object({ $var: z.string().min(1) })]),
4834
+ cap: z.string().min(1),
4835
+ method: 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: z.record(z.string(), z.unknown()).optional()
4839
+ }),
4840
+ z.object({
4841
+ kind: 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: z.string().min(1).max(2e4)
4845
+ })
4846
+ ]);
4847
+ z.object({
4848
+ triggers: z.array(AutomationTriggerSchema),
4849
+ conditions: AutomationConditionSchema.optional(),
4850
+ actions: 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 = z.object({
4869
+ id: z.string(),
4870
+ label: z.string(),
4871
+ icon: z.string(),
4872
+ path: z.string(),
4873
+ remoteName: z.string(),
4874
+ bundle: z.string(),
4875
+ section: z.string().optional(),
4876
+ sectionLabel: z.string().optional()
4877
+ });
4878
+ var AddonPageInfoSchema = z.object({
4879
+ addonId: z.string(),
4880
+ page: AddonPageDeclarationSchema$1,
4881
+ bundleUrl: z.string()
4882
+ });
4883
+ var addonPagesCapability = {
4884
+ name: "addon-pages",
4655
4885
  scope: "system",
4656
4886
  mode: "singleton",
4657
- methods: {
4658
- analyseChunk: method(z.object({
4659
- chunk: AudioChunkInputSchema,
4660
- settings: AudioAnalysisSettingsSchema
4661
- }), AudioAnalysisResultSchema.nullable(), { kind: "mutation" }),
4662
- classify: method(AudioChunkInputSchema, AudioClassificationResultSchema, { timeoutMs: 3e4 }),
4663
- isReady: method(z.void(), z.boolean()),
4664
- dispose: method(z.void(), z.void(), { kind: "mutation" }),
4665
- /**
4666
- * Re-run the host platform-probe for the audio backend and persist
4667
- * the detected value into `probedBestAudioBackend`. Replaces the
4668
- * orchestrator's legacy `AgentPipelineSettings.audio.engine` —
4669
- * audio-analyzer now owns the choice. Operator `audioBackend` is
4670
- * not touched; only the probed-best hint.
4671
- */
4672
- reprobeAudioEngine: method(z.void(), z.object({ backend: z.string() }), {
4673
- kind: "mutation",
4674
- auth: "admin"
4675
- })
4676
- }
4887
+ methods: { listPages: method(z.void(), z.array(AddonPageInfoSchema).readonly()) }
4677
4888
  };
4678
- var PcmSampleFormatSchema = z.enum(["f32le", "s16le"]);
4679
- var AudioCodecInfoSchema = z.object({
4680
- codec: z.string(),
4681
- canDecode: z.boolean(),
4682
- canEncode: z.boolean(),
4683
- label: z.string().optional()
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 = z.object({
4902
+ id: z.string(),
4903
+ label: z.string(),
4904
+ icon: z.string(),
4905
+ path: 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: 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: 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: z.string().optional(),
4931
+ /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
4932
+ sectionLabel: z.string().optional()
4684
4933
  });
4685
- var AudioDecodeSessionConfigSchema = z.object({
4686
- codec: z.string(),
4687
- sourceSampleRate: z.number().int().positive(),
4688
- sourceChannels: z.number().int().positive(),
4689
- extraData: z.instanceof(Uint8Array).optional(),
4690
- targetSampleRate: z.number().int().positive(),
4691
- targetChannels: z.number().int().positive(),
4692
- targetFormat: PcmSampleFormatSchema.optional(),
4693
- idleMs: z.number().int().positive().optional(),
4694
- tag: z.string().optional()
4934
+ var addonPagesSourceCapability = {
4935
+ name: "addon-pages-source",
4936
+ scope: "system",
4937
+ mode: "collection",
4938
+ internal: true,
4939
+ methods: { listPages: method(z.void(), z.array(AddonPageDeclarationSchema).readonly()) }
4940
+ };
4941
+ var AddonHttpRouteSchema = z.object({
4942
+ method: z.enum([
4943
+ "GET",
4944
+ "POST",
4945
+ "PUT",
4946
+ "DELETE",
4947
+ "PATCH"
4948
+ ]),
4949
+ path: z.string(),
4950
+ access: z.enum([
4951
+ "public",
4952
+ "authenticated",
4953
+ "admin"
4954
+ ]).optional(),
4955
+ description: z.string().optional()
4695
4956
  });
4696
- var AudioEncodeSessionConfigSchema = z.object({
4697
- codec: z.string(),
4698
- sourceSampleRate: z.number().int().positive(),
4699
- sourceChannels: z.number().int().positive(),
4700
- sourceFormat: PcmSampleFormatSchema.optional(),
4701
- targetSampleRate: z.number().int().positive(),
4702
- targetChannels: z.number().int().positive(),
4703
- bitrateKbps: z.number().int().positive().optional(),
4704
- idleMs: z.number().int().positive().optional(),
4705
- tag: z.string().optional()
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 = z.object({
4966
+ method: z.string(),
4967
+ path: z.string(),
4968
+ params: z.record(z.string(), z.string()),
4969
+ query: z.record(z.string(), z.string()),
4970
+ body: z.unknown(),
4971
+ headers: z.record(z.string(), z.string()),
4972
+ user: z.object({
4973
+ id: z.string(),
4974
+ username: z.string(),
4975
+ isAdmin: z.boolean()
4976
+ }).optional(),
4977
+ scopedToken: z.unknown().optional()
4706
4978
  });
4707
- var AudioPcmChunkSchema = z.object({
4708
- data: z.instanceof(Uint8Array),
4709
- sampleRate: z.number().int().positive(),
4710
- channels: z.number().int().positive(),
4711
- format: PcmSampleFormatSchema,
4712
- pts: z.number()
4979
+ var InvokeReplyEnvelopeSchema = z.object({
4980
+ status: z.number().int(),
4981
+ headers: z.record(z.string(), 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: z.string().nullable(),
4986
+ /** JSON-serializable body. `undefined` is treated as "no body". */
4987
+ body: z.unknown().optional(),
4988
+ /** Set when the handler called `reply.type(mime)`. */
4989
+ contentType: z.string().optional()
4713
4990
  });
4714
- var AudioEncodedChunkSchema = z.object({
4715
- data: z.instanceof(Uint8Array),
4716
- codec: z.string(),
4717
- pts: z.number(),
4718
- frameComplete: z.boolean()
4991
+ var addonRoutesCapability = {
4992
+ name: "addon-routes",
4993
+ scope: "system",
4994
+ mode: "collection",
4995
+ internal: true,
4996
+ methods: {
4997
+ getRoutes: method(z.void(), 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 = z.object({
5012
+ id: z.string(),
5013
+ label: z.string(),
5014
+ icon: z.string(),
5015
+ order: z.number().optional()
4719
5016
  });
4720
- var SessionInventoryEntrySchema = z.object({
4721
- sessionId: z.string(),
4722
- kind: z.enum(["decode", "encode"]),
4723
- codec: z.string(),
4724
- sourceSampleRate: z.number(),
4725
- sourceChannels: z.number(),
4726
- targetSampleRate: z.number(),
4727
- targetChannels: z.number(),
4728
- format: PcmSampleFormatSchema,
4729
- tag: z.string().optional(),
4730
- createdAtMs: z.number(),
4731
- lastActivityMs: z.number(),
4732
- framesIn: z.number(),
4733
- framesOut: z.number()
5017
+ var ConfigSectionWithValuesSchema = z.object({
5018
+ id: z.string(),
5019
+ title: z.string(),
5020
+ description: z.string().optional(),
5021
+ style: z.enum(["card", "accordion"]).optional(),
5022
+ defaultCollapsed: z.boolean().optional(),
5023
+ columns: z.union([
5024
+ z.literal(1),
5025
+ z.literal(2),
5026
+ z.literal(3),
5027
+ z.literal(4)
5028
+ ]).optional(),
5029
+ tab: z.string().optional(),
5030
+ location: z.enum(["settings", "top-tab"]).optional(),
5031
+ order: z.number().optional(),
5032
+ fields: z.array(z.any())
5033
+ });
5034
+ var SettingsSchemaWithValuesSchema = z.object({
5035
+ tabs: z.array(ConfigTabDeclarationSchema).optional(),
5036
+ sections: z.array(ConfigSectionWithValuesSchema)
4734
5037
  });
5038
+ /** Patch object — keys are field names, values are the new field values. */
5039
+ var SettingsPatchSchema = z.record(z.string(), z.unknown());
5040
+ /** Standard success response for update operations. */
5041
+ var SettingsUpdateResultSchema = z.object({ success: z.literal(true) });
4735
5042
  /**
4736
- * audio-codecbidirectional PCM encoded audio I/O box.
5043
+ * addon-settingssingleton gateway for three-level addon settings.
4737
5044
  *
4738
- * Independent per-consumer sessions. The provider runs decode + resample
4739
- * (or resample + encode) inside the session so a 16kHz mono ASA
4740
- * subscriber and a 48kHz stereo WebRTC subscriber on the same source
4741
- * stream don't share resamplers.
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.
4742
5049
  *
4743
- * Singleton on each node. Decoder and encoder live in the same provider
4744
- * because they share the underlying libav contexts (node-av today,
4745
- * pluggable later) operators always install one or the other together.
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.
4746
5061
  */
4747
- var audioCodecCapability = {
4748
- name: "audio-codec",
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",
4749
5070
  scope: "system",
4750
5071
  mode: "singleton",
4751
- preferredProvider: "decoder-nodeav",
5072
+ nodeIdMode: "data",
4752
5073
  methods: {
4753
- /** Probe the local runtime and return the supported codec matrix. */
4754
- listSupportedCodecs: method(z.void(), z.array(AudioCodecInfoSchema).readonly()),
4755
- /** Cheap predicate — does the runtime support `(codec, kind)`? */
4756
- canHandle: method(z.object({
4757
- codec: z.string(),
4758
- kind: z.enum(["decode", "encode"])
4759
- }), z.boolean()),
4760
- createDecodeSession: method(AudioDecodeSessionConfigSchema, z.object({
4761
- sessionId: z.string(),
4762
- nodeId: z.string()
4763
- }), { kind: "mutation" }),
4764
- createEncodeSession: method(AudioEncodeSessionConfigSchema, z.object({
4765
- sessionId: z.string(),
4766
- nodeId: z.string()
4767
- }), { kind: "mutation" }),
4768
- closeSession: method(z.object({
4769
- sessionId: z.string(),
4770
- nodeId: z.string().optional()
4771
- }), z.void(), { kind: "mutation" }),
4772
- /** Push one encoded audio frame into a decode session. */
4773
- pushEncodedFrame: method(z.object({
4774
- sessionId: z.string(),
4775
- nodeId: z.string().optional(),
4776
- data: z.instanceof(Uint8Array),
4777
- /** Source PTS in milliseconds. Synthesised when omitted. */
4778
- pts: z.number().optional()
4779
- }), z.void(), { kind: "mutation" }),
4780
- /** Pull up to `maxCount` PCM chunks from a decode session. */
4781
- pullPcm: method(z.object({
4782
- sessionId: z.string(),
4783
- nodeId: z.string().optional(),
4784
- maxCount: z.number().int().positive().default(8)
4785
- }), z.array(AudioPcmChunkSchema)),
4786
- /** Push one PCM chunk into an encode session. */
4787
- pushPcm: method(z.object({
4788
- sessionId: z.string(),
5074
+ getGlobalSettings: method(z.object({
5075
+ addonId: z.string(),
4789
5076
  nodeId: z.string().optional(),
4790
- data: z.instanceof(Uint8Array),
4791
- /** Source PTS in milliseconds. */
4792
- pts: z.number().optional()
4793
- }), z.void(), { kind: "mutation" }),
4794
- /** Pull up to `maxCount` encoded chunks from an encode session. */
4795
- pullEncoded: method(z.object({
4796
- sessionId: z.string(),
5077
+ overlay: z.record(z.string(), z.unknown()).optional(),
5078
+ cap: z.string().optional()
5079
+ }), SettingsSchemaWithValuesSchema.nullable()),
5080
+ updateGlobalSettings: method(z.object({
5081
+ addonId: z.string(),
4797
5082
  nodeId: z.string().optional(),
4798
- maxCount: z.number().int().positive().default(8)
4799
- }), z.array(AudioEncodedChunkSchema)),
4800
- /** Flush any pending encoded output (call before close on graceful tear). */
4801
- flushEncode: method(z.object({
4802
- sessionId: z.string(),
5083
+ patch: SettingsPatchSchema
5084
+ }), SettingsUpdateResultSchema, {
5085
+ kind: "mutation",
5086
+ auth: "admin"
5087
+ }),
5088
+ getDeviceSettings: method(z.object({
5089
+ addonId: z.string(),
5090
+ deviceId: z.number(),
4803
5091
  nodeId: z.string().optional()
4804
- }), z.array(AudioEncodedChunkSchema), { kind: "mutation" }),
4805
- listActiveSessions: method(z.void(), z.array(SessionInventoryEntrySchema).readonly())
5092
+ }), SettingsSchemaWithValuesSchema.nullable()),
5093
+ updateDeviceSettings: method(z.object({
5094
+ addonId: z.string(),
5095
+ deviceId: z.number(),
5096
+ nodeId: z.string().optional(),
5097
+ patch: SettingsPatchSchema
5098
+ }), SettingsUpdateResultSchema, {
5099
+ kind: "mutation",
5100
+ auth: "admin"
5101
+ })
4806
5102
  }
4807
5103
  };
4808
- var AuthResultSchema = z.object({
4809
- userId: z.string(),
4810
- username: z.string(),
4811
- email: z.string().optional(),
4812
- displayName: z.string().optional(),
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 = z.enum([
5126
+ "device-tab",
5127
+ "dashboard",
5128
+ "integration-detail"
5129
+ ]);
5130
+ var WidgetSizeEnum = 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 = z.object({
5145
+ remoteName: z.string(),
5146
+ exposedModule: z.string(),
5147
+ componentKey: 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 = z.object({
5179
+ /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
5180
+ tab: z.string(),
5181
+ /** Optional sub-tab within `tab`. */
5182
+ subTab: z.string().optional(),
5183
+ /** Operator-facing label. */
5184
+ label: z.string(),
5185
+ /** Ordering within `(tab, subTab)`, ascending. */
5186
+ order: z.number().optional(),
5187
+ /** Always `'remote'` — a widget is a Module Federation remote. */
5188
+ kind: z.literal("remote"),
5189
+ /** MF remote descriptor. */
5190
+ remote: WidgetRemoteSchema,
5191
+ /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
5192
+ stableId: z.string(),
5193
+ description: z.string().optional(),
5194
+ icon: z.string().optional(),
4813
5195
  /**
4814
- * Whether the authenticating user is an admin. The auth-provider
4815
- * surface returns this so the server's login flow can mint a JWT
4816
- * with the correct bypass flag. Non-admin users authenticated via
4817
- * an external IdP still need their scopes assigned by an admin via
4818
- * `setUserScopes` the SSO flow doesn't carry permissions.
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.
4819
5201
  */
4820
- isAdmin: z.boolean().default(false)
5202
+ bundle: z.string(),
5203
+ /** Every host the widget supports. The picker filters on this set. */
5204
+ hosts: z.array(WidgetHostEnum).readonly(),
5205
+ /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
5206
+ requires: z.object({
5207
+ deviceContext: z.boolean().default(false),
5208
+ integrationContext: z.boolean().default(false)
5209
+ }),
5210
+ /**
5211
+ * Loadable BEFORE authentication. The normal widget registry listing
5212
+ * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
5213
+ * (the login page) cannot discover a widget through it. A widget that
5214
+ * declares `preAuth: true` marks itself as safe to mount on a pre-auth
5215
+ * screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
5216
+ * login-method contribution channel (see `login-method.cap.ts`) rather
5217
+ * than the authenticated registry, and its bundle is served by the
5218
+ * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
5219
+ */
5220
+ preAuth: z.boolean().optional().default(false),
5221
+ /** Dashboard placement HINTS (operator can override per instance). */
5222
+ defaultSize: WidgetSizeEnum.default("md"),
5223
+ allowedSizes: z.array(WidgetSizeEnum).readonly().default([
5224
+ "sm",
5225
+ "md",
5226
+ "lg"
5227
+ ]),
5228
+ defaultColumns: z.number().int().min(1).max(12).default(6),
5229
+ defaultRows: z.number().int().min(1).max(12).default(1)
4821
5230
  });
4822
- var authProviderCapability = {
4823
- name: "auth-provider",
5231
+ var addonWidgetsSourceCapability = {
5232
+ name: "addon-widgets-source",
4824
5233
  scope: "system",
4825
5234
  mode: "collection",
4826
5235
  internal: true,
4827
- methods: {
4828
- validateCredentials: method(z.object({
4829
- username: z.string(),
4830
- password: z.string()
4831
- }), AuthResultSchema.nullable(), { kind: "mutation" }),
4832
- getLoginUrl: method(z.object({ state: z.string() }), z.string()),
4833
- handleCallback: method(z.record(z.string(), z.string()), AuthResultSchema, { kind: "mutation" }),
4834
- validateToken: method(z.object({ token: z.string() }), AuthResultSchema.nullable())
4835
- },
4836
- /** BIG PLAN 2: declarative mount hint — read by `@camstack/system` `buildCapRouters`. */
4837
- mount: { kind: "skip" }
5236
+ methods: { listWidgets: method(z.void(), z.array(WidgetMetadataSchema).readonly()) }
4838
5237
  };
4839
5238
  /**
4840
- * Orchestrator-side destination metadata. The orchestrator computes
4841
- * `id = <addonId>:<subId>` from its provider lookup so consumers
4842
- * (admin UI, restore flow) see one canonical key.
5239
+ * `addon-widgets` system-scoped singleton aggregator cap. Public-facing
5240
+ * surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
4843
5241
  *
4844
- * Phase 4 (admin UI redesign) adds the per-destination policy fields
4845
- * (`enabled`, `retentionCount`, `label`) so the destinations table can
4846
- * render the joined view without a follow-up round-trip. The backend
4847
- * already joins these in `listDestinations` — we now surface them on
4848
- * the wire.
5242
+ * The provider iterates every `addon-widgets-source` (collection)
5243
+ * provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
5244
+ * `bundleUrl` strings pointing at
5245
+ * `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
5246
+ * `mtime` cache-buster lets the browser pick up addon rebuilds without
5247
+ * manual reload — same scheme used by `addon-pages`.
4849
5248
  *
4850
- * `lastSuccessAt` / `lastSuccessSizeBytes` are computed from each
4851
- * location's `manifests.json` (newest archive). Optional — locations
4852
- * with no archives yet leave them undefined.
5249
+ * The hub-local builtin `addon-widgets-aggregator` (see
5250
+ * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
5251
+ * provider. Splitting the public aggregator from the raw collection
5252
+ * keeps both ends in codegen — there's no hand-written wrapper.
4853
5253
  */
4854
- var BackupDestinationInfoSchema = z.object({
4855
- /**
4856
- * Sub-id within this addon. Convention `default` for single-
4857
- * destination addons; addons that host many (S3 with N buckets)
4858
- * pick stable per-bucket strings. The orchestrator stitches
4859
- * `<manifestId>:<subId>` for the wire format.
4860
- */
4861
- subId: z.string(),
4862
- displayName: z.string(),
4863
- description: z.string().optional(),
4864
- kind: z.string(),
4865
- triggerSupported: z.boolean(),
4866
- restoreSupported: z.boolean()
4867
- }).extend({
4868
- /** `<addonId>:<subId>` — globally-unique dispatch key. */
4869
- id: z.string(),
4870
- /**
4871
- * Manifest id of the owning addon. The Settings button on the
4872
- * destination card opens that addon's `globalSettingsSchema` panel.
4873
- */
5254
+ var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
4874
5255
  addonId: z.string(),
4875
- /** Operator-toggled enable flag from the destination-policy table. */
4876
- enabled: z.boolean(),
4877
- /** Operator-defined retention count (archives kept per destination). */
4878
- retentionCount: z.number(),
4879
- /** Operator-defined display label (overrides storage location displayName). */
4880
- label: z.string().optional(),
4881
- /** Newest-archive timestamp from `manifests.json`, or undefined. */
4882
- lastSuccessAt: z.number().optional(),
4883
- /** Newest-archive size from `manifests.json`, or undefined. */
4884
- lastSuccessSizeBytes: z.number().optional(),
4885
- /** Per-destination cron expression. Empty = manual-only (no schedule). */
4886
- cron: z.string().optional(),
4887
- /** ms-epoch of next computed firing for this destination's cron, if any. */
4888
- nextRunAt: z.number().optional(),
4889
- /** ms-epoch of last successful scheduled run (mirrors policy.lastRunAt). */
4890
- lastRunAt: z.number().optional()
5256
+ bundleUrl: z.string()
4891
5257
  });
5258
+ var addonWidgetsCapability = {
5259
+ name: "addon-widgets",
5260
+ scope: "system",
5261
+ mode: "singleton",
5262
+ methods: { listWidgets: method(z.void(), z.array(EnrichedWidgetMetadataSchema).readonly()) }
5263
+ };
4892
5264
  /**
4893
- * Per-archive entry returned by `backup.listArchives({ destinationId })`.
4894
- * Same shape the destination drill-in renders. Sourced from the per-
4895
- * location `manifests.json` (no per-archive tarball read required).
5265
+ * Alerts capability collection-based internal alert system.
5266
+ *
5267
+ * Multiple providers can register. Each provider filters by EventBus category
5268
+ * and creates/updates alerts. The built-in Alert Center addon persists alerts
5269
+ * in the DB and serves them to the admin UI.
4896
5270
  */
4897
- var BackupArchiveEntrySchema = z.object({
4898
- id: z.string(),
4899
- filename: z.string(),
4900
- createdAt: z.number(),
4901
- sizeBytes: z.number(),
4902
- label: z.string().optional(),
4903
- /** Top-level locations included in the archive (db, addons, tls, …). */
4904
- locations: z.array(z.string()).readonly()
5271
+ var AlertSeveritySchema = z.enum([
5272
+ "info",
5273
+ "success",
5274
+ "warning",
5275
+ "error"
5276
+ ]);
5277
+ var AlertStatusSchema = z.enum([
5278
+ "active",
5279
+ "in-progress",
5280
+ "completed",
5281
+ "failed",
5282
+ "dismissed"
5283
+ ]);
5284
+ var AlertSourceSchema = z.object({
5285
+ type: z.string(),
5286
+ id: z.string()
4905
5287
  });
4906
- var BackupEntrySchema = z.object({
5288
+ var AlertSchema = z.object({
4907
5289
  id: z.string(),
4908
- /** Addon id of the destination that owns this backup (e.g. `local-backup`, `s3-backup`). */
4909
- destinationId: z.string().optional(),
4910
- label: z.string().optional(),
4911
- createdAt: z.number(),
4912
- sizeBytes: z.number(),
4913
- locations: z.array(z.string()).optional()
4914
- });
4915
- var ArchiveEntrySchema = z.object({
4916
- path: z.string(),
4917
- kind: z.enum([
4918
- "file",
4919
- "dir",
4920
- "symlink"
4921
- ]),
4922
- sizeBytes: z.number(),
4923
- mtime: z.number()
4924
- });
4925
- var ArchiveManifestSchema = z.object({
4926
- archiveVersion: z.literal(1),
5290
+ category: z.string(),
5291
+ severity: AlertSeveritySchema,
5292
+ title: z.string(),
5293
+ message: z.string(),
5294
+ status: AlertStatusSchema,
5295
+ progress: z.number().optional(),
5296
+ read: z.boolean(),
4927
5297
  createdAt: z.number(),
4928
- dataDir: z.string(),
4929
- locations: z.array(z.string()),
4930
- entries: z.array(ArchiveEntrySchema),
4931
- totalBytes: z.number(),
4932
- totalFiles: z.number()
4933
- });
4934
- var LocationStatSchema = z.object({
4935
- name: z.string(),
4936
- sizeBytes: z.number(),
4937
- fileCount: z.number(),
4938
- present: z.boolean()
5298
+ updatedAt: z.number(),
5299
+ source: AlertSourceSchema.optional(),
5300
+ metadata: z.record(z.string(), z.unknown()).optional()
4939
5301
  });
5302
+ var alertsCapability = {
5303
+ name: "alerts",
5304
+ scope: "system",
5305
+ mode: "singleton",
5306
+ methods: {
5307
+ emit: method(AlertSchema, z.void(), { kind: "mutation" }),
5308
+ update: method(z.object({
5309
+ alertId: z.string(),
5310
+ patch: AlertSchema.partial()
5311
+ }), z.void(), { kind: "mutation" }),
5312
+ list: method(z.object({
5313
+ unreadOnly: z.boolean().optional(),
5314
+ limit: z.number().optional()
5315
+ }).optional(), z.array(AlertSchema).readonly()),
5316
+ getUnreadCount: method(z.void(), z.number()),
5317
+ markRead: method(z.object({ alertId: z.string() }), z.void(), { kind: "mutation" }),
5318
+ markAllRead: method(z.void(), z.void(), { kind: "mutation" }),
5319
+ dismiss: method(z.object({ alertId: z.string() }), z.void(), { kind: "mutation" })
5320
+ }
5321
+ };
4940
5322
  /**
4941
- * A backup schedule the N:M "entry" that binds one cron cadence to a
4942
- * SET of destination locations. Supersedes the per-location cron on
4943
- * `BackupDestinationPolicy`: an operator creates a schedule, picks the
4944
- * `backups` locations it should write to, and the orchestrator fans a
4945
- * single archive out to all of them when the cron fires.
5323
+ * audio-analysisdevice-scoped facade over the system `audio-analyzer`.
4946
5324
  *
4947
- * `retentionCount` is per-schedule (D-decision 2026-07-28): every
4948
- * location targeted by this schedule keeps this many archives from
4949
- * this schedule's runs.
5325
+ * Pairs with `audio-analyzer` (system, singleton) the way `camera-streams`
5326
+ * pairs with `stream-broker`: the system cap owns the compute path
5327
+ * (`processChunk`, engine lifecycle) while `audio-analysis` exposes the
5328
+ * per-device surface — settings resolution, device-settings contribution,
5329
+ * audio-level events — and is the binding row every camera sees in the
5330
+ * device-manager bindings UI.
4950
5331
  *
4951
- * `dataSources` optionally narrows which top-level state locations
4952
- * (db, addons, tls, …) are archived; omitted = the orchestrator's
4953
- * default full set.
5332
+ * The cap definition itself declares `kind: 'wrapper'` and `defaultActive: true`,
5333
+ * so every camera picks up audio analysis automatically without any runtime flag
5334
+ * in the addon's registration. Operators can disable per-device via
5335
+ * `device-manager.setWrapperActive` when they don't want a given camera to be
5336
+ * analysed (e.g. muted / motion-only cams).
4954
5337
  */
4955
- var BackupScheduleSchema = z.object({
4956
- /** Stable id. Generated by the orchestrator on first upsert if absent. */
4957
- id: z.string(),
4958
- /** Operator-facing display name. */
4959
- label: z.string(),
4960
- /** 5-field POSIX cron. Empty = disabled cadence (kept for editing). */
4961
- cron: z.string(),
4962
- /** Master on/off toggle for the whole schedule. */
4963
- enabled: z.boolean(),
4964
- /** `backups`-location ids this schedule writes to (fan-out set). */
4965
- locationIds: z.array(z.string()).readonly(),
4966
- /** Archives kept per targeted location for this schedule. */
4967
- retentionCount: z.number().int().min(1).max(1e3),
4968
- /** Optional subset of source locations to include; omitted = all. */
4969
- dataSources: z.array(z.string()).readonly().optional(),
4970
- /** ms-epoch of last successful run. */
4971
- lastRunAt: z.number().optional(),
4972
- /** ms-epoch of next computed firing (read-only, filled on list). */
4973
- nextRunAt: z.number().optional()
5338
+ var audioAnalysisCapability = {
5339
+ name: "audio-analysis",
5340
+ scope: "device",
5341
+ mode: "singleton",
5342
+ kind: "wrapper",
5343
+ defaultActive: true,
5344
+ deviceTypes: [DeviceType.Camera],
5345
+ exposesDeviceSettings: true,
5346
+ methods: {
5347
+ /**
5348
+ * Resolve per-device audio analysis settings (minConfidence,
5349
+ * allowedClasses) from the addon settings store for a given camera.
5350
+ * Orchestrator callers use this before handing chunks to
5351
+ * `audio-analyzer.processChunk`.
5352
+ */
5353
+ resolveDeviceSettings: method(z.object({ deviceId: z.number() }), z.custom()) },
5354
+ events: { onAudioLevel: event(z.object({
5355
+ deviceId: z.number(),
5356
+ rms: z.number(),
5357
+ dbfs: z.number()
5358
+ })) }
5359
+ };
5360
+ /** Shared Zod schemas used across detection capabilities. */
5361
+ /**
5362
+ * Canonical frame-format enum mirrored on `FrameFormat` in
5363
+ * `packages/types/src/types/io.ts`. Kept inline (vs imported) so the
5364
+ * Zod runtime schema and TypeScript type stay in sync at the call site
5365
+ * — adding a new format requires changing both this enum and the
5366
+ * `FrameFormat` type alias together.
5367
+ */
5368
+ var FrameFormatSchema = z.enum([
5369
+ "jpeg",
5370
+ "rgb",
5371
+ "bgr",
5372
+ "yuv420",
5373
+ "gray"
5374
+ ]);
5375
+ var FrameInputSchema = z.object({
5376
+ data: z.custom(),
5377
+ format: FrameFormatSchema,
5378
+ width: z.number(),
5379
+ height: z.number(),
5380
+ timestamp: z.number()
5381
+ });
5382
+ var BoundingBoxSchema = z.object({
5383
+ x: z.number(),
5384
+ y: z.number(),
5385
+ w: z.number(),
5386
+ h: z.number()
5387
+ });
5388
+ z.object({
5389
+ class: z.string(),
5390
+ originalClass: z.string(),
5391
+ score: z.number(),
5392
+ bbox: BoundingBoxSchema
4974
5393
  });
4975
5394
  /**
4976
- * backup singleton capability for backup management.
5395
+ * `data` carries the raw f32le bytes of the PCM samples (4 bytes per
5396
+ * sample, little-endian IEEE 754). `Uint8Array` is the wire-safe choice
5397
+ * because `@msgpack/msgpack` serialises it as a MsgPack `bin` type that
5398
+ * round-trips losslessly over the UDS transport. `Float32Array` is NOT
5399
+ * preserved — the encoder serialises it as `bin` (its raw bytes) but
5400
+ * the decoder returns `Uint8Array`, so treating a `Float32Array` as the
5401
+ * wire type causes receivers to read bytes as sample values, producing
5402
+ * wildly wrong RMS/dBFS results (~+43 dBFS instead of ≤0).
4977
5403
  *
4978
- * Implemented by `local-backup` addon. Future providers (S3, rsync)
4979
- * can replace it by registering the same capability.
5404
+ * Callers that need float arithmetic reconstruct the view with:
5405
+ * `new Float32Array(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength / 4)`
4980
5406
  */
4981
- var backupCapability = {
4982
- name: "backup",
4983
- scope: "system",
4984
- mode: "singleton",
4985
- methods: {
4986
- /**
4987
- * Flat aggregate of every destination across every registered
4988
- * `backup-destination` provider. This is the orchestrator-side
4989
- * surface; it expands per-addon `listDestinations()` results into
4990
- * one list the UI can render directly.
4991
- */
4992
- listDestinations: method(z.void(), z.array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }),
4993
- /**
4994
- * Trigger a backup. Without `destinations` the orchestrator fans
4995
- * out to every destination flagged as enabled in the routing
4996
- * config; with it, only the listed addons receive the archive.
4997
- */
4998
- trigger: method(z.object({
4999
- /** Subset of registered `backup-destination` addon ids to write to. */
5000
- destinations: z.array(z.string()).optional(),
5001
- locations: z.array(z.string()).optional(),
5002
- label: z.string().optional(),
5003
- /**
5004
- * Per-run retention override applied to every targeted
5005
- * destination. Used by schedule-driven runs (per-entry
5006
- * retention). Omitted = each destination's own policy
5007
- * retention (manual runs).
5008
- */
5009
- retentionCount: z.number().int().min(1).max(1e3).optional()
5010
- }).optional(), z.array(BackupEntrySchema).readonly(), {
5011
- kind: "mutation",
5012
- auth: "admin"
5013
- }),
5014
- /** Union of every destination's archives, each tagged with `destinationId`. */
5015
- list: method(z.void(), z.array(BackupEntrySchema).readonly(), { auth: "admin" }),
5016
- /**
5017
- * Pre-backup snapshot of the well-known locations on disk — sizes
5018
- * + file counts. Powers the opt-in checklist that lets the
5019
- * operator pick which subsections of state get archived.
5020
- */
5021
- listLocations: method(z.void(), z.array(LocationStatSchema).readonly(), { auth: "admin" }),
5022
- /**
5023
- * Read the embedded `.camstack-backup-manifest.json` from a
5024
- * previously-created archive. The manifest carries the full
5025
- * file/dir listing with sizes + mtimes — the readdir snapshot
5026
- * the UI shows in the "Contents" panel. Returns `null` when the
5027
- * archive predates manifests (created before this feature
5028
- * shipped).
5029
- */
5030
- getEntries: method(z.object({
5031
- destinationId: z.string(),
5032
- backupId: z.string()
5033
- }), ArchiveManifestSchema.nullable(), { auth: "admin" }),
5034
- restore: method(z.object({
5035
- destinationId: z.string(),
5036
- backupId: z.string(),
5037
- /**
5038
- * Optional whitelist only restore these top-level locations
5039
- * from the archive. Default = every location the archive
5040
- * carries (full restore). The boot-time apply hook reads
5041
- * this list and skips entries whose path doesn't start with
5042
- * any of them.
5043
- */
5044
- locations: z.array(z.string()).optional()
5045
- }), z.void(), {
5046
- kind: "mutation",
5047
- auth: "admin"
5048
- }),
5049
- delete: method(z.object({
5050
- destinationId: z.string(),
5051
- backupId: z.string()
5052
- }), z.void(), {
5053
- kind: "mutation",
5054
- auth: "admin"
5055
- }),
5056
- /**
5057
- * List archives at a single destination. Reads the per-location
5058
- * `manifests.json` and returns one entry per archive (newest
5059
- * first). Powers the destinations-table drill-in in admin UI.
5060
- */
5061
- listArchives: method(z.object({ destinationId: z.string() }), z.array(BackupArchiveEntrySchema).readonly(), { auth: "admin" }),
5062
- /**
5063
- * Upsert a per-destination policy row. The `locationId` MUST be
5064
- * the id of an existing `backups`-typed `StorageLocation`. Used
5065
- * by the admin-UI destinations table (enable toggle + retention
5066
- * input + optional label).
5067
- */
5068
- upsertDestinationPolicy: method(z.object({
5069
- locationId: z.string(),
5070
- enabled: z.boolean(),
5071
- retentionCount: z.number().int().min(1).max(1e3),
5072
- label: z.string().optional(),
5073
- /**
5074
- * Per-destination cron expression. Empty string clears the
5075
- * schedule (manual-only). Validated server-side via croner;
5076
- * malformed expressions reject the upsert with an actionable
5077
- * message.
5078
- */
5079
- cron: z.string().optional()
5080
- }), z.void(), {
5081
- kind: "mutation",
5082
- auth: "admin"
5083
- }),
5084
- /**
5085
- * Validate a cron expression and peek the next N firing times.
5086
- * Used by the admin-UI CronEditor to render a live "next-run"
5087
- * preview as the operator edits the pattern.
5088
- */
5089
- previewSchedule: method(z.object({
5090
- cron: z.string(),
5091
- count: z.number().int().min(1).max(20).optional()
5092
- }), z.object({
5093
- ok: z.boolean(),
5094
- error: z.string().optional(),
5095
- nextRuns: z.array(z.number()).readonly()
5096
- })),
5097
- /**
5098
- * List every backup schedule (the N:M entries), each with its
5099
- * computed `nextRunAt`. Powers the Schedules section of the
5100
- * admin-UI Backups page.
5101
- */
5102
- listSchedules: method(z.void(), z.array(BackupScheduleSchema).readonly(), { auth: "admin" }),
5407
+ var AudioChunkInputSchema = z.object({
5408
+ data: z.instanceof(Uint8Array),
5409
+ sampleRate: z.number(),
5410
+ channels: z.number(),
5411
+ timestamp: z.number(),
5412
+ /** Originating device id — used by the classifier for per-camera concurrency tracking. */
5413
+ deviceId: z.number().optional()
5414
+ });
5415
+ var AudioLevelSchema = z.object({
5416
+ rms: z.number(),
5417
+ dbfs: z.number()
5418
+ });
5419
+ var AudioClassificationLabelSchema = z.object({
5420
+ /**
5421
+ * Primary display class. Depending on how the label was produced
5422
+ * this is either the macro category (e.g. `dog`) or the raw
5423
+ * backend label (e.g. `Dog bark`). Mirrors `class` on
5424
+ * `SpatialDetection`.
5425
+ */
5426
+ className: z.string(),
5427
+ /**
5428
+ * Raw backend-native label the classifier actually emitted (e.g.
5429
+ * `Dog bark` for YAMNet, `dog_bark` for Apple SoundAnalysis). For
5430
+ * macro-aggregated entries this is the top raw contributor. Mirrors
5431
+ * `originalClass` on `SpatialDetection`.
5432
+ */
5433
+ originalClass: z.string().optional(),
5434
+ score: z.number()
5435
+ });
5436
+ var AudioAnalysisResultSchema = z.object({
5437
+ level: AudioLevelSchema,
5438
+ classification: z.object({
5439
+ labels: z.array(AudioClassificationLabelSchema).readonly(),
5440
+ inferenceMs: z.number()
5441
+ }).optional(),
5442
+ timestamp: z.number()
5443
+ });
5444
+ var AudioAnalysisSettingsSchema = z.object({
5445
+ minConfidence: z.number().min(0).max(1).default(.3),
5446
+ allowedClasses: z.array(z.string()).default([])
5447
+ });
5448
+ var AudioClassificationResultSchema = z.object({
5449
+ labels: z.array(AudioClassificationLabelSchema).readonly(),
5450
+ rawLabels: z.array(AudioClassificationLabelSchema).readonly().optional(),
5451
+ inferenceMs: z.number()
5452
+ });
5453
+ var audioAnalyzerCapability = {
5454
+ name: "audio-analyzer",
5455
+ scope: "system",
5456
+ mode: "singleton",
5457
+ methods: {
5458
+ analyseChunk: method(z.object({
5459
+ chunk: AudioChunkInputSchema,
5460
+ settings: AudioAnalysisSettingsSchema
5461
+ }), AudioAnalysisResultSchema.nullable(), { kind: "mutation" }),
5462
+ classify: method(AudioChunkInputSchema, AudioClassificationResultSchema, { timeoutMs: 3e4 }),
5463
+ isReady: method(z.void(), z.boolean()),
5464
+ dispose: method(z.void(), z.void(), { kind: "mutation" }),
5103
5465
  /**
5104
- * Create or replace a schedule. An empty / absent `id` mints a new
5105
- * one; a present `id` replaces in place. `cron` is validated
5106
- * server-side; `locationIds` must reference existing `backups`
5107
- * locations (unknown ids are dropped with a warning).
5466
+ * Re-run the host platform-probe for the audio backend and persist
5467
+ * the detected value into `probedBestAudioBackend`. Replaces the
5468
+ * orchestrator's legacy `AgentPipelineSettings.audio.engine`
5469
+ * audio-analyzer now owns the choice. Operator `audioBackend` is
5470
+ * not touched; only the probed-best hint.
5108
5471
  */
5109
- upsertSchedule: method(z.object({
5110
- id: z.string().optional(),
5111
- label: z.string(),
5112
- cron: z.string(),
5113
- enabled: z.boolean(),
5114
- locationIds: z.array(z.string()).readonly(),
5115
- retentionCount: z.number().int().min(1).max(1e3),
5116
- dataSources: z.array(z.string()).readonly().optional()
5117
- }), BackupScheduleSchema, {
5118
- kind: "mutation",
5119
- auth: "admin"
5120
- }),
5121
- /** Delete a schedule by id. Does not touch the target locations. */
5122
- deleteSchedule: method(z.object({ id: z.string() }), z.void(), {
5472
+ reprobeAudioEngine: method(z.void(), z.object({ backend: z.string() }), {
5123
5473
  kind: "mutation",
5124
5474
  auth: "admin"
5125
5475
  })
5126
5476
  }
5127
5477
  };
5478
+ var PcmSampleFormatSchema = z.enum(["f32le", "s16le"]);
5479
+ var AudioCodecInfoSchema = z.object({
5480
+ codec: z.string(),
5481
+ canDecode: z.boolean(),
5482
+ canEncode: z.boolean(),
5483
+ label: z.string().optional()
5484
+ });
5485
+ var AudioDecodeSessionConfigSchema = z.object({
5486
+ codec: z.string(),
5487
+ sourceSampleRate: z.number().int().positive(),
5488
+ sourceChannels: z.number().int().positive(),
5489
+ extraData: z.instanceof(Uint8Array).optional(),
5490
+ targetSampleRate: z.number().int().positive(),
5491
+ targetChannels: z.number().int().positive(),
5492
+ targetFormat: PcmSampleFormatSchema.optional(),
5493
+ idleMs: z.number().int().positive().optional(),
5494
+ tag: z.string().optional()
5495
+ });
5496
+ var AudioEncodeSessionConfigSchema = z.object({
5497
+ codec: z.string(),
5498
+ sourceSampleRate: z.number().int().positive(),
5499
+ sourceChannels: z.number().int().positive(),
5500
+ sourceFormat: PcmSampleFormatSchema.optional(),
5501
+ targetSampleRate: z.number().int().positive(),
5502
+ targetChannels: z.number().int().positive(),
5503
+ bitrateKbps: z.number().int().positive().optional(),
5504
+ idleMs: z.number().int().positive().optional(),
5505
+ tag: z.string().optional()
5506
+ });
5507
+ var AudioPcmChunkSchema = z.object({
5508
+ data: z.instanceof(Uint8Array),
5509
+ sampleRate: z.number().int().positive(),
5510
+ channels: z.number().int().positive(),
5511
+ format: PcmSampleFormatSchema,
5512
+ pts: z.number()
5513
+ });
5514
+ var AudioEncodedChunkSchema = z.object({
5515
+ data: z.instanceof(Uint8Array),
5516
+ codec: z.string(),
5517
+ pts: z.number(),
5518
+ frameComplete: z.boolean()
5519
+ });
5520
+ var SessionInventoryEntrySchema = z.object({
5521
+ sessionId: z.string(),
5522
+ kind: z.enum(["decode", "encode"]),
5523
+ codec: z.string(),
5524
+ sourceSampleRate: z.number(),
5525
+ sourceChannels: z.number(),
5526
+ targetSampleRate: z.number(),
5527
+ targetChannels: z.number(),
5528
+ format: PcmSampleFormatSchema,
5529
+ tag: z.string().optional(),
5530
+ createdAtMs: z.number(),
5531
+ lastActivityMs: z.number(),
5532
+ framesIn: z.number(),
5533
+ framesOut: z.number()
5534
+ });
5128
5535
  /**
5129
- * `broker`unified pub/sub broker registry, system-scoped collection.
5536
+ * audio-codecbidirectional PCM encoded audio I/O box.
5130
5537
  *
5131
- * The cap models any kind-tagged message broker the user wants to
5132
- * register and that other addons might consume. The first two kinds
5133
- * are MQTT (mosquitto / aedes embedded / cloud bridge) and
5134
- * Home Assistant (HA WebSocket — subscribe_entities + call_service).
5135
- * Future kinds (Zigbee2MQTT bridge, ZHA, KNX, Telegram, …) plug in
5136
- * the same surface.
5538
+ * Independent per-consumer sessions. The provider runs decode + resample
5539
+ * (or resample + encode) inside the session so a 16kHz mono ASA
5540
+ * subscriber and a 48kHz stereo WebRTC subscriber on the same source
5541
+ * stream don't share resamplers.
5137
5542
  *
5138
- * Why one cap, not one cap per kind:
5139
- * - The integrations page wants a single table of "every broker"
5140
- * across kinds; a unified cap drives it without join logic.
5141
- * - Generic admin operations (add / remove / test / status) are
5142
- * identical across kinds — duplicating them per cap was busywork.
5143
- * - The `network-access` cap precedent: same interface, many
5144
- * `providerKind: 'ingress'` implementations (Tailscale, ngrok, …).
5145
- *
5146
- * What stays kind-specific:
5147
- * - `getBrokerConfig` payload (kind decides whether it returns
5148
- * mqtt URL+creds, HA baseUrl+token, …) typed as
5149
- * `Record<string, unknown>` at the wire; consumers narrow.
5150
- * - `publish` / `subscribe` / `getState` arguments (target / filter /
5151
- * key) — also typed as `Record<string, unknown>` so each kind can
5152
- * evolve its surface (MQTT topic+qos vs HA entity_id+domain)
5153
- * without rebreaking the cap signature.
5154
- * - `add` `settings` payload (different fields per kind, validated by
5155
- * the kind-specific provider).
5543
+ * Singleton on each node. Decoder and encoder live in the same provider
5544
+ * because they share the underlying libav contexts (node-av today,
5545
+ * pluggable later) operators always install one or the other together.
5546
+ */
5547
+ var audioCodecCapability = {
5548
+ name: "audio-codec",
5549
+ scope: "system",
5550
+ mode: "singleton",
5551
+ preferredProvider: "decoder-nodeav",
5552
+ methods: {
5553
+ /** Probe the local runtime and return the supported codec matrix. */
5554
+ listSupportedCodecs: method(z.void(), z.array(AudioCodecInfoSchema).readonly()),
5555
+ /** Cheap predicate does the runtime support `(codec, kind)`? */
5556
+ canHandle: method(z.object({
5557
+ codec: z.string(),
5558
+ kind: z.enum(["decode", "encode"])
5559
+ }), z.boolean()),
5560
+ createDecodeSession: method(AudioDecodeSessionConfigSchema, z.object({
5561
+ sessionId: z.string(),
5562
+ nodeId: z.string()
5563
+ }), { kind: "mutation" }),
5564
+ createEncodeSession: method(AudioEncodeSessionConfigSchema, z.object({
5565
+ sessionId: z.string(),
5566
+ nodeId: z.string()
5567
+ }), { kind: "mutation" }),
5568
+ closeSession: method(z.object({
5569
+ sessionId: z.string(),
5570
+ nodeId: z.string().optional()
5571
+ }), z.void(), { kind: "mutation" }),
5572
+ /** Push one encoded audio frame into a decode session. */
5573
+ pushEncodedFrame: method(z.object({
5574
+ sessionId: z.string(),
5575
+ nodeId: z.string().optional(),
5576
+ data: z.instanceof(Uint8Array),
5577
+ /** Source PTS in milliseconds. Synthesised when omitted. */
5578
+ pts: z.number().optional()
5579
+ }), z.void(), { kind: "mutation" }),
5580
+ /** Pull up to `maxCount` PCM chunks from a decode session. */
5581
+ pullPcm: method(z.object({
5582
+ sessionId: z.string(),
5583
+ nodeId: z.string().optional(),
5584
+ maxCount: z.number().int().positive().default(8)
5585
+ }), z.array(AudioPcmChunkSchema)),
5586
+ /** Push one PCM chunk into an encode session. */
5587
+ pushPcm: method(z.object({
5588
+ sessionId: z.string(),
5589
+ nodeId: z.string().optional(),
5590
+ data: z.instanceof(Uint8Array),
5591
+ /** Source PTS in milliseconds. */
5592
+ pts: z.number().optional()
5593
+ }), z.void(), { kind: "mutation" }),
5594
+ /** Pull up to `maxCount` encoded chunks from an encode session. */
5595
+ pullEncoded: method(z.object({
5596
+ sessionId: z.string(),
5597
+ nodeId: z.string().optional(),
5598
+ maxCount: z.number().int().positive().default(8)
5599
+ }), z.array(AudioEncodedChunkSchema)),
5600
+ /** Flush any pending encoded output (call before close on graceful tear). */
5601
+ flushEncode: method(z.object({
5602
+ sessionId: z.string(),
5603
+ nodeId: z.string().optional()
5604
+ }), z.array(AudioEncodedChunkSchema), { kind: "mutation" }),
5605
+ listActiveSessions: method(z.void(), z.array(SessionInventoryEntrySchema).readonly())
5606
+ }
5607
+ };
5608
+ var AuthResultSchema = z.object({
5609
+ userId: z.string(),
5610
+ username: z.string(),
5611
+ email: z.string().optional(),
5612
+ displayName: z.string().optional(),
5613
+ /**
5614
+ * Whether the authenticating user is an admin. The auth-provider
5615
+ * surface returns this so the server's login flow can mint a JWT
5616
+ * with the correct bypass flag. Non-admin users authenticated via
5617
+ * an external IdP still need their scopes assigned by an admin via
5618
+ * `setUserScopes` — the SSO flow doesn't carry permissions.
5619
+ */
5620
+ isAdmin: z.boolean().default(false)
5621
+ });
5622
+ var authProviderCapability = {
5623
+ name: "auth-provider",
5624
+ scope: "system",
5625
+ mode: "collection",
5626
+ internal: true,
5627
+ methods: {
5628
+ validateCredentials: method(z.object({
5629
+ username: z.string(),
5630
+ password: z.string()
5631
+ }), AuthResultSchema.nullable(), { kind: "mutation" }),
5632
+ getLoginUrl: method(z.object({ state: z.string() }), z.string()),
5633
+ handleCallback: method(z.record(z.string(), z.string()), AuthResultSchema, { kind: "mutation" }),
5634
+ validateToken: method(z.object({ token: z.string() }), AuthResultSchema.nullable())
5635
+ },
5636
+ /** BIG PLAN 2: declarative mount hint — read by `@camstack/system` `buildCapRouters`. */
5637
+ mount: { kind: "skip" }
5638
+ };
5639
+ /**
5640
+ * Orchestrator-side destination metadata. The orchestrator computes
5641
+ * `id = <addonId>:<subId>` from its provider lookup so consumers
5642
+ * (admin UI, restore flow) see one canonical key.
5156
5643
  *
5157
- * Bidirectionality:
5158
- * - `publish` is RPC synchronous result (provider-defined).
5159
- * - `subscribe` returns a subscription handle; the actual message
5160
- * stream flows over the typed event-bus as `broker.message` events
5161
- * keyed by `(brokerId, subscriptionId)`. Consumers filter in their
5162
- * event handler. Subscriptions persist across reconnects — the
5163
- * provider re-subscribes upstream after a transport drop.
5644
+ * Phase 4 (admin UI redesign) adds the per-destination policy fields
5645
+ * (`enabled`, `retentionCount`, `label`) so the destinations table can
5646
+ * render the joined view without a follow-up round-trip. The backend
5647
+ * already joins these in `listDestinations` — we now surface them on
5648
+ * the wire.
5164
5649
  *
5165
- * Why event-bus push (not a subscription RPC stream):
5166
- * - Matches the rest of CamStack's D8 contract events for telemetry,
5167
- * RPC for loss-is-a-bug. Broker messages are telemetry (a single
5168
- * drop is recoverable via `getState({key})`).
5169
- * - Frees `broker.subscribe` from holding a long-lived RPC channel.
5170
- * - Cross-process: events already route through `$event-bus`; no
5171
- * per-broker wire-shaping needed.
5650
+ * `lastSuccessAt` / `lastSuccessSizeBytes` are computed from each
5651
+ * location's `manifests.json` (newest archive). Optional locations
5652
+ * with no archives yet leave them undefined.
5172
5653
  */
5173
- var BrokerStatusEnum = z.enum([
5174
- "connected",
5175
- "disconnected",
5176
- "connecting",
5177
- "auth-failed",
5178
- "unreachable",
5179
- "error"
5180
- ]);
5181
- var BrokerInfoSchema$1 = z.object({
5182
- /** Stable broker id. Persisted; survives addon restarts. */
5654
+ var BackupDestinationInfoSchema = z.object({
5655
+ /**
5656
+ * Sub-id within this addon. Convention `default` for single-
5657
+ * destination addons; addons that host many (S3 with N buckets)
5658
+ * pick stable per-bucket strings. The orchestrator stitches
5659
+ * `<manifestId>:<subId>` for the wire format.
5660
+ */
5661
+ subId: z.string(),
5662
+ displayName: z.string(),
5663
+ description: z.string().optional(),
5664
+ kind: z.string(),
5665
+ triggerSupported: z.boolean(),
5666
+ restoreSupported: z.boolean()
5667
+ }).extend({
5668
+ /** `<addonId>:<subId>` — globally-unique dispatch key. */
5183
5669
  id: z.string(),
5184
- /** Addon id of the provider that OWNS this broker.
5185
- *
5186
- * The `broker` cap is a system-scoped collection: several addons
5187
- * register a `broker` provider, each owning a DISJOINT set of
5188
- * brokers (mqtt-broker owns `mqtt_*`, provider-homeassistant owns
5189
- * `ha_*`). A broker therefore belongs to exactly one addon — like a
5190
- * device belongs to one integration. The admin UI threads this id
5191
- * back as the `{ addonId }` system-collection selector on every
5192
- * id-keyed call (`get` / `getSettings` / `setSettings` / `remove` /
5193
- * `testConnection`), so the call routes to the OWNING provider
5194
- * instead of defaulting to the first-registered one. */
5670
+ /**
5671
+ * Manifest id of the owning addon. The Settings button on the
5672
+ * destination card opens that addon's `globalSettingsSchema` panel.
5673
+ */
5195
5674
  addonId: z.string(),
5196
- /** Human-readable name (operator-chosen at add-time). */
5197
- name: z.string(),
5198
- /** Provider-defined kind tag `mqtt` / `home-assistant` / future. */
5199
- kind: z.string(),
5200
- status: BrokerStatusEnum,
5201
- /** Free-form provider-specific info (HA version, MQTT broker
5202
- * flavour, latency, mTLS active, …). */
5203
- info: z.record(z.string(), z.unknown()),
5204
- /** Ms epoch of the last connection probe / status update. */
5205
- lastCheckedAt: z.number().nullable(),
5206
- /** Last error message, when `status` indicates failure. */
5207
- error: z.string().nullable()
5208
- });
5209
- var RegistryStatusSchema = z.object({
5210
- brokerCount: z.number().int().nonnegative(),
5211
- connectedCount: z.number().int().nonnegative()
5675
+ /** Operator-toggled enable flag from the destination-policy table. */
5676
+ enabled: z.boolean(),
5677
+ /** Operator-defined retention count (archives kept per destination). */
5678
+ retentionCount: z.number(),
5679
+ /** Operator-defined display label (overrides storage location displayName). */
5680
+ label: z.string().optional(),
5681
+ /** Newest-archive timestamp from `manifests.json`, or undefined. */
5682
+ lastSuccessAt: z.number().optional(),
5683
+ /** Newest-archive size from `manifests.json`, or undefined. */
5684
+ lastSuccessSizeBytes: z.number().optional(),
5685
+ /** Per-destination cron expression. Empty = manual-only (no schedule). */
5686
+ cron: z.string().optional(),
5687
+ /** ms-epoch of next computed firing for this destination's cron, if any. */
5688
+ nextRunAt: z.number().optional(),
5689
+ /** ms-epoch of last successful scheduled run (mirrors policy.lastRunAt). */
5690
+ lastRunAt: z.number().optional()
5212
5691
  });
5213
5692
  /**
5214
- * One entry per `broker` provider — which addon provides which broker
5215
- * kind(s). The unified create picker reads this to know, for each
5216
- * (addonId, kind) pair the operator can pick, where to route the
5217
- * follow-up `getSettingsSchema` / `testSettings` / `add` calls (via the
5218
- * `{ addonId }` selector). A provider only advertises a kind here when
5219
- * it implements `getSettingsSchema` for that kind (so the picker never
5220
- * offers a kind whose creation form can't be rendered).
5693
+ * Per-archive entry returned by `backup.listArchives({ destinationId })`.
5694
+ * Same shape the destination drill-in renders. Sourced from the per-
5695
+ * location `manifests.json` (no per-archive tarball read required).
5221
5696
  */
5222
- var BrokerProviderInfoSchema = z.object({
5223
- /** Addon id of the `broker` provider this entry describes. */
5224
- addonId: z.string(),
5225
- /** Broker kinds this provider can create, with a display label. */
5226
- kinds: z.array(z.object({
5227
- kind: z.string(),
5228
- label: z.string()
5229
- }))
5697
+ var BackupArchiveEntrySchema = z.object({
5698
+ id: z.string(),
5699
+ filename: z.string(),
5700
+ createdAt: z.number(),
5701
+ sizeBytes: z.number(),
5702
+ label: z.string().optional(),
5703
+ /** Top-level locations included in the archive (db, addons, tls, …). */
5704
+ locations: z.array(z.string()).readonly()
5230
5705
  });
5231
- var ListInputSchema = z.object({
5232
- /** Optional kind filter — `list({kind:'home-assistant'})` returns
5233
- * only HA brokers. Omit to list every broker. */
5234
- kind: z.string().optional() });
5235
- var GetInputSchema = z.object({ id: z.string() });
5236
- var AddInputSchema = z.object({
5237
- kind: z.string().min(1),
5238
- name: z.string().min(1),
5239
- /**
5240
- * ADOPT an existing id instead of minting a new one.
5241
- *
5242
- * Written the day this cost an outage. A broker's id is not a detail: HA
5243
- * devices carry it inside their `stableId` (`ha:ha_004:dev:…`), so a broker
5244
- * lost from config and re-added as `ha_001` leaves every one of its devices
5245
- * bound to a broker that no longer exists. Re-entering the password under the
5246
- * ORIGINAL id turns a multi-step device migration back into re-entering a
5247
- * password.
5248
- *
5249
- * A provider MUST refuse an id that is already in use — adopting a live
5250
- * broker's id would silently take it over.
5251
- */
5252
- id: z.string().min(1).optional(),
5253
- /** Kind-specific settings (e.g. MQTT `{url,username,password}` or HA
5254
- * `{baseUrl,accessToken}`). Validated by the kind-specific provider
5255
- * branch on receipt — invalid shape rejects the add. */
5256
- settings: z.record(z.string(), z.unknown())
5257
- });
5258
- var AddResultSchema = z.object({ id: z.string() });
5259
- var RemoveInputSchema = z.object({ id: z.string() });
5260
- var TestConnectionResultSchema = z.discriminatedUnion("ok", [z.object({
5261
- ok: z.literal(true),
5262
- latencyMs: z.number().nonnegative()
5263
- }), z.object({
5264
- ok: z.literal(false),
5265
- error: z.string()
5266
- })]);
5267
- var SettingsRecordSchema$1 = z.record(z.string(), z.unknown());
5268
- var SettingsSchemaInputSchema = z.object({ kind: z.string() });
5269
- var TestSettingsInputSchema = z.object({
5270
- kind: z.string(),
5271
- settings: SettingsRecordSchema$1
5706
+ var BackupEntrySchema = z.object({
5707
+ id: z.string(),
5708
+ /** Addon id of the destination that owns this backup (e.g. `local-backup`, `s3-backup`). */
5709
+ destinationId: z.string().optional(),
5710
+ label: z.string().optional(),
5711
+ createdAt: z.number(),
5712
+ sizeBytes: z.number(),
5713
+ locations: z.array(z.string()).optional()
5272
5714
  });
5273
- var TestSettingsResultSchema = z.discriminatedUnion("ok", [z.object({
5274
- ok: z.literal(true),
5275
- latencyMs: z.number().nonnegative().optional()
5276
- }).strict(), z.object({
5277
- ok: z.literal(false),
5278
- error: z.string()
5279
- })]);
5280
- var SettingsSchemaResultSchema = z.unknown().nullable();
5281
- var PublishInputSchema = z.object({
5282
- brokerId: z.string(),
5283
- /** Kind-specific routing target.
5284
- * MQTT: `{ topic: string, qos?: 0|1|2, retain?: boolean }`.
5285
- * HA: `{ domain: string, service: string, entityId?: string, area?: string }`. */
5286
- target: z.record(z.string(), z.unknown()),
5287
- /** Kind-specific payload.
5288
- * MQTT: raw string / number / object (provider serialises).
5289
- * HA: service-call `data` object (transition, brightness, …). */
5290
- payload: z.unknown().optional()
5715
+ var ArchiveEntrySchema = z.object({
5716
+ path: z.string(),
5717
+ kind: z.enum([
5718
+ "file",
5719
+ "dir",
5720
+ "symlink"
5721
+ ]),
5722
+ sizeBytes: z.number(),
5723
+ mtime: z.number()
5291
5724
  });
5292
- var SubscribeInputSchema = z.object({
5293
- brokerId: z.string(),
5294
- /** Kind-specific filter.
5295
- * MQTT: `{ topic: string, qos?: 0|1|2 }` — topic can include wildcards.
5296
- * HA: `{ entityIds: string[] }` or `{ domain: string }`. */
5297
- filter: z.record(z.string(), z.unknown())
5725
+ var ArchiveManifestSchema = z.object({
5726
+ archiveVersion: z.literal(1),
5727
+ createdAt: z.number(),
5728
+ dataDir: z.string(),
5729
+ locations: z.array(z.string()),
5730
+ entries: z.array(ArchiveEntrySchema),
5731
+ totalBytes: z.number(),
5732
+ totalFiles: z.number()
5298
5733
  });
5299
- var SubscribeResultSchema = z.object({
5300
- /** Stable subscription id. Used to unsubscribe and to filter the
5301
- * matching `broker.message` events on the bus. */
5302
- subscriptionId: z.string() });
5303
- var UnsubscribeInputSchema = z.object({
5304
- brokerId: z.string(),
5305
- subscriptionId: z.string()
5734
+ var LocationStatSchema = z.object({
5735
+ name: z.string(),
5736
+ sizeBytes: z.number(),
5737
+ fileCount: z.number(),
5738
+ present: z.boolean()
5306
5739
  });
5307
- var GetStateInputSchema = z.object({
5308
- brokerId: z.string(),
5309
- /** Kind-specific lookup key.
5310
- * MQTT: topic string (returns the last retained message).
5311
- * HA: entity_id (returns the cached entity state). */
5312
- key: z.string()
5740
+ /**
5741
+ * A backup schedule — the N:M "entry" that binds one cron cadence to a
5742
+ * SET of destination locations. Supersedes the per-location cron on
5743
+ * `BackupDestinationPolicy`: an operator creates a schedule, picks the
5744
+ * `backups` locations it should write to, and the orchestrator fans a
5745
+ * single archive out to all of them when the cron fires.
5746
+ *
5747
+ * `retentionCount` is per-schedule (D-decision 2026-07-28): every
5748
+ * location targeted by this schedule keeps this many archives from
5749
+ * this schedule's runs.
5750
+ *
5751
+ * `dataSources` optionally narrows which top-level state locations
5752
+ * (db, addons, tls, …) are archived; omitted = the orchestrator's
5753
+ * default full set.
5754
+ */
5755
+ var BackupScheduleSchema = z.object({
5756
+ /** Stable id. Generated by the orchestrator on first upsert if absent. */
5757
+ id: z.string(),
5758
+ /** Operator-facing display name. */
5759
+ label: z.string(),
5760
+ /** 5-field POSIX cron. Empty = disabled cadence (kept for editing). */
5761
+ cron: z.string(),
5762
+ /** Master on/off toggle for the whole schedule. */
5763
+ enabled: z.boolean(),
5764
+ /** `backups`-location ids this schedule writes to (fan-out set). */
5765
+ locationIds: z.array(z.string()).readonly(),
5766
+ /** Archives kept per targeted location for this schedule. */
5767
+ retentionCount: z.number().int().min(1).max(1e3),
5768
+ /** Optional subset of source locations to include; omitted = all. */
5769
+ dataSources: z.array(z.string()).readonly().optional(),
5770
+ /** ms-epoch of last successful run. */
5771
+ lastRunAt: z.number().optional(),
5772
+ /** ms-epoch of next computed firing (read-only, filled on list). */
5773
+ nextRunAt: z.number().optional()
5313
5774
  });
5314
- var brokerCapability = {
5315
- name: "broker",
5775
+ /**
5776
+ * backup — singleton capability for backup management.
5777
+ *
5778
+ * Implemented by `local-backup` addon. Future providers (S3, rsync)
5779
+ * can replace it by registering the same capability.
5780
+ */
5781
+ var backupCapability = {
5782
+ name: "backup",
5316
5783
  scope: "system",
5317
- mode: "collection",
5318
- providerKind: "broker",
5319
- status: {
5320
- schema: RegistryStatusSchema,
5321
- kind: "poll"
5322
- },
5784
+ mode: "singleton",
5323
5785
  methods: {
5324
- list: method(ListInputSchema, z.array(BrokerInfoSchema$1)),
5325
- get: method(GetInputSchema, BrokerInfoSchema$1.nullable()),
5326
- /** Enumerate which addon provides which broker kind(s) for the
5327
- * unified create picker. The auto-mount fans this array across
5328
- * every registered `broker` provider (array-output method), so the
5329
- * picker sees every kind from every provider in one call. */
5330
- listProviders: method(z.void(), z.array(BrokerProviderInfoSchema), { auth: "admin" }),
5331
- add: method(AddInputSchema, AddResultSchema, {
5332
- kind: "mutation",
5333
- auth: "admin"
5334
- }),
5335
- remove: method(RemoveInputSchema, z.void(), {
5786
+ /**
5787
+ * Flat aggregate of every destination across every registered
5788
+ * `backup-destination` provider. This is the orchestrator-side
5789
+ * surface; it expands per-addon `listDestinations()` results into
5790
+ * one list the UI can render directly.
5791
+ */
5792
+ listDestinations: method(z.void(), z.array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }),
5793
+ /**
5794
+ * Trigger a backup. Without `destinations` the orchestrator fans
5795
+ * out to every destination flagged as enabled in the routing
5796
+ * config; with it, only the listed addons receive the archive.
5797
+ */
5798
+ trigger: method(z.object({
5799
+ /** Subset of registered `backup-destination` addon ids to write to. */
5800
+ destinations: z.array(z.string()).optional(),
5801
+ locations: z.array(z.string()).optional(),
5802
+ label: z.string().optional(),
5803
+ /**
5804
+ * Per-run retention override applied to every targeted
5805
+ * destination. Used by schedule-driven runs (per-entry
5806
+ * retention). Omitted = each destination's own policy
5807
+ * retention (manual runs).
5808
+ */
5809
+ retentionCount: z.number().int().min(1).max(1e3).optional()
5810
+ }).optional(), z.array(BackupEntrySchema).readonly(), {
5336
5811
  kind: "mutation",
5337
5812
  auth: "admin"
5338
5813
  }),
5339
- testConnection: method(GetInputSchema, TestConnectionResultSchema, {
5814
+ /** Union of every destination's archives, each tagged with `destinationId`. */
5815
+ list: method(z.void(), z.array(BackupEntrySchema).readonly(), { auth: "admin" }),
5816
+ /**
5817
+ * Pre-backup snapshot of the well-known locations on disk — sizes
5818
+ * + file counts. Powers the opt-in checklist that lets the
5819
+ * operator pick which subsections of state get archived.
5820
+ */
5821
+ listLocations: method(z.void(), z.array(LocationStatSchema).readonly(), { auth: "admin" }),
5822
+ /**
5823
+ * Read the embedded `.camstack-backup-manifest.json` from a
5824
+ * previously-created archive. The manifest carries the full
5825
+ * file/dir listing with sizes + mtimes — the readdir snapshot
5826
+ * the UI shows in the "Contents" panel. Returns `null` when the
5827
+ * archive predates manifests (created before this feature
5828
+ * shipped).
5829
+ */
5830
+ getEntries: method(z.object({
5831
+ destinationId: z.string(),
5832
+ backupId: z.string()
5833
+ }), ArchiveManifestSchema.nullable(), { auth: "admin" }),
5834
+ restore: method(z.object({
5835
+ destinationId: z.string(),
5836
+ backupId: z.string(),
5837
+ /**
5838
+ * Optional whitelist — only restore these top-level locations
5839
+ * from the archive. Default = every location the archive
5840
+ * carries (full restore). The boot-time apply hook reads
5841
+ * this list and skips entries whose path doesn't start with
5842
+ * any of them.
5843
+ */
5844
+ locations: z.array(z.string()).optional()
5845
+ }), z.void(), {
5340
5846
  kind: "mutation",
5341
5847
  auth: "admin"
5342
5848
  }),
5343
- /** Read the persisted settings record for a broker (kind-specific
5344
- * shape). Admin-only — settings may contain secrets. Returns `null`
5345
- * when the broker id is unknown to the provider (the collection
5346
- * fallback may route a foreign id to the first provider). */
5347
- getSettings: method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }),
5348
- /** Overwrite the persisted settings record. The kind-specific
5349
- * provider validates the shape and applies the change (reconnects
5350
- * if credentials changed). */
5351
- setSettings: method(z.object({
5352
- id: z.string(),
5353
- settings: SettingsRecordSchema$1
5849
+ delete: method(z.object({
5850
+ destinationId: z.string(),
5851
+ backupId: z.string()
5354
5852
  }), z.void(), {
5355
5853
  kind: "mutation",
5356
5854
  auth: "admin"
5357
5855
  }),
5358
- /** Returns the kind-specific connection config the consumer needs
5359
- * to open its own client (MQTT pattern: `{url, username, password,
5360
- * clientIdPrefix}`). HA providers MAY return the auth envelope
5361
- * but typical HA consumers use `publish` / `subscribe` instead.
5362
- * Returns `null` when the broker id is unknown to the provider. */
5363
- getBrokerConfig: method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }),
5364
- getSettingsSchema: method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }),
5365
- testSettings: method(TestSettingsInputSchema, TestSettingsResultSchema, {
5856
+ /**
5857
+ * List archives at a single destination. Reads the per-location
5858
+ * `manifests.json` and returns one entry per archive (newest
5859
+ * first). Powers the destinations-table drill-in in admin UI.
5860
+ */
5861
+ listArchives: method(z.object({ destinationId: z.string() }), z.array(BackupArchiveEntrySchema).readonly(), { auth: "admin" }),
5862
+ /**
5863
+ * Upsert a per-destination policy row. The `locationId` MUST be
5864
+ * the id of an existing `backups`-typed `StorageLocation`. Used
5865
+ * by the admin-UI destinations table (enable toggle + retention
5866
+ * input + optional label).
5867
+ */
5868
+ upsertDestinationPolicy: method(z.object({
5869
+ locationId: z.string(),
5870
+ enabled: z.boolean(),
5871
+ retentionCount: z.number().int().min(1).max(1e3),
5872
+ label: z.string().optional(),
5873
+ /**
5874
+ * Per-destination cron expression. Empty string clears the
5875
+ * schedule (manual-only). Validated server-side via croner;
5876
+ * malformed expressions reject the upsert with an actionable
5877
+ * message.
5878
+ */
5879
+ cron: z.string().optional()
5880
+ }), z.void(), {
5366
5881
  kind: "mutation",
5367
5882
  auth: "admin"
5368
5883
  }),
5369
- publish: method(PublishInputSchema, z.unknown(), {
5370
- kind: "mutation",
5371
- auth: "admin"
5372
- }),
5373
- subscribe: method(SubscribeInputSchema, SubscribeResultSchema, {
5884
+ /**
5885
+ * Validate a cron expression and peek the next N firing times.
5886
+ * Used by the admin-UI CronEditor to render a live "next-run"
5887
+ * preview as the operator edits the pattern.
5888
+ */
5889
+ previewSchedule: method(z.object({
5890
+ cron: z.string(),
5891
+ count: z.number().int().min(1).max(20).optional()
5892
+ }), z.object({
5893
+ ok: z.boolean(),
5894
+ error: z.string().optional(),
5895
+ nextRuns: z.array(z.number()).readonly()
5896
+ })),
5897
+ /**
5898
+ * List every backup schedule (the N:M entries), each with its
5899
+ * computed `nextRunAt`. Powers the Schedules section of the
5900
+ * admin-UI Backups page.
5901
+ */
5902
+ listSchedules: method(z.void(), z.array(BackupScheduleSchema).readonly(), { auth: "admin" }),
5903
+ /**
5904
+ * Create or replace a schedule. An empty / absent `id` mints a new
5905
+ * one; a present `id` replaces in place. `cron` is validated
5906
+ * server-side; `locationIds` must reference existing `backups`
5907
+ * locations (unknown ids are dropped with a warning).
5908
+ */
5909
+ upsertSchedule: method(z.object({
5910
+ id: z.string().optional(),
5911
+ label: z.string(),
5912
+ cron: z.string(),
5913
+ enabled: z.boolean(),
5914
+ locationIds: z.array(z.string()).readonly(),
5915
+ retentionCount: z.number().int().min(1).max(1e3),
5916
+ dataSources: z.array(z.string()).readonly().optional()
5917
+ }), BackupScheduleSchema, {
5374
5918
  kind: "mutation",
5375
5919
  auth: "admin"
5376
5920
  }),
5377
- unsubscribe: method(UnsubscribeInputSchema, z.void(), {
5921
+ /** Delete a schedule by id. Does not touch the target locations. */
5922
+ deleteSchedule: method(z.object({ id: z.string() }), z.void(), {
5378
5923
  kind: "mutation",
5379
5924
  auth: "admin"
5380
- }),
5381
- /** Read the broker's cached state for a key. Returns `null` when
5382
- * unknown to the broker (never published / unknown entity). */
5383
- getState: method(GetStateInputSchema, z.unknown().nullable()),
5384
- /** Status method — explicit registration with a `z.void()` input so
5385
- * the codegen-generated tRPC router types its input as
5386
- * `{addonId?: string, nodeId?: string}` (system-scoped collection
5387
- * shape) instead of the device-scoped `{deviceId}` fallback. */
5388
- getStatus: method(z.void(), RegistryStatusSchema)
5925
+ })
5389
5926
  }
5390
5927
  };
5391
5928
  /**
5392
- * camera-pipeline-configdevice-scoped wrapper that carries the
5393
- * pipeline-orchestrator's PER-DEVICE settings contribution into the
5394
- * binding-driven device-detail aggregate (D12).
5929
+ * `broker`unified pub/sub broker registry, system-scoped collection.
5395
5930
  *
5396
- * Why this cap exists: the orchestrator's per-camera settings (motion
5397
- * sources / fps / cooldown, detection + audio mode, cluster assignment,
5398
- * the Detection-Zones top-tab widget, onboard-motion + onboard-object-
5399
- * detection toggles) were historically contributed via the
5400
- * `pipeline-orchestrator` cap's `exposesDeviceSettings` methods. But
5401
- * `pipeline-orchestrator` is `scope:'system'` and never appears in
5402
- * `getBindings(deviceId)`, so the D12 binding-driven aggregate
5403
- * (`device-manager.getDeviceAggregate`) silently dropped the entire
5404
- * contribution — the zones tab and the pipeline/motion settings vanished
5405
- * from device-detail.
5931
+ * The cap models any kind-tagged message broker the user wants to
5932
+ * register and that other addons might consume. The first two kinds
5933
+ * are MQTT (mosquitto / aedes embedded / cloud bridge) and
5934
+ * Home Assistant (HA WebSocket subscribe_entities + call_service).
5935
+ * Future kinds (Zigbee2MQTT bridge, ZHA, KNX, Telegram, …) plug in
5936
+ * the same surface.
5406
5937
  *
5407
- * `kind:'wrapper'` + `defaultActive:true` makes this cap auto-bind to every
5408
- * camera (it appears in `getBindings` as `kind:'wrapped'`), so the aggregate
5409
- * invokes the orchestrator's contribution methods through it restoring the
5410
- * tabs without violating D12 (contributions come only from bound providers).
5938
+ * Why one cap, not one cap per kind:
5939
+ * - The integrations page wants a single table of "every broker"
5940
+ * across kinds; a unified cap drives it without join logic.
5941
+ * - Generic admin operations (add / remove / test / status) are
5942
+ * identical across kinds — duplicating them per cap was busywork.
5943
+ * - The `network-access` cap precedent: same interface, many
5944
+ * `providerKind: 'ingress'` implementations (Tailscale, ngrok, …).
5411
5945
  *
5412
- * `exposesDeviceSettings:true` (NOT `deviceConfig`) is intentional: the
5413
- * contribution is a dynamic, multi-section, per-device hand-built structure
5414
- * (it probes bindings for onboard-motion / native-object-detection presence,
5415
- * cluster nodes, etc.), which the derived-UI `deviceConfig` archetype cannot
5416
- * express. This mirrors the `detection-pipeline` / `motion-detection`
5417
- * wrapper pattern. The orchestrator class is the provider; it already
5418
- * implements the three DeviceSettingsContribution methods.
5419
- */
5420
- var cameraPipelineConfigCapability = {
5421
- name: "camera-pipeline-config",
5422
- scope: "device",
5423
- mode: "singleton",
5424
- kind: "wrapper",
5425
- defaultActive: true,
5426
- deviceTypes: [DeviceType.Camera],
5427
- exposesDeviceSettings: true,
5428
- methods: {}
5429
- };
5430
- /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
5431
- var StreamFormatSchema = z.enum([
5432
- "webrtc",
5433
- "hls",
5434
- "mjpeg",
5435
- "rtsp"
5436
- ]);
5437
- /** A container `produceEventMedia` can emit. */
5438
- var EventMediaKindSchema = z.enum(["mp4", "gif"]);
5439
- /**
5440
- * One produced artifact, referenced by HANDLE.
5946
+ * What stays kind-specific:
5947
+ * - `getBrokerConfig` payload (kind decides whether it returns
5948
+ * mqtt URL+creds, HA baseUrl+token, …) typed as
5949
+ * `Record<string, unknown>` at the wire; consumers narrow.
5950
+ * - `publish` / `subscribe` / `getState` arguments (target / filter /
5951
+ * key) also typed as `Record<string, unknown>` so each kind can
5952
+ * evolve its surface (MQTT topic+qos vs HA entity_id+domain)
5953
+ * without rebreaking the cap signature.
5954
+ * - `add` `settings` payload (different fields per kind, validated by
5955
+ * the kind-specific provider).
5441
5956
  *
5442
- * Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
5443
- * method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
5444
- * [D18](../../../../docs/decisions/adr-0018.md) cross-process media is fetched
5445
- * on demand, compressed, by handle). `bytes` is here so a caller can decide
5446
- * whether it wants the fetch at all.
5957
+ * Bidirectionality:
5958
+ * - `publish` is RPC synchronous result (provider-defined).
5959
+ * - `subscribe` returns a subscription handle; the actual message
5960
+ * stream flows over the typed event-bus as `broker.message` events
5961
+ * keyed by `(brokerId, subscriptionId)`. Consumers filter in their
5962
+ * event handler. Subscriptions persist across reconnects — the
5963
+ * provider re-subscribes upstream after a transport drop.
5964
+ *
5965
+ * Why event-bus push (not a subscription RPC stream):
5966
+ * - Matches the rest of CamStack's D8 contract — events for telemetry,
5967
+ * RPC for loss-is-a-bug. Broker messages are telemetry (a single
5968
+ * drop is recoverable via `getState({key})`).
5969
+ * - Frees `broker.subscribe` from holding a long-lived RPC channel.
5970
+ * - Cross-process: events already route through `$event-bus`; no
5971
+ * per-broker wire-shaping needed.
5447
5972
  */
5448
- var EventMediaArtifactSchema = z.object({
5449
- kind: EventMediaKindSchema,
5450
- /** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
5451
- handle: z.string(),
5452
- /**
5453
- * The node holding the bytes — the ROUTING key for `fetchEventMedia`.
5973
+ var BrokerStatusEnum = z.enum([
5974
+ "connected",
5975
+ "disconnected",
5976
+ "connecting",
5977
+ "auth-failed",
5978
+ "unreachable",
5979
+ "error"
5980
+ ]);
5981
+ var BrokerInfoSchema$1 = z.object({
5982
+ /** Stable broker id. Persisted; survives addon restarts. */
5983
+ id: z.string(),
5984
+ /** Addon id of the provider that OWNS this broker.
5454
5985
  *
5455
- * `stream-broker` is a singleton cap and an unpinned call never leaves the
5456
- * hub, so a handle produced on an agent's broker would be redeemed against
5457
- * the hub's store and come back `null`. Same contract, same field name and
5458
- * the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
5459
- * lives and the consumer pins to it.
5460
- */
5461
- nodeId: z.string(),
5462
- mime: z.string(),
5463
- bytes: z.number().int(),
5464
- width: z.number().int(),
5465
- height: z.number().int()
5986
+ * The `broker` cap is a system-scoped collection: several addons
5987
+ * register a `broker` provider, each owning a DISJOINT set of
5988
+ * brokers (mqtt-broker owns `mqtt_*`, provider-homeassistant owns
5989
+ * `ha_*`). A broker therefore belongs to exactly one addon like a
5990
+ * device belongs to one integration. The admin UI threads this id
5991
+ * back as the `{ addonId }` system-collection selector on every
5992
+ * id-keyed call (`get` / `getSettings` / `setSettings` / `remove` /
5993
+ * `testConnection`), so the call routes to the OWNING provider
5994
+ * instead of defaulting to the first-registered one. */
5995
+ addonId: z.string(),
5996
+ /** Human-readable name (operator-chosen at add-time). */
5997
+ name: z.string(),
5998
+ /** Provider-defined kind tag — `mqtt` / `home-assistant` / future. */
5999
+ kind: z.string(),
6000
+ status: BrokerStatusEnum,
6001
+ /** Free-form provider-specific info (HA version, MQTT broker
6002
+ * flavour, latency, mTLS active, …). */
6003
+ info: z.record(z.string(), z.unknown()),
6004
+ /** Ms epoch of the last connection probe / status update. */
6005
+ lastCheckedAt: z.number().nullable(),
6006
+ /** Last error message, when `status` indicates failure. */
6007
+ error: z.string().nullable()
5466
6008
  });
5467
- /**
5468
- * What a production actually covered — the answer to the only question an
5469
- * operator asks about a notification clip.
5470
- *
5471
- * `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
5472
- * so a caller can state "this clip starts 4.1 s before the event" instead of
5473
- * inferring it from a duration. A production whose `fromTs` is later than the
5474
- * event is a production with no pre-roll, and that is exactly the defect this
5475
- * method exists to make visible rather than plausible.
5476
- */
5477
- var EventMediaCoverageSchema = z.object({
5478
- fromTs: z.number(),
5479
- toTs: z.number(),
5480
- /** Encoded packets in the muxed window. */
5481
- packets: z.number().int()
6009
+ var RegistryStatusSchema = z.object({
6010
+ brokerCount: z.number().int().nonnegative(),
6011
+ connectedCount: z.number().int().nonnegative()
5482
6012
  });
5483
6013
  /**
5484
- * The result of ONE cut, in every container the caller asked for.
5485
- *
5486
- * Every artifact in `media` came out of the SAME window of the SAME rendition —
5487
- * that is the whole reason this is one method rather than one call per format.
5488
- * A consumer attaching a gif and a video can no longer show two different
5489
- * moments, because it never chose two sources.
6014
+ * One entry per `broker` provider which addon provides which broker
6015
+ * kind(s). The unified create picker reads this to know, for each
6016
+ * (addonId, kind) pair the operator can pick, where to route the
6017
+ * follow-up `getSettingsSchema` / `testSettings` / `add` calls (via the
6018
+ * `{ addonId }` selector). A provider only advertises a kind here when
6019
+ * it implements `getSettingsSchema` for that kind (so the picker never
6020
+ * offers a kind whose creation form can't be rendered).
5490
6021
  */
5491
- var EventMediaProductionSchema = z.object({
5492
- media: z.array(EventMediaArtifactSchema).readonly(),
5493
- coverage: EventMediaCoverageSchema,
5494
- /** The rendition actually cut from what the default or the fallback chose. */
5495
- profile: CamProfileSchema,
5496
- /** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
5497
- * source, a downscale, or a playback rate other than 1). */
5498
- video: z.enum(["copy", "encode"])
6022
+ var BrokerProviderInfoSchema = z.object({
6023
+ /** Addon id of the `broker` provider this entry describes. */
6024
+ addonId: z.string(),
6025
+ /** Broker kinds this provider can create, with a display label. */
6026
+ kinds: z.array(z.object({
6027
+ kind: z.string(),
6028
+ label: z.string()
6029
+ }))
5499
6030
  });
5500
- var RtspRestreamEntrySchema = z.object({
5501
- brokerId: z.string(),
5502
- url: z.string(),
5503
- mutedUrl: z.string(),
5504
- enabled: z.boolean(),
6031
+ var ListInputSchema = z.object({
6032
+ /** Optional kind filter — `list({kind:'home-assistant'})` returns
6033
+ * only HA brokers. Omit to list every broker. */
6034
+ kind: z.string().optional() });
6035
+ var GetInputSchema = z.object({ id: z.string() });
6036
+ var AddInputSchema = z.object({
6037
+ kind: z.string().min(1),
6038
+ name: z.string().min(1),
5505
6039
  /**
5506
- * Source-stream codec / resolution for the camStream this entry serves
5507
- * (the broker's "high"/"mid"/"low" profile slot for this device).
5508
- * Used by exporter pickers (`pickPreferredRtspEntry`) to resolve
5509
- * `streamPreference: 'auto'` to the slot whose source is closest to
5510
- * the consumer's target Alexa wants ~720p, HomeKit wants ~1080p,
5511
- * and there's no point dialling the 4K slot for an Echo Show. Absent
5512
- * when the source publisher never advertised the field; pickers fall
5513
- * back to first-enabled in that case.
6040
+ * ADOPT an existing id instead of minting a new one.
6041
+ *
6042
+ * Written the day this cost an outage. A broker's id is not a detail: HA
6043
+ * devices carry it inside their `stableId` (`ha:ha_004:dev:…`), so a broker
6044
+ * lost from config and re-added as `ha_001` leaves every one of its devices
6045
+ * bound to a broker that no longer exists. Re-entering the password under the
6046
+ * ORIGINAL id turns a multi-step device migration back into re-entering a
6047
+ * password.
6048
+ *
6049
+ * A provider MUST refuse an id that is already in use — adopting a live
6050
+ * broker's id would silently take it over.
5514
6051
  */
5515
- codec: z.string().optional(),
5516
- resolution: z.object({
5517
- width: z.number().int().positive(),
5518
- height: z.number().int().positive()
5519
- }).optional()
6052
+ id: z.string().min(1).optional(),
6053
+ /** Kind-specific settings (e.g. MQTT `{url,username,password}` or HA
6054
+ * `{baseUrl,accessToken}`). Validated by the kind-specific provider
6055
+ * branch on receipt — invalid shape rejects the add. */
6056
+ settings: z.record(z.string(), z.unknown())
5520
6057
  });
5521
- var BrokerRtspClientSchema = z.object({
5522
- sessionId: z.string(),
5523
- remoteAddr: z.string(),
5524
- /** Client `User-Agent` (e.g. `recorder` for the recorder's RTSP pull), or
5525
- * null/absent when the client sent none. Lets the UI label a consumer by
5526
- * purpose. Optional so a client built against an older schema stays valid. */
5527
- userAgent: z.string().nullish(),
5528
- playing: z.boolean(),
5529
- muted: z.boolean(),
5530
- connectedAt: z.number(),
5531
- lastRtpAt: z.number(),
5532
- bytesSent: z.number()
6058
+ var AddResultSchema = z.object({ id: z.string() });
6059
+ var RemoveInputSchema = z.object({ id: z.string() });
6060
+ var TestConnectionResultSchema = z.discriminatedUnion("ok", [z.object({
6061
+ ok: z.literal(true),
6062
+ latencyMs: z.number().nonnegative()
6063
+ }), z.object({
6064
+ ok: z.literal(false),
6065
+ error: z.string()
6066
+ })]);
6067
+ var SettingsRecordSchema$1 = z.record(z.string(), z.unknown());
6068
+ var SettingsSchemaInputSchema = z.object({ kind: z.string() });
6069
+ var TestSettingsInputSchema = z.object({
6070
+ kind: z.string(),
6071
+ settings: SettingsRecordSchema$1
5533
6072
  });
5534
- var BrokerDecodedClientSchema = z.object({
5535
- tag: z.string(),
5536
- subscribedAt: z.number(),
5537
- maxFps: z.number(),
5538
- framesDelivered: z.number(),
5539
- framesDropped: z.number()
6073
+ var TestSettingsResultSchema = z.discriminatedUnion("ok", [z.object({
6074
+ ok: z.literal(true),
6075
+ latencyMs: z.number().nonnegative().optional()
6076
+ }).strict(), z.object({
6077
+ ok: z.literal(false),
6078
+ error: z.string()
6079
+ })]);
6080
+ var SettingsSchemaResultSchema = z.unknown().nullable();
6081
+ var PublishInputSchema = z.object({
6082
+ brokerId: z.string(),
6083
+ /** Kind-specific routing target.
6084
+ * MQTT: `{ topic: string, qos?: 0|1|2, retain?: boolean }`.
6085
+ * HA: `{ domain: string, service: string, entityId?: string, area?: string }`. */
6086
+ target: z.record(z.string(), z.unknown()),
6087
+ /** Kind-specific payload.
6088
+ * MQTT: raw string / number / object (provider serialises).
6089
+ * HA: service-call `data` object (transition, brightness, …). */
6090
+ payload: z.unknown().optional()
5540
6091
  });
5541
- var BrokerAudioClientSchema = z.object({
5542
- tag: z.string(),
5543
- subscribedAt: z.number(),
5544
- chunksDelivered: z.number()
6092
+ var SubscribeInputSchema = z.object({
6093
+ brokerId: z.string(),
6094
+ /** Kind-specific filter.
6095
+ * MQTT: `{ topic: string, qos?: 0|1|2 }` — topic can include wildcards.
6096
+ * HA: `{ entityIds: string[] }` or `{ domain: string }`. */
6097
+ filter: z.record(z.string(), z.unknown())
6098
+ });
6099
+ var SubscribeResultSchema = z.object({
6100
+ /** Stable subscription id. Used to unsubscribe and to filter the
6101
+ * matching `broker.message` events on the bus. */
6102
+ subscriptionId: z.string() });
6103
+ var UnsubscribeInputSchema = z.object({
6104
+ brokerId: z.string(),
6105
+ subscriptionId: z.string()
6106
+ });
6107
+ var GetStateInputSchema = z.object({
6108
+ brokerId: z.string(),
6109
+ /** Kind-specific lookup key.
6110
+ * MQTT: topic string (returns the last retained message).
6111
+ * HA: entity_id (returns the cached entity state). */
6112
+ key: z.string()
5545
6113
  });
6114
+ var brokerCapability = {
6115
+ name: "broker",
6116
+ scope: "system",
6117
+ mode: "collection",
6118
+ providerKind: "broker",
6119
+ status: {
6120
+ schema: RegistryStatusSchema,
6121
+ kind: "poll"
6122
+ },
6123
+ methods: {
6124
+ list: method(ListInputSchema, z.array(BrokerInfoSchema$1)),
6125
+ get: method(GetInputSchema, BrokerInfoSchema$1.nullable()),
6126
+ /** Enumerate which addon provides which broker kind(s) for the
6127
+ * unified create picker. The auto-mount fans this array across
6128
+ * every registered `broker` provider (array-output method), so the
6129
+ * picker sees every kind from every provider in one call. */
6130
+ listProviders: method(z.void(), z.array(BrokerProviderInfoSchema), { auth: "admin" }),
6131
+ add: method(AddInputSchema, AddResultSchema, {
6132
+ kind: "mutation",
6133
+ auth: "admin"
6134
+ }),
6135
+ remove: method(RemoveInputSchema, z.void(), {
6136
+ kind: "mutation",
6137
+ auth: "admin"
6138
+ }),
6139
+ testConnection: method(GetInputSchema, TestConnectionResultSchema, {
6140
+ kind: "mutation",
6141
+ auth: "admin"
6142
+ }),
6143
+ /** Read the persisted settings record for a broker (kind-specific
6144
+ * shape). Admin-only — settings may contain secrets. Returns `null`
6145
+ * when the broker id is unknown to the provider (the collection
6146
+ * fallback may route a foreign id to the first provider). */
6147
+ getSettings: method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }),
6148
+ /** Overwrite the persisted settings record. The kind-specific
6149
+ * provider validates the shape and applies the change (reconnects
6150
+ * if credentials changed). */
6151
+ setSettings: method(z.object({
6152
+ id: z.string(),
6153
+ settings: SettingsRecordSchema$1
6154
+ }), z.void(), {
6155
+ kind: "mutation",
6156
+ auth: "admin"
6157
+ }),
6158
+ /** Returns the kind-specific connection config the consumer needs
6159
+ * to open its own client (MQTT pattern: `{url, username, password,
6160
+ * clientIdPrefix}`). HA providers MAY return the auth envelope
6161
+ * but typical HA consumers use `publish` / `subscribe` instead.
6162
+ * Returns `null` when the broker id is unknown to the provider. */
6163
+ getBrokerConfig: method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }),
6164
+ getSettingsSchema: method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }),
6165
+ testSettings: method(TestSettingsInputSchema, TestSettingsResultSchema, {
6166
+ kind: "mutation",
6167
+ auth: "admin"
6168
+ }),
6169
+ publish: method(PublishInputSchema, z.unknown(), {
6170
+ kind: "mutation",
6171
+ auth: "admin"
6172
+ }),
6173
+ subscribe: method(SubscribeInputSchema, SubscribeResultSchema, {
6174
+ kind: "mutation",
6175
+ auth: "admin"
6176
+ }),
6177
+ unsubscribe: method(UnsubscribeInputSchema, z.void(), {
6178
+ kind: "mutation",
6179
+ auth: "admin"
6180
+ }),
6181
+ /** Read the broker's cached state for a key. Returns `null` when
6182
+ * unknown to the broker (never published / unknown entity). */
6183
+ getState: method(GetStateInputSchema, z.unknown().nullable()),
6184
+ /** Status method — explicit registration with a `z.void()` input so
6185
+ * the codegen-generated tRPC router types its input as
6186
+ * `{addonId?: string, nodeId?: string}` (system-scoped collection
6187
+ * shape) instead of the device-scoped `{deviceId}` fallback. */
6188
+ getStatus: method(z.void(), RegistryStatusSchema)
6189
+ }
6190
+ };
5546
6191
  /**
5547
- * Identifies who is holding an encoded / raw-RTP subscription open on
5548
- * the broker. Populated by the subscribing addon when it attaches —
5549
- * defaults to `{ kind: 'unknown' }` for callers that haven't been
5550
- * migrated. The widget surfaces every field that's set so an operator
5551
- * can answer "who is keeping cam X warm?" without grepping logs.
6192
+ * camera-pipeline-config device-scoped wrapper that carries the
6193
+ * pipeline-orchestrator's PER-DEVICE settings contribution into the
6194
+ * binding-driven device-detail aggregate (D12).
6195
+ *
6196
+ * Why this cap exists: the orchestrator's per-camera settings (motion
6197
+ * sources / fps / cooldown, detection + audio mode, cluster assignment,
6198
+ * the Detection-Zones top-tab widget, onboard-motion + onboard-object-
6199
+ * detection toggles) were historically contributed via the
6200
+ * `pipeline-orchestrator` cap's `exposesDeviceSettings` methods. But
6201
+ * `pipeline-orchestrator` is `scope:'system'` and never appears in
6202
+ * `getBindings(deviceId)`, so the D12 binding-driven aggregate
6203
+ * (`device-manager.getDeviceAggregate`) silently dropped the entire
6204
+ * contribution — the zones tab and the pipeline/motion settings vanished
6205
+ * from device-detail.
6206
+ *
6207
+ * `kind:'wrapper'` + `defaultActive:true` makes this cap auto-bind to every
6208
+ * camera (it appears in `getBindings` as `kind:'wrapped'`), so the aggregate
6209
+ * invokes the orchestrator's contribution methods through it — restoring the
6210
+ * tabs without violating D12 (contributions come only from bound providers).
6211
+ *
6212
+ * `exposesDeviceSettings:true` (NOT `deviceConfig`) is intentional: the
6213
+ * contribution is a dynamic, multi-section, per-device hand-built structure
6214
+ * (it probes bindings for onboard-motion / native-object-detection presence,
6215
+ * cluster nodes, etc.), which the derived-UI `deviceConfig` archetype cannot
6216
+ * express. This mirrors the `detection-pipeline` / `motion-detection`
6217
+ * wrapper pattern. The orchestrator class is the provider; it already
6218
+ * implements the three DeviceSettingsContribution methods.
5552
6219
  */
5553
- var BrokerConsumerKindSchema = z.enum([
5554
- "alexa",
5555
- "homekit",
5556
- "webrtc-browser",
5557
- "webrtc-mobile",
5558
- "webrtc-whep",
5559
- "rtsp-listen",
5560
- "derived-broker",
5561
- "recording",
5562
- "pipeline",
5563
- "snapshot",
5564
- "warmup",
5565
- "unknown"
6220
+ var cameraPipelineConfigCapability = {
6221
+ name: "camera-pipeline-config",
6222
+ scope: "device",
6223
+ mode: "singleton",
6224
+ kind: "wrapper",
6225
+ defaultActive: true,
6226
+ deviceTypes: [DeviceType.Camera],
6227
+ exposesDeviceSettings: true,
6228
+ methods: {}
6229
+ };
6230
+ /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
6231
+ var StreamFormatSchema = z.enum([
6232
+ "webrtc",
6233
+ "hls",
6234
+ "mjpeg",
6235
+ "rtsp"
5566
6236
  ]);
5567
- var BrokerConsumerAttributionSchema = z.object({
5568
- kind: BrokerConsumerKindSchema,
6237
+ /** A container `produceEventMedia` can emit. */
6238
+ var EventMediaKindSchema = z.enum(["mp4", "gif"]);
6239
+ /**
6240
+ * One produced artifact, referenced by HANDLE.
6241
+ *
6242
+ * Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
6243
+ * method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
6244
+ * [D18](../../../../docs/decisions/adr-0018.md) — cross-process media is fetched
6245
+ * on demand, compressed, by handle). `bytes` is here so a caller can decide
6246
+ * whether it wants the fetch at all.
6247
+ */
6248
+ var EventMediaArtifactSchema = z.object({
6249
+ kind: EventMediaKindSchema,
6250
+ /** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
6251
+ handle: z.string(),
5569
6252
  /**
5570
- * Free-form label intended to disambiguate consumers OF THE SAME kind
5571
- * on the same broker — e.g. user name, device alias. Should NOT repeat
5572
- * the kind / cam / cam-stream / sessionId (those are surfaced by
6253
+ * The node holding the bytes the ROUTING key for `fetchEventMedia`.
6254
+ *
6255
+ * `stream-broker` is a singleton cap and an unpinned call never leaves the
6256
+ * hub, so a handle produced on an agent's broker would be redeemed against
6257
+ * the hub's store and come back `null`. Same contract, same field name and
6258
+ * the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
6259
+ * lives and the consumer pins to it.
6260
+ */
6261
+ nodeId: z.string(),
6262
+ mime: z.string(),
6263
+ bytes: z.number().int(),
6264
+ width: z.number().int(),
6265
+ height: z.number().int()
6266
+ });
6267
+ /**
6268
+ * What a production actually covered — the answer to the only question an
6269
+ * operator asks about a notification clip.
6270
+ *
6271
+ * `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
6272
+ * so a caller can state "this clip starts 4.1 s before the event" instead of
6273
+ * inferring it from a duration. A production whose `fromTs` is later than the
6274
+ * event is a production with no pre-roll, and that is exactly the defect this
6275
+ * method exists to make visible rather than plausible.
6276
+ */
6277
+ var EventMediaCoverageSchema = z.object({
6278
+ fromTs: z.number(),
6279
+ toTs: z.number(),
6280
+ /** Encoded packets in the muxed window. */
6281
+ packets: z.number().int()
6282
+ });
6283
+ /**
6284
+ * The result of ONE cut, in every container the caller asked for.
6285
+ *
6286
+ * Every artifact in `media` came out of the SAME window of the SAME rendition —
6287
+ * that is the whole reason this is one method rather than one call per format.
6288
+ * A consumer attaching a gif and a video can no longer show two different
6289
+ * moments, because it never chose two sources.
6290
+ */
6291
+ var EventMediaProductionSchema = z.object({
6292
+ media: z.array(EventMediaArtifactSchema).readonly(),
6293
+ coverage: EventMediaCoverageSchema,
6294
+ /** The rendition actually cut from — what the default or the fallback chose. */
6295
+ profile: CamProfileSchema,
6296
+ /** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
6297
+ * source, a downscale, or a playback rate other than 1). */
6298
+ video: z.enum(["copy", "encode"])
6299
+ });
6300
+ var RtspRestreamEntrySchema = z.object({
6301
+ brokerId: z.string(),
6302
+ url: z.string(),
6303
+ mutedUrl: z.string(),
6304
+ enabled: z.boolean(),
6305
+ /**
6306
+ * Source-stream codec / resolution for the camStream this entry serves
6307
+ * (the broker's "high"/"mid"/"low" profile slot for this device).
6308
+ * Used by exporter pickers (`pickPreferredRtspEntry`) to resolve
6309
+ * `streamPreference: 'auto'` to the slot whose source is closest to
6310
+ * the consumer's target — Alexa wants ~720p, HomeKit wants ~1080p,
6311
+ * and there's no point dialling the 4K slot for an Echo Show. Absent
6312
+ * when the source publisher never advertised the field; pickers fall
6313
+ * back to first-enabled in that case.
6314
+ */
6315
+ codec: z.string().optional(),
6316
+ resolution: z.object({
6317
+ width: z.number().int().positive(),
6318
+ height: z.number().int().positive()
6319
+ }).optional()
6320
+ });
6321
+ var BrokerRtspClientSchema = z.object({
6322
+ sessionId: z.string(),
6323
+ remoteAddr: z.string(),
6324
+ /** Client `User-Agent` (e.g. `recorder` for the recorder's RTSP pull), or
6325
+ * null/absent when the client sent none. Lets the UI label a consumer by
6326
+ * purpose. Optional so a client built against an older schema stays valid. */
6327
+ userAgent: z.string().nullish(),
6328
+ playing: z.boolean(),
6329
+ muted: z.boolean(),
6330
+ connectedAt: z.number(),
6331
+ lastRtpAt: z.number(),
6332
+ bytesSent: z.number()
6333
+ });
6334
+ var BrokerDecodedClientSchema = z.object({
6335
+ tag: z.string(),
6336
+ subscribedAt: z.number(),
6337
+ maxFps: z.number(),
6338
+ framesDelivered: z.number(),
6339
+ framesDropped: z.number()
6340
+ });
6341
+ var BrokerAudioClientSchema = z.object({
6342
+ tag: z.string(),
6343
+ subscribedAt: z.number(),
6344
+ chunksDelivered: z.number()
6345
+ });
6346
+ /**
6347
+ * Identifies who is holding an encoded / raw-RTP subscription open on
6348
+ * the broker. Populated by the subscribing addon when it attaches —
6349
+ * defaults to `{ kind: 'unknown' }` for callers that haven't been
6350
+ * migrated. The widget surfaces every field that's set so an operator
6351
+ * can answer "who is keeping cam X warm?" without grepping logs.
6352
+ */
6353
+ var BrokerConsumerKindSchema = z.enum([
6354
+ "alexa",
6355
+ "homekit",
6356
+ "webrtc-browser",
6357
+ "webrtc-mobile",
6358
+ "webrtc-whep",
6359
+ "rtsp-listen",
6360
+ "derived-broker",
6361
+ "recording",
6362
+ "pipeline",
6363
+ "snapshot",
6364
+ "warmup",
6365
+ "unknown"
6366
+ ]);
6367
+ var BrokerConsumerAttributionSchema = z.object({
6368
+ kind: BrokerConsumerKindSchema,
6369
+ /**
6370
+ * Free-form label intended to disambiguate consumers OF THE SAME kind
6371
+ * on the same broker — e.g. user name, device alias. Should NOT repeat
6372
+ * the kind / cam / cam-stream / sessionId (those are surfaced by
5573
6373
  * dedicated fields). When empty the widget falls back to `${kind} ·
5574
6374
  * <sessionId tail>`.
5575
6375
  */
@@ -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 = z.object({
12471
13288
  /** Periodic snapshots at snapshotIntervalMs cadence (subject to
12472
13289
  * saveThumbnails policy). */
12473
13290
  snapshots: 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: z.array(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: z.array(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. */
@@ -25752,723 +26587,278 @@ var AccessoryKind = {
25752
26587
  Spotlight: DeviceRole.Spotlight,
25753
26588
  PirSensor: DeviceRole.PirSensor,
25754
26589
  Chime: DeviceRole.Chime,
25755
- Autotrack: DeviceRole.Autotrack,
25756
- Nightvision: DeviceRole.Nightvision,
25757
- PrivacyMask: DeviceRole.PrivacyMask
25758
- };
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 z.ZodNullable || s instanceof z.ZodOptional || s instanceof z.ZodDefault) s = s.unwrap();
25768
- return s;
25769
- }
25770
- function leafKind(schema) {
25771
- const s = unwrap(schema);
25772
- if (s instanceof z.ZodEnum) return {
25773
- kind: "enum",
25774
- enumValues: s.options
25775
- };
25776
- if (s instanceof z.ZodNumber) return { kind: "number" };
25777
- if (s instanceof z.ZodString) return { kind: "string" };
25778
- if (s instanceof 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 skippednever throws. */
25785
- function enumerateSchemaFields(schema, prefix = "") {
25786
- const root = unwrap(schema);
25787
- if (!(root instanceof 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 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
- /**
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".
25821
- */
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
- };
25840
- /**
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.
25845
- *
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.
25851
- *
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.
25856
- */
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")
25956
- },
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
- }
25973
- };
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
- /**
25979
- * Resource-bound constants for the safe expression engine.
25980
- *
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.
25986
- */
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"
25999
- ]);
26000
- /**
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.
26008
- */
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
- }
26142
- /**
26143
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
26144
- *
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.
26151
- *
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`.
26154
- */
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
26170
- };
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();
26590
+ Autotrack: DeviceRole.Autotrack,
26591
+ Nightvision: DeviceRole.Nightvision,
26592
+ PrivacyMask: DeviceRole.PrivacyMask
26593
+ };
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 z.ZodNullable || s instanceof z.ZodOptional || s instanceof 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 z.ZodEnum) return {
26818
+ kind: "enum",
26819
+ enumValues: s.options
26820
+ };
26821
+ if (s instanceof z.ZodNumber) return { kind: "number" };
26822
+ if (s instanceof z.ZodString) return { kind: "string" };
26823
+ if (s instanceof 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 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 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 = z.object({
26433
- kind: z.literal("field").optional(),
26434
- sourceKey: z.string(),
26435
- cap: z.string(),
26436
- fieldPath: z.string()
26437
- });
26438
- /** A constant. No device is read. */
26439
- var ExpressionLiteralBindingSchema = z.object({
26440
- kind: z.literal("literal"),
26441
- value: z.union([
26442
- z.string(),
26443
- z.number(),
26444
- z.boolean(),
26445
- 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 = z.object({
26451
- kind: z.literal("global"),
26452
- sourceStableId: z.string(),
26453
- cap: z.string(),
26454
- fieldPath: z.string()
26455
- });
26456
- var ExpressionBindingSourceSchema = z.union([
26457
- ExpressionFieldBindingSchema,
26458
- ExpressionLiteralBindingSchema,
26459
- ExpressionGlobalBindingSchema
26460
- ]);
26461
- z.object({
26462
- expr: z.string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
26463
- bindings: z.record(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 +
@@ -32477,4 +32867,4 @@ function enumerateInferenceDevices(hw) {
32477
32867
  return out;
32478
32868
  }
32479
32869
  //#endregion
32480
- export { platformProbeCapability as $, deviceStatusCapability as A, nodePin as At, isSameAddonId as B, coreBlockAddonId as C, asString as Ct, decodeVectorBase64 as D, expandCapMethods as Dt, dataStoreProviderCapability as E, emitReadiness as Et, extractNestedAddonId as F, scopeKey as Ft, logDestinationCapability as G, kebabToCamel as H, filesystemBrowseCapability as I, sleep as It, metricsProviderCapability as J, logLevelAtMost as K, isArrayOutputSchema as L, EventCategory as Lt, enumerateInferenceDevices as M, parseJsonUnknown as Mt, enumerateItemArrayFields as N, readinessKey as Nt, deviceManagerCapability as O, hydrateSchema as Ot, enumerateSchemaFields as P, resolveCapMount as Pt, parseStreamParamsFormPatch as Q, isCollectionArrayMethod as R, buildStreamParamsConfigSchema as S, asNumber as St, coreBlocksCapability as T, emitDownForOwnedCaps as Tt, lifecycleJobSchema as U, isVoidInput as V, localNetworkCapability as W, oauthIntegrationCapability as X, normalizeUnit as Y, objectInputDeclaresAddonId as Z, addonWidgetsCapability as _, DeviceType as _t, CAP_NAMES_WITH_STATUS as a, storageProviderCapability as at, backupCapability as b, WELL_KNOWN_TAB_MAP as bt, DeviceStatusSchema as c, vectorDimFromBase64 as ct, STREAM_PROFILE_META as d, BaseAddon as dt, procedureAuthKey as et, ScopedTokenSchema as f, DATAPLANE_SECRET_HEADER as ft, addonSettingsCapability as g, DeviceRole as gt, addonPagesCapability as h, DeviceFeature as ht, BatteryStatusSchema as i, storageCapability as it, doorbellCapability as j, parseJsonObject as jt, deviceStateCapability as k, isDeviceConfigCap as kt, METHOD_ACCESS_MAP as l, vectorStoreCapability as lt, UserRecordSchema as m, DEVICE_STATUS_METHOD as mt, AlertSchema as n, settingsStoreCapability as nt, CORE_BLOCK_ADDON_PREFIX as o, streamQualityLabel as ot, StorageLocationTypeSchema as p, DEVICE_SETTINGS_CONTRIBUTION_METHODS as pt, looseSchema as q, ApiKeyRecordSchema as r, snapshotCapability as rt, CoreBlockSchema as s, userManagementCapability as st, ALL_CAPABILITY_DEFINITIONS as t, scoreRuntimes as tt, RUNTIME_DEFAULTS as u, errMsg as ut, alertsCapability as v, ReadinessRegistry as vt, coreBlockIdFromAddonId as w, createEvent as wt, bareAddonId as x, asJsonObject as xt, authProviderCapability as y, ReadinessTimeoutError as yt, isObjectInput as z };
32870
+ export { objectInputDeclaresAddonId as $, deviceManagerCapability as A, hydrateSchema as At, isCollectionArrayMethod as B, bareAddonId as C, asJsonObject as Ct, coreBlocksCapability as D, emitDownForOwnedCaps as Dt, coreBlockIdFromAddonId as E, createEvent as Et, enumerateItemArrayFields as F, readinessKey as Ft, lifecycleJobSchema as G, isSameAddonId as H, enumerateSchemaFields as I, resolveCapMount as It, logLevelAtMost as J, localNetworkCapability as K, extractNestedAddonId as L, scopeKey as Lt, deviceStatusCapability as M, nodePin as Mt, doorbellCapability as N, parseJsonObject as Nt, dataStoreProviderCapability as O, emitReadiness as Ot, enumerateInferenceDevices as P, parseJsonUnknown as Pt, oauthIntegrationCapability as Q, filesystemBrowseCapability as R, sleep as Rt, backupCapability as S, WELL_KNOWN_TAB_MAP as St, coreBlockAddonId as T, asString as Tt, isVoidInput as U, isObjectInput as V, kebabToCamel as W, metricsProviderCapability as X, looseSchema as Y, normalizeUnit as Z, addonPagesCapability as _, DeviceFeature as _t, CAP_NAMES_WITH_STATUS as a, snapshotCapability as at, alertsCapability as b, ReadinessRegistry as bt, CoreBlockSchema as c, streamQualityLabel as ct, METHOD_ACCESS_MAP as d, vectorStoreCapability as dt, parseStreamParamsFormPatch as et, RUNTIME_DEFAULTS as f, errMsg as ft, UserRecordSchema as g, DEVICE_STATUS_METHOD as gt, StorageLocationTypeSchema as h, DEVICE_SETTINGS_CONTRIBUTION_METHODS as ht, BatteryStatusSchema as i, settingsStoreCapability as it, deviceStateCapability as j, isDeviceConfigCap as jt, decodeVectorBase64 as k, expandCapMethods as kt, DeclaredDevices as l, userManagementCapability as lt, ScopedTokenSchema as m, DATAPLANE_SECRET_HEADER as mt, AlertSchema as n, procedureAuthKey as nt, CORE_BLOCKS_ADDON_ID as o, storageCapability as ot, STREAM_PROFILE_META as p, BaseAddon as pt, logDestinationCapability as q, ApiKeyRecordSchema as r, scoreRuntimes as rt, CORE_BLOCK_ADDON_PREFIX as s, storageProviderCapability as st, ALL_CAPABILITY_DEFINITIONS as t, platformProbeCapability as tt, DeviceStatusSchema as u, vectorDimFromBase64 as ut, addonSettingsCapability as v, DeviceRole as vt, buildStreamParamsConfigSchema as w, asNumber as wt, authProviderCapability as x, ReadinessTimeoutError as xt, addonWidgetsCapability as y, DeviceType as yt, isArrayOutputSchema as z, EventCategory as zt };