@camstack/addon-provider-amcrest 0.2.10 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +2918 -1382
  2. package/dist/addon.mjs +2918 -1382
  3. package/package.json +1 -1
package/dist/addon.mjs CHANGED
@@ -5,7 +5,7 @@ import { networkInterfaces } from "node:os";
5
5
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
6
6
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
7
7
  //#endregion
8
- //#region ../types/dist/event-category-41fKf-q9.mjs
8
+ //#region ../types/dist/event-category-Cv9dO26A.mjs
9
9
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
10
10
  EventCategory["SystemBoot"] = "system.boot";
11
11
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -21,6 +21,13 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
21
21
  */
22
22
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
23
23
  /**
24
+ * A newer addon or server-root package version was found by the
25
+ * authoritative registry check. Emitted once per
26
+ * `(target, packageName, currentVersion, latestVersion)` transition; repeated
27
+ * polling of the same result is deduplicated by the checker.
28
+ */
29
+ EventCategory["UpdateAvailable"] = "update.available";
30
+ /**
24
31
  * Readiness transition for a capability provider. Every producer emits
25
32
  * this event on `onInitialize` completion, `onDestroy`, and
26
33
  * `$node.reconnect`; every consumer that needs to gate on a cross-process
@@ -6467,7 +6474,20 @@ var BrokerStatsSchema = object({
6467
6474
  sampleRate: number(),
6468
6475
  channels: number(),
6469
6476
  supported: boolean()
6470
- }).nullable().optional()
6477
+ }).nullable().optional(),
6478
+ /**
6479
+ * BROKER-SIDE AUDIO MUTE (D83). `true` = this broker is deliberately
6480
+ * distributing none of the device's audio, on live or recording.
6481
+ *
6482
+ * Present so a silent camera can be told apart from a broken one on the
6483
+ * stream panel itself, without cross-referencing the switch group: a
6484
+ * broker holding an `audio` track descriptor while `audioMuted` is true is
6485
+ * working exactly as asked. `audioMutedDropped` counts the audio units
6486
+ * thrown away since the current dial — it is how you confirm from stats
6487
+ * alone that the mute is on the packet path and not merely persisted.
6488
+ */
6489
+ audioMuted: boolean().optional(),
6490
+ audioMutedDropped: number().optional()
6471
6491
  });
6472
6492
  /**
6473
6493
  * Exporter-facing "profile restream" entry. Returned by
@@ -7081,6 +7101,104 @@ object({
7081
7101
  })
7082
7102
  });
7083
7103
  /**
7104
+ * Adoption job — the background form of `device-adoption.adopt`.
7105
+ *
7106
+ * ## Why this exists
7107
+ *
7108
+ * `adopt({childNativeIds: [...]})` materialises one CamStack device per
7109
+ * candidate PLUS every accessory child, and the whole array shares ONE UDS
7110
+ * request deadline (60s). Measured on the live hub against Home Assistant:
7111
+ * each device the kernel creates costs ~450 ms — `devices.create` pre-seeds
7112
+ * meta with up to eleven SEQUENTIAL round trips (`setName`, `setType`,
7113
+ * `setRole`, … `persistConfig`) before the class is constructed — and an
7114
+ * accessory child costs the same as its parent. So the real unit of work is
7115
+ * the CHILD, not the candidate:
7116
+ *
7117
+ * - 25 candidates averaging 6 children → ~150 devices → **>60s, times out**
7118
+ * - ONE candidate with 217 children → ~217 devices → **>60s, times out**
7119
+ *
7120
+ * That second line is why this is a job and not a smaller batch. No chunking,
7121
+ * no bounded concurrency over candidates and no per-call tuning can fix a
7122
+ * shape where **N=1 already exceeds the deadline** — the count that blows the
7123
+ * budget is the source system's accessory fan-out, which the operator does not
7124
+ * choose and cannot see. A design that only works below some N is the same bug
7125
+ * deferred.
7126
+ *
7127
+ * ## What the timeout did NOT do
7128
+ *
7129
+ * It did not stop the work. The UDS deadline ends the CALLER's wait; the
7130
+ * provider's loop runs to completion. Measured: a 25-candidate adopt that
7131
+ * "failed" at 60s had adopted 17 by 87s and all 25 by ~130s. The operator saw
7132
+ * an error and had no way to learn that. Every field below exists so that
7133
+ * question has an answer.
7134
+ *
7135
+ * ## Idempotency
7136
+ *
7137
+ * Jobs are in-RAM; a restart forgets them. That is safe here because adoption
7138
+ * is keyed by a stable id (`ha:<broker>:dev:<nativeId>` and equivalents), so
7139
+ * re-running a job re-adopts nothing: an already-adopted candidate is SKIPPED
7140
+ * by the engine before any provider call and lands in `alreadyAdopted`. It is
7141
+ * never a duplicate device, and never an error the operator has to interpret.
7142
+ */
7143
+ var AdoptionJobStateSchema = _enum([
7144
+ "running",
7145
+ "done",
7146
+ "failed",
7147
+ "cancelled"
7148
+ ]);
7149
+ /**
7150
+ * Per-candidate result. Every candidate the job was asked to adopt ends in
7151
+ * exactly one of these buckets — there is no silent drop, and the operator can
7152
+ * always answer "which of my 25 landed?".
7153
+ *
7154
+ * - `adopted` — created now by this job.
7155
+ * - `already-adopted` — a device for this candidate existed before the job
7156
+ * reached it (a re-run, or a retry after a timeout). Not an error.
7157
+ * - `failed` — the provider threw; `error` carries the message.
7158
+ * - `cancelled` — the operator cancelled before this candidate was reached.
7159
+ */
7160
+ var AdoptionOutcomeSchema = _enum([
7161
+ "adopted",
7162
+ "already-adopted",
7163
+ "failed",
7164
+ "cancelled"
7165
+ ]);
7166
+ var AdoptionCandidateResultSchema = object({
7167
+ childNativeId: string(),
7168
+ outcome: AdoptionOutcomeSchema,
7169
+ /** The materialised parent device id — null for `failed` / `cancelled`. */
7170
+ parentDeviceId: number().int().nonnegative().nullable(),
7171
+ /** Accessory children created for this candidate. */
7172
+ accessoryCount: number().int().nonnegative(),
7173
+ /** Failure message; null unless `outcome === 'failed'`. */
7174
+ error: string().nullable()
7175
+ });
7176
+ var AdoptionJobSchema = object({
7177
+ jobId: string(),
7178
+ /** The integration provider this job adopts through (the `addonId` pin). */
7179
+ addonId: string(),
7180
+ integrationId: string(),
7181
+ state: AdoptionJobStateSchema,
7182
+ /** Candidates the job was asked to adopt. Known up front, so never null. */
7183
+ total: number().int().nonnegative(),
7184
+ /** Candidates that have reached a terminal bucket. */
7185
+ processed: number().int().nonnegative(),
7186
+ adopted: number().int().nonnegative(),
7187
+ alreadyAdopted: number().int().nonnegative(),
7188
+ failed: number().int().nonnegative(),
7189
+ /** Accessory child devices created across every candidate — the real unit
7190
+ * of work, surfaced so a slow job is legible rather than mysterious. */
7191
+ accessoriesCreated: number().int().nonnegative(),
7192
+ /** The candidate currently being adopted; null when idle or finished. */
7193
+ currentChildNativeId: string().nullable(),
7194
+ /** One entry per candidate, in the order they were processed. */
7195
+ results: array(AdoptionCandidateResultSchema).readonly(),
7196
+ startedAt: number(),
7197
+ finishedAt: number().nullable(),
7198
+ /** Set only when the job itself broke (not a per-candidate failure). */
7199
+ error: string().nullable()
7200
+ });
7201
+ /**
7084
7202
  * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7085
7203
  * pipeline functions an operator thinks in terms of.
7086
7204
  *
@@ -7100,6 +7218,19 @@ object({
7100
7218
  * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7101
7219
  * | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
7102
7220
  * | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
7221
+ * | `broker-audio` | `streamBroker.setDeviceAudioMute` → `DeviceOverride.audioMuted` | `StreamBroker.setAudioMuted` drops the audio plane at the source: no `type:'audio'` packet leaves `fanOutEncoded`, no RTP reaches the restreamer, and the restreamer serves the video-only SDP |
7222
+ *
7223
+ * ## `device-audio` and `broker-audio` are two functions, not two knobs
7224
+ *
7225
+ * They look adjacent and they are not the same control ([D83](../../../../docs/decisions/adr-0083.md)):
7226
+ * `device-audio` writes the CAMERA, so it is hardware privacy — the microphone
7227
+ * genuinely stops, it survives CamStack entirely, and it costs a multi-second
7228
+ * encoder restart on every flip. `broker-audio` writes THIS server, so it is
7229
+ * instant, vendor-independent and reversible without touching the camera, and
7230
+ * a camera that ignores or lacks the ISAPI/Reolink control is still silenced.
7231
+ * D62 forbids a second switch that *disagrees* with the first; these two
7232
+ * cannot disagree, because neither reads the other's store — the camera holds
7233
+ * one, the broker holds the other, and each reports its own fact.
7103
7234
  *
7104
7235
  * ## The two switches whose authority is not on this server
7105
7236
  *
@@ -7161,6 +7292,7 @@ var CameraSwitchIdSchema = _enum([
7161
7292
  "object-detection",
7162
7293
  "privacy-mask",
7163
7294
  "device-audio",
7295
+ "broker-audio",
7164
7296
  "audio-analysis",
7165
7297
  "recording",
7166
7298
  "notifications"
@@ -7186,7 +7318,8 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7186
7318
  object({
7187
7319
  kind: literal("camera-mask"),
7188
7320
  capName: string()
7189
- })
7321
+ }),
7322
+ object({ kind: literal("broker-audio-mute") })
7190
7323
  ]);
7191
7324
  /**
7192
7325
  * Why a switch is not offered for this camera. Rendered instead of the
@@ -7885,408 +8018,1182 @@ var ConvertResultSchema = object({
7885
8018
  })).readonly()
7886
8019
  });
7887
8020
  /**
7888
- * `addon-pages` system-scoped singleton aggregator cap. Public-facing
7889
- * surface that admin-ui consumes through `useAddonPagesListPages()`.
7890
- *
7891
- * The provider iterates every `addon-pages-source` (collection) provider
7892
- * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
7893
- * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
7894
- * filesystem `mtime` cache-buster lets the browser pick up addon
7895
- * rebuilds without manual reload.
7896
- *
7897
- * The hub-local builtin `addon-pages-aggregator` (see
7898
- * `@camstack/system/builtins/addon-pages-aggregator`) registers the
7899
- * provider. Splitting the public aggregator from the raw collection
7900
- * keeps both ends in codegen — there's no hand-written
7901
- * `addon-pages.router.ts` wrapper anymore.
8021
+ * Error types for the safe expression engine. Two distinct classes so callers
8022
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
8023
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
7902
8024
  */
7903
- var AddonPageDeclarationSchema$1 = object({
7904
- id: string(),
7905
- label: string(),
7906
- icon: string(),
7907
- path: string(),
7908
- remoteName: string(),
7909
- bundle: string(),
7910
- section: string().optional(),
7911
- sectionLabel: string().optional()
7912
- });
7913
- var AddonPageInfoSchema = object({
7914
- addonId: string(),
7915
- page: AddonPageDeclarationSchema$1,
7916
- bundleUrl: string()
7917
- });
7918
- method(_void(), array(AddonPageInfoSchema).readonly());
8025
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
8026
+ * the failure is anchored to a character (author-facing inline feedback). */
8027
+ var ExpressionParseError = class extends Error {
8028
+ position;
8029
+ constructor(message, position) {
8030
+ super(message);
8031
+ this.name = "ExpressionParseError";
8032
+ this.position = position;
8033
+ }
8034
+ };
8035
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
8036
+ * result, unknown builtin, step-budget exceeded). */
8037
+ var ExpressionEvalError = class extends Error {
8038
+ constructor(message) {
8039
+ super(message);
8040
+ this.name = "ExpressionEvalError";
8041
+ }
8042
+ };
7919
8043
  /**
7920
- * `addon-pages-source` collection cap exposing per-provider raw page
7921
- * declarations. Every addon that contributes a UI page registers a
7922
- * provider here. The hub-side singleton aggregator (`addon-pages` cap,
7923
- * see `addon-pages.cap.ts`) walks this collection, stamps versioned
7924
- * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
7925
- * that admin-ui consumes.
8044
+ * Frozen, null-prototype builtin function table for the expression engine
8045
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
8046
+ * parser rejects any callee not in it, and the evaluator gates each call on an
8047
+ * own-property check against it.
7926
8048
  *
7927
- * The split exists because the public listing has a different output
7928
- * shape than the per-provider raw declarations, and we want both ends
7929
- * to flow through codegen instead of relying on a hand-written wrapper.
7930
- */
7931
- var AddonPageDeclarationSchema = object({
7932
- id: string(),
7933
- label: string(),
7934
- icon: string(),
7935
- path: string(),
7936
- /**
7937
- * Module Federation remote name — must match the `name` field on the
7938
- * page addon's `federation()` plugin config. Used by admin-ui's
7939
- * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
7940
- * Conventionally `addon_<id>_page` (snake_case; MF names cannot
7941
- * contain hyphens).
7942
- */
7943
- remoteName: string(),
7944
- /**
7945
- * Bundle filename inside the addon's `dist/` dir served at
7946
- * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
7947
- * is always `'remoteEntry.js'`; the value is kept on the metadata so
7948
- * the static-file route can compute an mtime-based cache-buster URL
7949
- * without a separate filesystem stat.
7950
- */
7951
- bundle: string(),
7952
- /**
7953
- * Sidebar section this page docks into. Well-known ids: `'detection'`,
7954
- * `'cluster'`, `'administration'` — the page renders inside that group.
7955
- * Any OTHER string creates (or joins) a custom section rendered after
7956
- * the built-in groups; its label comes from `sectionLabel` (first
7957
- * declaration wins), falling back to the id. Absent → the legacy
7958
- * "Addon Pages" group.
7959
- */
7960
- section: string().optional(),
7961
- /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
7962
- sectionLabel: string().optional()
7963
- });
7964
- method(_void(), array(AddonPageDeclarationSchema).readonly());
7965
- var AddonHttpRouteSchema = object({
7966
- method: _enum([
7967
- "GET",
7968
- "POST",
7969
- "PUT",
7970
- "DELETE",
7971
- "PATCH"
7972
- ]),
7973
- path: string(),
7974
- access: _enum([
7975
- "public",
7976
- "authenticated",
7977
- "admin"
7978
- ]).optional(),
7979
- description: string().optional()
7980
- });
7981
- /**
7982
- * Cross-process route invocation envelope. The hub captures the
7983
- * request as plain data, ships it to the worker via Moleculer, and
7984
- * the worker runs the local handler against a capturing reply. The
7985
- * envelope returned describes what the handler intended (status,
7986
- * headers, body, or a redirect) so the hub can translate it back to
7987
- * the Fastify reply that's actually wired to the socket.
8049
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
8050
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
8051
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
8052
+ * (there is no `Object.prototype` in the chain), so those names are not
8053
+ * callable they are simply "unknown function" at parse time.
8054
+ *
8055
+ * Every numeric argument is validated as a finite number and every numeric
8056
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
8057
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
8058
+ * closed rather than emitting a garbage value.
7988
8059
  */
7989
- var InvokeRequestSchema = object({
7990
- method: string(),
7991
- path: string(),
7992
- params: record(string(), string()),
7993
- query: record(string(), string()),
7994
- body: unknown(),
7995
- headers: record(string(), string()),
7996
- user: object({
7997
- id: string(),
7998
- username: string(),
7999
- isAdmin: boolean()
8000
- }).optional(),
8001
- scopedToken: unknown().optional()
8002
- });
8003
- var InvokeReplyEnvelopeSchema = object({
8004
- status: number().int(),
8005
- headers: record(string(), string()),
8006
- /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
8007
- * sending `body`. Status defaults to 302 when this is set unless
8008
- * the handler called `reply.code(...)` explicitly. */
8009
- redirectUrl: string().nullable(),
8010
- /** JSON-serializable body. `undefined` is treated as "no body". */
8011
- body: unknown().optional(),
8012
- /** Set when the handler called `reply.type(mime)`. */
8013
- contentType: string().optional()
8014
- });
8015
- method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
8016
- var ConfigTabDeclarationSchema = object({
8017
- id: string(),
8018
- label: string(),
8019
- icon: string(),
8020
- order: number().optional()
8021
- });
8022
- var ConfigSectionWithValuesSchema = object({
8023
- id: string(),
8024
- title: string(),
8025
- description: string().optional(),
8026
- style: _enum(["card", "accordion"]).optional(),
8027
- defaultCollapsed: boolean().optional(),
8028
- columns: union([
8029
- literal(1),
8030
- literal(2),
8031
- literal(3),
8032
- literal(4)
8033
- ]).optional(),
8034
- tab: string().optional(),
8035
- location: _enum(["settings", "top-tab"]).optional(),
8036
- order: number().optional(),
8037
- fields: array(any())
8038
- });
8039
- var SettingsSchemaWithValuesSchema = object({
8040
- tabs: array(ConfigTabDeclarationSchema).optional(),
8041
- sections: array(ConfigSectionWithValuesSchema)
8042
- });
8043
- /** Patch object keys are field names, values are the new field values. */
8044
- var SettingsPatchSchema = record(string(), unknown());
8045
- /** Standard success response for update operations. */
8046
- var SettingsUpdateResultSchema = object({ success: literal(true) });
8047
- method(object({
8048
- addonId: string(),
8049
- nodeId: string().optional(),
8050
- overlay: record(string(), unknown()).optional(),
8051
- cap: string().optional()
8052
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8053
- addonId: string(),
8054
- nodeId: string().optional(),
8055
- patch: SettingsPatchSchema
8056
- }), SettingsUpdateResultSchema, {
8057
- kind: "mutation",
8058
- auth: "admin"
8059
- }), method(object({
8060
- addonId: string(),
8061
- deviceId: number(),
8062
- nodeId: string().optional()
8063
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8064
- addonId: string(),
8065
- deviceId: number(),
8066
- nodeId: string().optional(),
8067
- patch: SettingsPatchSchema
8068
- }), SettingsUpdateResultSchema, {
8069
- kind: "mutation",
8070
- auth: "admin"
8071
- });
8060
+ function asFiniteNumber(value, name, index) {
8061
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
8062
+ return value;
8063
+ }
8064
+ function asString$1(value, name, index) {
8065
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
8066
+ return value;
8067
+ }
8068
+ function finiteResult(value, name) {
8069
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
8070
+ return value;
8071
+ }
8072
+ function allFiniteNumbers(args, name) {
8073
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
8074
+ }
8075
+ var INF = Number.POSITIVE_INFINITY;
8076
+ var table = {
8077
+ min: {
8078
+ minArgs: 1,
8079
+ maxArgs: INF,
8080
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
8081
+ },
8082
+ max: {
8083
+ minArgs: 1,
8084
+ maxArgs: INF,
8085
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
8086
+ },
8087
+ abs: {
8088
+ minArgs: 1,
8089
+ maxArgs: 1,
8090
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
8091
+ },
8092
+ floor: {
8093
+ minArgs: 1,
8094
+ maxArgs: 1,
8095
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
8096
+ },
8097
+ ceil: {
8098
+ minArgs: 1,
8099
+ maxArgs: 1,
8100
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
8101
+ },
8102
+ sqrt: {
8103
+ minArgs: 1,
8104
+ maxArgs: 1,
8105
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
8106
+ },
8107
+ round: {
8108
+ minArgs: 1,
8109
+ maxArgs: 2,
8110
+ apply: (args) => {
8111
+ const x = asFiniteNumber(args[0], "round", 0);
8112
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
8113
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
8114
+ const factor = 10 ** digits;
8115
+ return finiteResult(Math.round(x * factor) / factor, "round");
8116
+ }
8117
+ },
8118
+ pow: {
8119
+ minArgs: 2,
8120
+ maxArgs: 2,
8121
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
8122
+ },
8123
+ clamp: {
8124
+ minArgs: 3,
8125
+ maxArgs: 3,
8126
+ apply: (args) => {
8127
+ const x = asFiniteNumber(args[0], "clamp", 0);
8128
+ const lo = asFiniteNumber(args[1], "clamp", 1);
8129
+ const hi = asFiniteNumber(args[2], "clamp", 2);
8130
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
8131
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
8132
+ }
8133
+ },
8134
+ avg: {
8135
+ minArgs: 1,
8136
+ maxArgs: INF,
8137
+ apply: (args) => {
8138
+ const nums = allFiniteNumbers(args, "avg");
8139
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
8140
+ }
8141
+ },
8142
+ sum: {
8143
+ minArgs: 1,
8144
+ maxArgs: INF,
8145
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
8146
+ },
8147
+ coalesce: {
8148
+ minArgs: 1,
8149
+ maxArgs: INF,
8150
+ apply: (args) => {
8151
+ for (const a of args) if (a !== null) return a;
8152
+ return null;
8153
+ }
8154
+ },
8155
+ age: {
8156
+ minArgs: 2,
8157
+ maxArgs: 2,
8158
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
8159
+ },
8160
+ convert: {
8161
+ minArgs: 3,
8162
+ maxArgs: 3,
8163
+ apply: (args, hooks) => {
8164
+ const x = asFiniteNumber(args[0], "convert", 0);
8165
+ const from = asString$1(args[1], "convert", 1).trim();
8166
+ const to = asString$1(args[2], "convert", 2).trim();
8167
+ if (hooks.convert) {
8168
+ const out = hooks.convert(x, from, to);
8169
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
8170
+ return finiteResult(out, "convert");
8171
+ }
8172
+ if (from === to) return x;
8173
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
8174
+ }
8175
+ }
8176
+ };
8177
+ Object.freeze(Object.assign(Object.create(null), table));
8178
+ /** The set of valid builtin names — used by the parser to reject unknown
8179
+ * callees at parse time (immediate author feedback). */
8180
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
8072
8181
  /**
8073
- * `addon-widgets-source` collection cap exposing per-addon raw widget
8074
- * declarations. Mirrors the addon-pages split: every addon shipping
8075
- * widgets registers a provider on this collection cap; the hub-local
8076
- * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
8077
- * collection, stamps versioned `bundleUrl`s onto each declaration, and
8078
- * exposes the public listing surface that admin-ui consumes.
8079
- *
8080
- * The split exists because the public listing has a different output
8081
- * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
8082
- * per-provider raw declarations. Both ends flow through codegen.
8182
+ * Resource-bound constants for the safe expression engine.
8083
8183
  *
8084
- * Unified UI-contribution model (Task 10): a widget descriptor IS a
8085
- * `UiContribution` with `kind:'remote'`. The host renders it through the
8086
- * same `ContributionRenderer` / Module-Federation path as every other
8087
- * contributed UI surface no bespoke widget-rendering path. The widget-
8088
- * only metadata (sizing hints, `requires`) lives as extra fields on the
8089
- * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
8090
- * `kind` / `remote`) carries identity + placement + the MF remote.
8184
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
8185
+ * loops, recursion, lambdas or member access see `ast.ts`), so evaluation is
8186
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
8187
+ * work a single author-supplied expression can request, so a hostile or
8188
+ * accidental pathological string can never spend unbounded CPU/memory.
8091
8189
  */
8092
- /** Where the widget makes sense to render maps to a contribution `tab`. */
8093
- var WidgetHostEnum = _enum([
8094
- "device-tab",
8095
- "dashboard",
8096
- "integration-detail"
8097
- ]);
8098
- var WidgetSizeEnum = _enum([
8099
- "xs",
8100
- "sm",
8101
- "md",
8102
- "lg",
8103
- "xl"
8190
+ /** Max source length (chars) checked BEFORE tokenizing so a huge string is
8191
+ * rejected without allocation. */
8192
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
8193
+ /** A legal binding / identifier name. */
8194
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
8195
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
8196
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
8197
+ var RESERVED_BINDING_NAMES = new Set([
8198
+ "now",
8199
+ "true",
8200
+ "false",
8201
+ "null"
8104
8202
  ]);
8105
8203
  /**
8106
- * MF remote descriptor mirrors `UiContributionRemote` from
8107
- * `capability-definition.ts`. Widget remotes expose a single
8108
- * `'./widgets'` module whose default export is a
8109
- * `Record<componentKey, Component>` map; `componentKey` (the widget
8110
- * `stableId`) picks the entry the host mounts.
8111
- */
8112
- var WidgetRemoteSchema = object({
8113
- remoteName: string(),
8114
- exposedModule: string(),
8115
- componentKey: string().optional()
8116
- });
8117
- /**
8118
- * One widget declaration — a `UiContribution` (`kind:'remote'`) plus
8119
- * widget-only metadata. The `UiContribution` core fields:
8120
- *
8121
- * - `tab` — where the widget hosts. A widget that runs on the
8122
- * dashboard declares `tab:'dashboard'`; a device-tab
8123
- * widget declares the target device-detail tab id.
8124
- * - `subTab` — optional sub-tab within `tab`.
8125
- * - `label` — operator-facing label.
8126
- * - `order` — ordering within `(tab, subTab)`.
8127
- * - `kind` — always `'remote'` for widgets.
8128
- * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
8129
- *
8130
- * Widget-only fields retained alongside the contribution core:
8131
- *
8132
- * - `stableId` — stable identity within the addon (the MF
8133
- * `componentKey`; kept top-level so consumers have
8134
- * a stable key without reaching into `remote`).
8135
- * - `description` / `icon` — picker metadata.
8136
- * - `bundle` — entry filename inside the addon `dist/` dir; the
8137
- * aggregator stamps a versioned `bundleUrl` from it.
8138
- * - `hosts` — every host the widget supports (a widget can run
8139
- * both on the dashboard and a device tab). `tab`
8140
- * is the PRIMARY host; `hosts` is the full set the
8141
- * picker filters on.
8142
- * - `requires` — host-context requirements validated at mount.
8143
- * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
8144
- * — dashboard placement hints.
8204
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
8205
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
8206
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
8207
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
8208
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
8209
+ * is a parse error with a source position, so member access / assignment /
8210
+ * template literals are lexically impossible.
8145
8211
  */
8146
- var WidgetMetadataSchema = object({
8147
- /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
8148
- tab: string(),
8149
- /** Optional sub-tab within `tab`. */
8150
- subTab: string().optional(),
8151
- /** Operator-facing label. */
8152
- label: string(),
8153
- /** Ordering within `(tab, subTab)`, ascending. */
8154
- order: number().optional(),
8155
- /** Always `'remote'` a widget is a Module Federation remote. */
8156
- kind: literal("remote"),
8157
- /** MF remote descriptor. */
8158
- remote: WidgetRemoteSchema,
8159
- /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
8160
- stableId: string(),
8161
- description: string().optional(),
8162
- icon: string().optional(),
8163
- /**
8164
- * Bundle filename inside the addon's `dist/` dir served at
8165
- * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
8166
- * this is always `'remoteEntry.js'` — the value is kept on the
8167
- * metadata so the static-file route can compute an mtime-based
8168
- * cache-buster URL without a separate filesystem stat.
8169
- */
8170
- bundle: string(),
8171
- /** Every host the widget supports. The picker filters on this set. */
8172
- hosts: array(WidgetHostEnum).readonly(),
8173
- /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
8174
- requires: object({
8175
- deviceContext: boolean().default(false),
8176
- integrationContext: boolean().default(false)
8177
- }),
8178
- /**
8179
- * Loadable BEFORE authentication. The normal widget registry listing
8180
- * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
8181
- * (the login page) cannot discover a widget through it. A widget that
8182
- * declares `preAuth: true` marks itself as safe to mount on a pre-auth
8183
- * screen it is surfaced through the PUBLIC `auth.listLoginMethods`
8184
- * login-method contribution channel (see `login-method.cap.ts`) rather
8185
- * than the authenticated registry, and its bundle is served by the
8186
- * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
8187
- */
8188
- preAuth: boolean().optional().default(false),
8189
- /** Dashboard placement HINTS (operator can override per instance). */
8190
- defaultSize: WidgetSizeEnum.default("md"),
8191
- allowedSizes: array(WidgetSizeEnum).readonly().default([
8192
- "sm",
8193
- "md",
8194
- "lg"
8195
- ]),
8196
- defaultColumns: number().int().min(1).max(12).default(6),
8197
- defaultRows: number().int().min(1).max(12).default(1)
8198
- });
8199
- method(_void(), array(WidgetMetadataSchema).readonly());
8212
+ var KEYWORDS = new Set([
8213
+ "true",
8214
+ "false",
8215
+ "null"
8216
+ ]);
8217
+ function isDigit(ch) {
8218
+ return ch >= "0" && ch <= "9";
8219
+ }
8220
+ function isIdentStart(ch) {
8221
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
8222
+ }
8223
+ function isIdentPart(ch) {
8224
+ return isIdentStart(ch) || isDigit(ch);
8225
+ }
8226
+ function isWhitespace(ch) {
8227
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
8228
+ }
8229
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
8230
+ * Throws `ExpressionParseError` on any illegal character or unterminated
8231
+ * string. */
8232
+ function tokenize(source) {
8233
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
8234
+ const tokens = [];
8235
+ let i = 0;
8236
+ const n = source.length;
8237
+ while (i < n) {
8238
+ const ch = source[i];
8239
+ if (isWhitespace(ch)) {
8240
+ i += 1;
8241
+ continue;
8242
+ }
8243
+ if (isDigit(ch)) {
8244
+ const start = i;
8245
+ while (i < n && isDigit(source[i])) i += 1;
8246
+ if (i < n && source[i] === ".") {
8247
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
8248
+ i += 1;
8249
+ while (i < n && isDigit(source[i])) i += 1;
8250
+ }
8251
+ const text = source.slice(start, i);
8252
+ const value = Number(text);
8253
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
8254
+ tokens.push({
8255
+ type: "number",
8256
+ value,
8257
+ pos: start
8258
+ });
8259
+ continue;
8260
+ }
8261
+ if (ch === "'" || ch === "\"") {
8262
+ const quote = ch;
8263
+ const start = i;
8264
+ i += 1;
8265
+ let out = "";
8266
+ let closed = false;
8267
+ while (i < n) {
8268
+ const c = source[i];
8269
+ if (c === "\\") {
8270
+ const next = i + 1 < n ? source[i + 1] : "";
8271
+ if (next === "\\" || next === "'" || next === "\"") {
8272
+ out += next;
8273
+ i += 2;
8274
+ continue;
8275
+ }
8276
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
8277
+ }
8278
+ if (c === quote) {
8279
+ closed = true;
8280
+ i += 1;
8281
+ break;
8282
+ }
8283
+ out += c;
8284
+ i += 1;
8285
+ }
8286
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
8287
+ tokens.push({
8288
+ type: "string",
8289
+ value: out,
8290
+ pos: start
8291
+ });
8292
+ continue;
8293
+ }
8294
+ if (isIdentStart(ch)) {
8295
+ const start = i;
8296
+ while (i < n && isIdentPart(source[i])) i += 1;
8297
+ const text = source.slice(start, i);
8298
+ if (KEYWORDS.has(text)) tokens.push({
8299
+ type: "keyword",
8300
+ keyword: keywordOf(text),
8301
+ pos: start
8302
+ });
8303
+ else tokens.push({
8304
+ type: "identifier",
8305
+ name: text,
8306
+ pos: start
8307
+ });
8308
+ continue;
8309
+ }
8310
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
8311
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
8312
+ tokens.push({
8313
+ type: "punct",
8314
+ punct: two,
8315
+ pos: i
8316
+ });
8317
+ i += 2;
8318
+ continue;
8319
+ }
8320
+ if (isSinglePunct(ch)) {
8321
+ tokens.push({
8322
+ type: "punct",
8323
+ punct: ch,
8324
+ pos: i
8325
+ });
8326
+ i += 1;
8327
+ continue;
8328
+ }
8329
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
8330
+ }
8331
+ tokens.push({
8332
+ type: "eof",
8333
+ pos: n
8334
+ });
8335
+ return tokens;
8336
+ }
8337
+ function keywordOf(text) {
8338
+ if (text === "true") return "true";
8339
+ if (text === "false") return "false";
8340
+ return "null";
8341
+ }
8342
+ function isSinglePunct(ch) {
8343
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
8344
+ }
8200
8345
  /**
8201
- * `addon-widgets` system-scoped singleton aggregator cap. Public-facing
8202
- * surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
8346
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
8203
8347
  *
8204
- * The provider iterates every `addon-widgets-source` (collection)
8205
- * provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
8206
- * `bundleUrl` strings pointing at
8207
- * `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
8208
- * `mtime` cache-buster lets the browser pick up addon rebuilds without
8209
- * manual reload same scheme used by `addon-pages`.
8348
+ * Precedence (low high): ternary `?:` (right-assoc) → `||` → `&&` → equality
8349
+ * relational additive multiplicative → unary `! -` → call / primary.
8350
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
8351
+ * string validated against the builtin table at parse time, so an unknown
8352
+ * function is rejected immediately (author feedback) and a persisted expression
8353
+ * that references a since-removed builtin degrades at read.
8210
8354
  *
8211
- * The hub-local builtin `addon-widgets-aggregator` (see
8212
- * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
8213
- * provider. Splitting the public aggregator from the raw collection
8214
- * keeps both ends in codegen — there's no hand-written wrapper.
8355
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
8356
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) both raise `ExpressionParseError`.
8215
8357
  */
8216
- var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
8217
- addonId: string(),
8218
- bundleUrl: string()
8219
- });
8220
- method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
8358
+ /** Binary/logical operator precedence (higher binds tighter). */
8359
+ var BINARY_PRECEDENCE = {
8360
+ "||": 1,
8361
+ "&&": 2,
8362
+ "==": 3,
8363
+ "!=": 3,
8364
+ "<": 4,
8365
+ "<=": 4,
8366
+ ">": 4,
8367
+ ">=": 4,
8368
+ "+": 5,
8369
+ "-": 5,
8370
+ "*": 6,
8371
+ "/": 6,
8372
+ "%": 6
8373
+ };
8374
+ function isLogicalOp(op) {
8375
+ return op === "&&" || op === "||";
8376
+ }
8377
+ function isBinaryOp(op) {
8378
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
8379
+ }
8380
+ var Parser = class {
8381
+ tokens;
8382
+ pos = 0;
8383
+ nodeCount = 0;
8384
+ identifiers = /* @__PURE__ */ new Set();
8385
+ callees = /* @__PURE__ */ new Set();
8386
+ constructor(tokens) {
8387
+ this.tokens = tokens;
8388
+ }
8389
+ parse() {
8390
+ const ast = this.parseTernary();
8391
+ const tok = this.peek();
8392
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
8393
+ return {
8394
+ ast,
8395
+ identifiers: this.identifiers,
8396
+ callees: this.callees,
8397
+ nodeCount: this.nodeCount
8398
+ };
8399
+ }
8400
+ peek() {
8401
+ return this.tokens[this.pos];
8402
+ }
8403
+ next() {
8404
+ return this.tokens[this.pos++];
8405
+ }
8406
+ /** Consume a punctuator token, erroring if the next token isn't it. */
8407
+ expectPunct(punct) {
8408
+ const tok = this.peek();
8409
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
8410
+ this.pos += 1;
8411
+ }
8412
+ matchPunct(punct) {
8413
+ const tok = this.peek();
8414
+ if (tok.type === "punct" && tok.punct === punct) {
8415
+ this.pos += 1;
8416
+ return true;
8417
+ }
8418
+ return false;
8419
+ }
8420
+ countNode() {
8421
+ this.nodeCount += 1;
8422
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
8423
+ }
8424
+ parseTernary() {
8425
+ const test = this.parseBinary(1);
8426
+ if (this.matchPunct("?")) {
8427
+ const consequent = this.parseTernary();
8428
+ this.expectPunct(":");
8429
+ const alternate = this.parseTernary();
8430
+ this.countNode();
8431
+ return {
8432
+ kind: "conditional",
8433
+ test,
8434
+ consequent,
8435
+ alternate
8436
+ };
8437
+ }
8438
+ return test;
8439
+ }
8440
+ parseBinary(minPrec) {
8441
+ let left = this.parseUnary();
8442
+ for (;;) {
8443
+ const tok = this.peek();
8444
+ if (tok.type !== "punct") break;
8445
+ const prec = BINARY_PRECEDENCE[tok.punct];
8446
+ if (prec === void 0 || prec < minPrec) break;
8447
+ const op = tok.punct;
8448
+ this.pos += 1;
8449
+ const right = this.parseBinary(prec + 1);
8450
+ this.countNode();
8451
+ if (isLogicalOp(op)) left = {
8452
+ kind: "logical",
8453
+ op,
8454
+ left,
8455
+ right
8456
+ };
8457
+ else if (isBinaryOp(op)) left = {
8458
+ kind: "binary",
8459
+ op,
8460
+ left,
8461
+ right
8462
+ };
8463
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
8464
+ }
8465
+ return left;
8466
+ }
8467
+ parseUnary() {
8468
+ const tok = this.peek();
8469
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
8470
+ const op = tok.punct;
8471
+ this.pos += 1;
8472
+ const operand = this.parseUnary();
8473
+ this.countNode();
8474
+ return {
8475
+ kind: "unary",
8476
+ op,
8477
+ operand
8478
+ };
8479
+ }
8480
+ return this.parsePrimary();
8481
+ }
8482
+ parsePrimary() {
8483
+ const tok = this.next();
8484
+ switch (tok.type) {
8485
+ case "number":
8486
+ this.countNode();
8487
+ return {
8488
+ kind: "literal",
8489
+ value: tok.value
8490
+ };
8491
+ case "string":
8492
+ this.countNode();
8493
+ return {
8494
+ kind: "literal",
8495
+ value: tok.value
8496
+ };
8497
+ case "keyword":
8498
+ this.countNode();
8499
+ return {
8500
+ kind: "literal",
8501
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
8502
+ };
8503
+ case "identifier": {
8504
+ const nextTok = this.peek();
8505
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
8506
+ this.identifiers.add(tok.name);
8507
+ this.countNode();
8508
+ return {
8509
+ kind: "identifier",
8510
+ name: tok.name
8511
+ };
8512
+ }
8513
+ case "punct":
8514
+ if (tok.punct === "(") {
8515
+ const inner = this.parseTernary();
8516
+ this.expectPunct(")");
8517
+ return inner;
8518
+ }
8519
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
8520
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
8521
+ }
8522
+ }
8523
+ parseCall(callee, pos) {
8524
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
8525
+ this.expectPunct("(");
8526
+ const args = [];
8527
+ if (!this.matchPunct(")")) for (;;) {
8528
+ args.push(this.parseTernary());
8529
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
8530
+ if (this.matchPunct(",")) continue;
8531
+ this.expectPunct(")");
8532
+ break;
8533
+ }
8534
+ this.callees.add(callee);
8535
+ this.countNode();
8536
+ return {
8537
+ kind: "call",
8538
+ callee,
8539
+ args
8540
+ };
8541
+ }
8542
+ };
8543
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
8544
+ * `ExpressionParseError` on any lexical or grammatical failure. */
8545
+ function parseExpression(source) {
8546
+ return new Parser(tokenize(source)).parse();
8547
+ }
8221
8548
  /**
8222
- * Alerts capability collection-based internal alert system.
8549
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
8550
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
8551
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
8552
+ * one per read on a hot resolve path.
8223
8553
  *
8224
- * Multiple providers can register. Each provider filters by EventBus category
8225
- * and creates/updates alerts. The built-in Alert Center addon persists alerts
8226
- * in the DB and serves them to the admin UI.
8554
+ * The cache is a module-level singleton: entries are pure, content-addressed
8555
+ * ASTs keyed by the raw source string, so sharing one instance across all
8556
+ * callers is safe and maximises hit rate.
8227
8557
  */
8228
- var AlertSeveritySchema = _enum([
8229
- "info",
8230
- "success",
8231
- "warning",
8232
- "error"
8233
- ]);
8234
- var AlertStatusSchema = _enum([
8235
- "active",
8236
- "in-progress",
8237
- "completed",
8238
- "failed",
8239
- "dismissed"
8558
+ var cache = /* @__PURE__ */ new Map();
8559
+ function getCached(source) {
8560
+ const hit = cache.get(source);
8561
+ if (hit !== void 0) {
8562
+ cache.delete(source);
8563
+ cache.set(source, hit);
8564
+ return hit;
8565
+ }
8566
+ let result;
8567
+ try {
8568
+ result = {
8569
+ ok: true,
8570
+ parsed: parseExpression(source)
8571
+ };
8572
+ } catch (err) {
8573
+ result = {
8574
+ ok: false,
8575
+ error: err instanceof ExpressionParseError ? err.message : String(err)
8576
+ };
8577
+ }
8578
+ cache.set(source, result);
8579
+ if (cache.size > 256) {
8580
+ const oldest = cache.keys().next().value;
8581
+ if (oldest !== void 0) cache.delete(oldest);
8582
+ }
8583
+ return result;
8584
+ }
8585
+ /** Compile `source`, returning a discriminated result instead of throwing.
8586
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
8587
+ function compileExpressionSafe(source) {
8588
+ return getCached(source);
8589
+ }
8590
+ Object.freeze({});
8591
+ /**
8592
+ * Author-time validation. Returns `null` when the source is valid, else a
8593
+ * human-readable error message. Checks: the expression compiles; binding count
8594
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
8595
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
8596
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
8597
+ */
8598
+ function validateExpressionSource(src) {
8599
+ const names = Object.keys(src.bindings);
8600
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
8601
+ for (const name of names) {
8602
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
8603
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
8604
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
8605
+ }
8606
+ const compiled = compileExpressionSafe(src.expr);
8607
+ if (!compiled.ok) return compiled.error;
8608
+ const bound = new Set(names);
8609
+ for (const id of compiled.parsed.identifiers) {
8610
+ if (id === "now") continue;
8611
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
8612
+ }
8613
+ return null;
8614
+ }
8615
+ var ExpressionBindingSourceSchema = union([
8616
+ object({
8617
+ kind: literal("field").optional(),
8618
+ sourceKey: string(),
8619
+ cap: string(),
8620
+ fieldPath: string()
8621
+ }),
8622
+ object({
8623
+ kind: literal("literal"),
8624
+ value: union([
8625
+ string(),
8626
+ number(),
8627
+ boolean(),
8628
+ _null()
8629
+ ])
8630
+ }),
8631
+ object({
8632
+ kind: literal("global"),
8633
+ sourceStableId: string(),
8634
+ cap: string(),
8635
+ fieldPath: string()
8636
+ })
8240
8637
  ]);
8241
- var AlertSourceSchema = object({
8242
- type: string(),
8243
- id: string()
8244
- });
8245
- var AlertSchema = object({
8246
- id: string(),
8247
- category: string(),
8248
- severity: AlertSeveritySchema,
8249
- title: string(),
8250
- message: string(),
8251
- status: AlertStatusSchema,
8252
- progress: number().optional(),
8253
- read: boolean(),
8254
- createdAt: number(),
8255
- updatedAt: number(),
8256
- source: AlertSourceSchema.optional(),
8257
- metadata: record(string(), unknown()).optional()
8638
+ object({
8639
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
8640
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
8641
+ }).superRefine((src, ctx) => {
8642
+ const err = validateExpressionSource(src);
8643
+ if (err !== null) ctx.addIssue({
8644
+ code: "custom",
8645
+ message: err,
8646
+ path: ["expr"]
8647
+ });
8258
8648
  });
8259
- method(AlertSchema, _void(), { kind: "mutation" }), method(object({
8260
- alertId: string(),
8261
- patch: AlertSchema.partial()
8262
- }), _void(), { kind: "mutation" }), method(object({
8263
- unreadOnly: boolean().optional(),
8264
- limit: number().optional()
8265
- }).optional(), array(AlertSchema).readonly()), method(_void(), number()), method(object({ alertId: string() }), _void(), { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(object({ alertId: string() }), _void(), { kind: "mutation" });
8266
- DeviceType.Camera, method(object({ deviceId: number() }), custom()), object({
8267
- deviceId: number(),
8268
- rms: number(),
8269
- dbfs: number()
8649
+ /** How a leaf compares a device field to a value. Derived from the field's
8650
+ * `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
8651
+ var AutomationConditionOperatorSchema = _enum([
8652
+ "eq",
8653
+ "ne",
8654
+ "gt",
8655
+ "gte",
8656
+ "lt",
8657
+ "lte",
8658
+ "contains",
8659
+ "in"
8660
+ ]);
8661
+ var AutomationConditionLeafSchema = object({
8662
+ kind: literal("condition"),
8663
+ deviceId: number().int().nonnegative(),
8664
+ cap: string().min(1),
8665
+ fieldPath: string().min(1),
8666
+ operator: AutomationConditionOperatorSchema,
8667
+ value: union([
8668
+ string(),
8669
+ number(),
8670
+ boolean(),
8671
+ array(union([string(), number()]))
8672
+ ])
8270
8673
  });
8271
- /** Shared Zod schemas used across detection capabilities. */
8272
8674
  /**
8273
- * Canonical frame-format enum mirrored on `FrameFormat` in
8274
- * `packages/types/src/types/io.ts`. Kept inline (vs imported) so the
8275
- * Zod runtime schema and TypeScript type stay in sync at the call site
8276
- * adding a new format requires changing both this enum and the
8277
- * `FrameFormat` type alias together.
8675
+ * The expression leaf, declared as a plain object rather than an intersection
8676
+ * with {@link ExpressionSourceSchema}: a discriminated union has to be able to
8677
+ * read `kind` off each option, and an intersection hides it. The author-time
8678
+ * validation is the SAME function `ExpressionSourceSchema` runs, so the two
8679
+ * cannot drift an expression that one accepts, the other accepts.
8278
8680
  */
8279
- var FrameFormatSchema = _enum([
8280
- "jpeg",
8281
- "rgb",
8282
- "bgr",
8283
- "yuv420",
8284
- "gray"
8285
- ]);
8286
- var FrameInputSchema = object({
8287
- data: custom(),
8288
- format: FrameFormatSchema,
8289
- width: number(),
8681
+ var AutomationConditionExpressionSchema = object({
8682
+ kind: literal("expression"),
8683
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
8684
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
8685
+ }).superRefine((src, ctx) => {
8686
+ const err = validateExpressionSource(src);
8687
+ if (err !== null) ctx.addIssue({
8688
+ code: "custom",
8689
+ message: err,
8690
+ path: ["expr"]
8691
+ });
8692
+ });
8693
+ var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
8694
+ object({
8695
+ kind: literal("all"),
8696
+ children: array(AutomationConditionSchema)
8697
+ }),
8698
+ object({
8699
+ kind: literal("any"),
8700
+ children: array(AutomationConditionSchema)
8701
+ }),
8702
+ object({
8703
+ kind: literal("not"),
8704
+ child: AutomationConditionSchema
8705
+ }),
8706
+ AutomationConditionLeafSchema,
8707
+ AutomationConditionExpressionSchema
8708
+ ]));
8709
+ /**
8710
+ * What starts a run.
8711
+ *
8712
+ * D8 compliance, and it is the reason `device-state` is not merely an event
8713
+ * subscription: the trigger evaluates against the **state mirror**, which is
8714
+ * reconciled, and an event only WAKES the evaluation. A dropped event therefore
8715
+ * DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
8716
+ * already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
8717
+ * not.
8718
+ */
8719
+ var AutomationTriggerSchema = discriminatedUnion("kind", [
8720
+ object({
8721
+ kind: literal("device-state"),
8722
+ deviceId: number().int().nonnegative(),
8723
+ cap: string().min(1),
8724
+ fieldPath: string().min(1),
8725
+ /** Fire when the field takes this value. Omit to fire on any change. */
8726
+ becomes: union([
8727
+ string(),
8728
+ number(),
8729
+ boolean()
8730
+ ]).optional(),
8731
+ /** Only on a CHANGE of value, not on every re-report. */
8732
+ edge: boolean().optional(),
8733
+ /** The condition must hold this long before the run starts. */
8734
+ forMs: number().int().min(0).max(864e5).optional(),
8735
+ /** Collapse a burst into one run. */
8736
+ debounceMs: number().int().min(0).max(6e5).optional()
8737
+ }),
8738
+ object({
8739
+ kind: literal("device-event"),
8740
+ /** An `EventCategory` value. */
8741
+ category: string().min(1),
8742
+ deviceId: number().int().nonnegative().optional()
8743
+ }),
8744
+ object({
8745
+ kind: literal("schedule"),
8746
+ cron: string().min(1).max(120)
8747
+ }),
8748
+ object({ kind: literal("manual") })
8749
+ ]);
8750
+ /**
8751
+ * One action step.
8752
+ *
8753
+ * `wait` and `cap` are `NcRuleActionSchema`'s two members, kept structurally
8754
+ * identical so `NcRuleActionRunner` runs them unchanged — its device-scope
8755
+ * check, stop-at-first-failure and per-sequence throttle are the whole reason
8756
+ * to reuse it, and none of them are re-implemented here.
8757
+ *
8758
+ * **The one divergence, and it is forced.** `NcRuleActionSchema.cap.deviceId` is
8759
+ * a literal `z.number().int()`, and the NC runner's own `RunSequencesInput`
8760
+ * documents its subject device as *"for the log tag, never for routing"*. So an
8761
+ * NC action can never target the device that triggered it — which is fine for
8762
+ * the NC (its rules already scope to a device) and fatal for an automation
8763
+ * ("sound the siren of the camera that saw the person"). `deviceId` therefore
8764
+ * also accepts `{ $var }`, resolved from the run's `vars` bag BEFORE the runner
8765
+ * is called. The runner still receives a number and is untouched; the
8766
+ * resolution is the recipe's job, not the runner's.
8767
+ */
8768
+ var AutomationActionSchema = discriminatedUnion("kind", [
8769
+ object({
8770
+ kind: literal("wait"),
8771
+ seconds: number().min(0).max(300)
8772
+ }),
8773
+ object({
8774
+ kind: literal("cap"),
8775
+ deviceId: union([number().int(), object({ $var: string().min(1) })]),
8776
+ cap: string().min(1),
8777
+ method: string().min(1),
8778
+ /** Values may carry `{{vars.x}}` slots, which SUBSTITUTE and do not
8779
+ * evaluate (§3.2.3). Anything beyond substitution is the expression leaf. */
8780
+ args: record(string(), unknown()).optional()
8781
+ }),
8782
+ object({
8783
+ kind: literal("code"),
8784
+ /** Compiled into the automation's OWN block by esbuild — not a third
8785
+ * runtime, not a `vm`, and not dynamically evaluated. */
8786
+ code: string().min(1).max(2e4)
8787
+ })
8788
+ ]);
8789
+ object({
8790
+ triggers: array(AutomationTriggerSchema),
8791
+ conditions: AutomationConditionSchema.optional(),
8792
+ actions: array(AutomationActionSchema)
8793
+ });
8794
+ /**
8795
+ * `addon-pages` — system-scoped singleton aggregator cap. Public-facing
8796
+ * surface that admin-ui consumes through `useAddonPagesListPages()`.
8797
+ *
8798
+ * The provider iterates every `addon-pages-source` (collection) provider
8799
+ * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
8800
+ * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
8801
+ * filesystem `mtime` cache-buster lets the browser pick up addon
8802
+ * rebuilds without manual reload.
8803
+ *
8804
+ * The hub-local builtin `addon-pages-aggregator` (see
8805
+ * `@camstack/system/builtins/addon-pages-aggregator`) registers the
8806
+ * provider. Splitting the public aggregator from the raw collection
8807
+ * keeps both ends in codegen — there's no hand-written
8808
+ * `addon-pages.router.ts` wrapper anymore.
8809
+ */
8810
+ var AddonPageDeclarationSchema$1 = object({
8811
+ id: string(),
8812
+ label: string(),
8813
+ icon: string(),
8814
+ path: string(),
8815
+ remoteName: string(),
8816
+ bundle: string(),
8817
+ section: string().optional(),
8818
+ sectionLabel: string().optional()
8819
+ });
8820
+ var AddonPageInfoSchema = object({
8821
+ addonId: string(),
8822
+ page: AddonPageDeclarationSchema$1,
8823
+ bundleUrl: string()
8824
+ });
8825
+ method(_void(), array(AddonPageInfoSchema).readonly());
8826
+ /**
8827
+ * `addon-pages-source` — collection cap exposing per-provider raw page
8828
+ * declarations. Every addon that contributes a UI page registers a
8829
+ * provider here. The hub-side singleton aggregator (`addon-pages` cap,
8830
+ * see `addon-pages.cap.ts`) walks this collection, stamps versioned
8831
+ * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
8832
+ * that admin-ui consumes.
8833
+ *
8834
+ * The split exists because the public listing has a different output
8835
+ * shape than the per-provider raw declarations, and we want both ends
8836
+ * to flow through codegen instead of relying on a hand-written wrapper.
8837
+ */
8838
+ var AddonPageDeclarationSchema = object({
8839
+ id: string(),
8840
+ label: string(),
8841
+ icon: string(),
8842
+ path: string(),
8843
+ /**
8844
+ * Module Federation remote name — must match the `name` field on the
8845
+ * page addon's `federation()` plugin config. Used by admin-ui's
8846
+ * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
8847
+ * Conventionally `addon_<id>_page` (snake_case; MF names cannot
8848
+ * contain hyphens).
8849
+ */
8850
+ remoteName: string(),
8851
+ /**
8852
+ * Bundle filename inside the addon's `dist/` dir served at
8853
+ * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
8854
+ * is always `'remoteEntry.js'`; the value is kept on the metadata so
8855
+ * the static-file route can compute an mtime-based cache-buster URL
8856
+ * without a separate filesystem stat.
8857
+ */
8858
+ bundle: string(),
8859
+ /**
8860
+ * Sidebar section this page docks into. Well-known ids: `'detection'`,
8861
+ * `'cluster'`, `'administration'` — the page renders inside that group.
8862
+ * Any OTHER string creates (or joins) a custom section rendered after
8863
+ * the built-in groups; its label comes from `sectionLabel` (first
8864
+ * declaration wins), falling back to the id. Absent → the legacy
8865
+ * "Addon Pages" group.
8866
+ */
8867
+ section: string().optional(),
8868
+ /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
8869
+ sectionLabel: string().optional()
8870
+ });
8871
+ method(_void(), array(AddonPageDeclarationSchema).readonly());
8872
+ var AddonHttpRouteSchema = object({
8873
+ method: _enum([
8874
+ "GET",
8875
+ "POST",
8876
+ "PUT",
8877
+ "DELETE",
8878
+ "PATCH"
8879
+ ]),
8880
+ path: string(),
8881
+ access: _enum([
8882
+ "public",
8883
+ "authenticated",
8884
+ "admin"
8885
+ ]).optional(),
8886
+ description: string().optional()
8887
+ });
8888
+ /**
8889
+ * Cross-process route invocation envelope. The hub captures the
8890
+ * request as plain data, ships it to the worker via Moleculer, and
8891
+ * the worker runs the local handler against a capturing reply. The
8892
+ * envelope returned describes what the handler intended (status,
8893
+ * headers, body, or a redirect) so the hub can translate it back to
8894
+ * the Fastify reply that's actually wired to the socket.
8895
+ */
8896
+ var InvokeRequestSchema = object({
8897
+ method: string(),
8898
+ path: string(),
8899
+ params: record(string(), string()),
8900
+ query: record(string(), string()),
8901
+ body: unknown(),
8902
+ headers: record(string(), string()),
8903
+ user: object({
8904
+ id: string(),
8905
+ username: string(),
8906
+ isAdmin: boolean()
8907
+ }).optional(),
8908
+ scopedToken: unknown().optional()
8909
+ });
8910
+ var InvokeReplyEnvelopeSchema = object({
8911
+ status: number().int(),
8912
+ headers: record(string(), string()),
8913
+ /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
8914
+ * sending `body`. Status defaults to 302 when this is set unless
8915
+ * the handler called `reply.code(...)` explicitly. */
8916
+ redirectUrl: string().nullable(),
8917
+ /** JSON-serializable body. `undefined` is treated as "no body". */
8918
+ body: unknown().optional(),
8919
+ /** Set when the handler called `reply.type(mime)`. */
8920
+ contentType: string().optional()
8921
+ });
8922
+ method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
8923
+ var ConfigTabDeclarationSchema = object({
8924
+ id: string(),
8925
+ label: string(),
8926
+ icon: string(),
8927
+ order: number().optional()
8928
+ });
8929
+ var ConfigSectionWithValuesSchema = object({
8930
+ id: string(),
8931
+ title: string(),
8932
+ description: string().optional(),
8933
+ style: _enum(["card", "accordion"]).optional(),
8934
+ defaultCollapsed: boolean().optional(),
8935
+ columns: union([
8936
+ literal(1),
8937
+ literal(2),
8938
+ literal(3),
8939
+ literal(4)
8940
+ ]).optional(),
8941
+ tab: string().optional(),
8942
+ location: _enum(["settings", "top-tab"]).optional(),
8943
+ order: number().optional(),
8944
+ fields: array(any())
8945
+ });
8946
+ var SettingsSchemaWithValuesSchema = object({
8947
+ tabs: array(ConfigTabDeclarationSchema).optional(),
8948
+ sections: array(ConfigSectionWithValuesSchema)
8949
+ });
8950
+ /** Patch object — keys are field names, values are the new field values. */
8951
+ var SettingsPatchSchema = record(string(), unknown());
8952
+ /** Standard success response for update operations. */
8953
+ var SettingsUpdateResultSchema = object({ success: literal(true) });
8954
+ method(object({
8955
+ addonId: string(),
8956
+ nodeId: string().optional(),
8957
+ overlay: record(string(), unknown()).optional(),
8958
+ cap: string().optional()
8959
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8960
+ addonId: string(),
8961
+ nodeId: string().optional(),
8962
+ patch: SettingsPatchSchema
8963
+ }), SettingsUpdateResultSchema, {
8964
+ kind: "mutation",
8965
+ auth: "admin"
8966
+ }), method(object({
8967
+ addonId: string(),
8968
+ deviceId: number(),
8969
+ nodeId: string().optional()
8970
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8971
+ addonId: string(),
8972
+ deviceId: number(),
8973
+ nodeId: string().optional(),
8974
+ patch: SettingsPatchSchema
8975
+ }), SettingsUpdateResultSchema, {
8976
+ kind: "mutation",
8977
+ auth: "admin"
8978
+ });
8979
+ /**
8980
+ * `addon-widgets-source` — collection cap exposing per-addon raw widget
8981
+ * declarations. Mirrors the addon-pages split: every addon shipping
8982
+ * widgets registers a provider on this collection cap; the hub-local
8983
+ * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
8984
+ * collection, stamps versioned `bundleUrl`s onto each declaration, and
8985
+ * exposes the public listing surface that admin-ui consumes.
8986
+ *
8987
+ * The split exists because the public listing has a different output
8988
+ * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
8989
+ * per-provider raw declarations. Both ends flow through codegen.
8990
+ *
8991
+ * Unified UI-contribution model (Task 10): a widget descriptor IS a
8992
+ * `UiContribution` with `kind:'remote'`. The host renders it through the
8993
+ * same `ContributionRenderer` / Module-Federation path as every other
8994
+ * contributed UI surface — no bespoke widget-rendering path. The widget-
8995
+ * only metadata (sizing hints, `requires`) lives as extra fields on the
8996
+ * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
8997
+ * `kind` / `remote`) carries identity + placement + the MF remote.
8998
+ */
8999
+ /** Where the widget makes sense to render — maps to a contribution `tab`. */
9000
+ var WidgetHostEnum = _enum([
9001
+ "device-tab",
9002
+ "dashboard",
9003
+ "integration-detail"
9004
+ ]);
9005
+ var WidgetSizeEnum = _enum([
9006
+ "xs",
9007
+ "sm",
9008
+ "md",
9009
+ "lg",
9010
+ "xl"
9011
+ ]);
9012
+ /**
9013
+ * MF remote descriptor — mirrors `UiContributionRemote` from
9014
+ * `capability-definition.ts`. Widget remotes expose a single
9015
+ * `'./widgets'` module whose default export is a
9016
+ * `Record<componentKey, Component>` map; `componentKey` (the widget
9017
+ * `stableId`) picks the entry the host mounts.
9018
+ */
9019
+ var WidgetRemoteSchema = object({
9020
+ remoteName: string(),
9021
+ exposedModule: string(),
9022
+ componentKey: string().optional()
9023
+ });
9024
+ /**
9025
+ * One widget declaration — a `UiContribution` (`kind:'remote'`) plus
9026
+ * widget-only metadata. The `UiContribution` core fields:
9027
+ *
9028
+ * - `tab` — where the widget hosts. A widget that runs on the
9029
+ * dashboard declares `tab:'dashboard'`; a device-tab
9030
+ * widget declares the target device-detail tab id.
9031
+ * - `subTab` — optional sub-tab within `tab`.
9032
+ * - `label` — operator-facing label.
9033
+ * - `order` — ordering within `(tab, subTab)`.
9034
+ * - `kind` — always `'remote'` for widgets.
9035
+ * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
9036
+ *
9037
+ * Widget-only fields retained alongside the contribution core:
9038
+ *
9039
+ * - `stableId` — stable identity within the addon (the MF
9040
+ * `componentKey`; kept top-level so consumers have
9041
+ * a stable key without reaching into `remote`).
9042
+ * - `description` / `icon` — picker metadata.
9043
+ * - `bundle` — entry filename inside the addon `dist/` dir; the
9044
+ * aggregator stamps a versioned `bundleUrl` from it.
9045
+ * - `hosts` — every host the widget supports (a widget can run
9046
+ * both on the dashboard and a device tab). `tab`
9047
+ * is the PRIMARY host; `hosts` is the full set the
9048
+ * picker filters on.
9049
+ * - `requires` — host-context requirements validated at mount.
9050
+ * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
9051
+ * — dashboard placement hints.
9052
+ */
9053
+ var WidgetMetadataSchema = object({
9054
+ /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
9055
+ tab: string(),
9056
+ /** Optional sub-tab within `tab`. */
9057
+ subTab: string().optional(),
9058
+ /** Operator-facing label. */
9059
+ label: string(),
9060
+ /** Ordering within `(tab, subTab)`, ascending. */
9061
+ order: number().optional(),
9062
+ /** Always `'remote'` — a widget is a Module Federation remote. */
9063
+ kind: literal("remote"),
9064
+ /** MF remote descriptor. */
9065
+ remote: WidgetRemoteSchema,
9066
+ /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
9067
+ stableId: string(),
9068
+ description: string().optional(),
9069
+ icon: string().optional(),
9070
+ /**
9071
+ * Bundle filename inside the addon's `dist/` dir served at
9072
+ * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
9073
+ * this is always `'remoteEntry.js'` — the value is kept on the
9074
+ * metadata so the static-file route can compute an mtime-based
9075
+ * cache-buster URL without a separate filesystem stat.
9076
+ */
9077
+ bundle: string(),
9078
+ /** Every host the widget supports. The picker filters on this set. */
9079
+ hosts: array(WidgetHostEnum).readonly(),
9080
+ /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
9081
+ requires: object({
9082
+ deviceContext: boolean().default(false),
9083
+ integrationContext: boolean().default(false)
9084
+ }),
9085
+ /**
9086
+ * Loadable BEFORE authentication. The normal widget registry listing
9087
+ * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
9088
+ * (the login page) cannot discover a widget through it. A widget that
9089
+ * declares `preAuth: true` marks itself as safe to mount on a pre-auth
9090
+ * screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
9091
+ * login-method contribution channel (see `login-method.cap.ts`) rather
9092
+ * than the authenticated registry, and its bundle is served by the
9093
+ * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
9094
+ */
9095
+ preAuth: boolean().optional().default(false),
9096
+ /** Dashboard placement HINTS (operator can override per instance). */
9097
+ defaultSize: WidgetSizeEnum.default("md"),
9098
+ allowedSizes: array(WidgetSizeEnum).readonly().default([
9099
+ "sm",
9100
+ "md",
9101
+ "lg"
9102
+ ]),
9103
+ defaultColumns: number().int().min(1).max(12).default(6),
9104
+ defaultRows: number().int().min(1).max(12).default(1)
9105
+ });
9106
+ method(_void(), array(WidgetMetadataSchema).readonly());
9107
+ /**
9108
+ * `addon-widgets` — system-scoped singleton aggregator cap. Public-facing
9109
+ * surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
9110
+ *
9111
+ * The provider iterates every `addon-widgets-source` (collection)
9112
+ * provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
9113
+ * `bundleUrl` strings pointing at
9114
+ * `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
9115
+ * `mtime` cache-buster lets the browser pick up addon rebuilds without
9116
+ * manual reload — same scheme used by `addon-pages`.
9117
+ *
9118
+ * The hub-local builtin `addon-widgets-aggregator` (see
9119
+ * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
9120
+ * provider. Splitting the public aggregator from the raw collection
9121
+ * keeps both ends in codegen — there's no hand-written wrapper.
9122
+ */
9123
+ var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
9124
+ addonId: string(),
9125
+ bundleUrl: string()
9126
+ });
9127
+ method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
9128
+ /**
9129
+ * Alerts capability — collection-based internal alert system.
9130
+ *
9131
+ * Multiple providers can register. Each provider filters by EventBus category
9132
+ * and creates/updates alerts. The built-in Alert Center addon persists alerts
9133
+ * in the DB and serves them to the admin UI.
9134
+ */
9135
+ var AlertSeveritySchema = _enum([
9136
+ "info",
9137
+ "success",
9138
+ "warning",
9139
+ "error"
9140
+ ]);
9141
+ var AlertStatusSchema = _enum([
9142
+ "active",
9143
+ "in-progress",
9144
+ "completed",
9145
+ "failed",
9146
+ "dismissed"
9147
+ ]);
9148
+ var AlertSourceSchema = object({
9149
+ type: string(),
9150
+ id: string()
9151
+ });
9152
+ var AlertSchema = object({
9153
+ id: string(),
9154
+ category: string(),
9155
+ severity: AlertSeveritySchema,
9156
+ title: string(),
9157
+ message: string(),
9158
+ status: AlertStatusSchema,
9159
+ progress: number().optional(),
9160
+ read: boolean(),
9161
+ createdAt: number(),
9162
+ updatedAt: number(),
9163
+ source: AlertSourceSchema.optional(),
9164
+ metadata: record(string(), unknown()).optional()
9165
+ });
9166
+ method(AlertSchema, _void(), { kind: "mutation" }), method(object({
9167
+ alertId: string(),
9168
+ patch: AlertSchema.partial()
9169
+ }), _void(), { kind: "mutation" }), method(object({
9170
+ unreadOnly: boolean().optional(),
9171
+ limit: number().optional()
9172
+ }).optional(), array(AlertSchema).readonly()), method(_void(), number()), method(object({ alertId: string() }), _void(), { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(object({ alertId: string() }), _void(), { kind: "mutation" });
9173
+ DeviceType.Camera, method(object({ deviceId: number() }), custom()), object({
9174
+ deviceId: number(),
9175
+ rms: number(),
9176
+ dbfs: number()
9177
+ });
9178
+ /** Shared Zod schemas used across detection capabilities. */
9179
+ /**
9180
+ * Canonical frame-format enum mirrored on `FrameFormat` in
9181
+ * `packages/types/src/types/io.ts`. Kept inline (vs imported) so the
9182
+ * Zod runtime schema and TypeScript type stay in sync at the call site
9183
+ * — adding a new format requires changing both this enum and the
9184
+ * `FrameFormat` type alias together.
9185
+ */
9186
+ var FrameFormatSchema = _enum([
9187
+ "jpeg",
9188
+ "rgb",
9189
+ "bgr",
9190
+ "yuv420",
9191
+ "gray"
9192
+ ]);
9193
+ var FrameInputSchema = object({
9194
+ data: custom(),
9195
+ format: FrameFormatSchema,
9196
+ width: number(),
8290
9197
  height: number(),
8291
9198
  timestamp: number()
8292
9199
  });
@@ -8912,6 +9819,69 @@ var StreamFormatSchema = _enum([
8912
9819
  "mjpeg",
8913
9820
  "rtsp"
8914
9821
  ]);
9822
+ /** A container `produceEventMedia` can emit. */
9823
+ var EventMediaKindSchema = _enum(["mp4", "gif"]);
9824
+ /**
9825
+ * One produced artifact, referenced by HANDLE.
9826
+ *
9827
+ * Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
9828
+ * method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
9829
+ * [D18](../../../../docs/decisions/adr-0018.md) — cross-process media is fetched
9830
+ * on demand, compressed, by handle). `bytes` is here so a caller can decide
9831
+ * whether it wants the fetch at all.
9832
+ */
9833
+ var EventMediaArtifactSchema = object({
9834
+ kind: EventMediaKindSchema,
9835
+ /** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
9836
+ handle: string(),
9837
+ /**
9838
+ * The node holding the bytes — the ROUTING key for `fetchEventMedia`.
9839
+ *
9840
+ * `stream-broker` is a singleton cap and an unpinned call never leaves the
9841
+ * hub, so a handle produced on an agent's broker would be redeemed against
9842
+ * the hub's store and come back `null`. Same contract, same field name and
9843
+ * the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
9844
+ * lives and the consumer pins to it.
9845
+ */
9846
+ nodeId: string(),
9847
+ mime: string(),
9848
+ bytes: number().int(),
9849
+ width: number().int(),
9850
+ height: number().int()
9851
+ });
9852
+ /**
9853
+ * What a production actually covered — the answer to the only question an
9854
+ * operator asks about a notification clip.
9855
+ *
9856
+ * `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
9857
+ * so a caller can state "this clip starts 4.1 s before the event" instead of
9858
+ * inferring it from a duration. A production whose `fromTs` is later than the
9859
+ * event is a production with no pre-roll, and that is exactly the defect this
9860
+ * method exists to make visible rather than plausible.
9861
+ */
9862
+ var EventMediaCoverageSchema = object({
9863
+ fromTs: number(),
9864
+ toTs: number(),
9865
+ /** Encoded packets in the muxed window. */
9866
+ packets: number().int()
9867
+ });
9868
+ /**
9869
+ * The result of ONE cut, in every container the caller asked for.
9870
+ *
9871
+ * Every artifact in `media` came out of the SAME window of the SAME rendition —
9872
+ * that is the whole reason this is one method rather than one call per format.
9873
+ * A consumer attaching a gif and a video can no longer show two different
9874
+ * moments, because it never chose two sources.
9875
+ */
9876
+ var EventMediaProductionSchema = object({
9877
+ media: array(EventMediaArtifactSchema).readonly(),
9878
+ coverage: EventMediaCoverageSchema,
9879
+ /** The rendition actually cut from — what the default or the fallback chose. */
9880
+ profile: CamProfileSchema,
9881
+ /** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
9882
+ * source, a downscale, or a playback rate other than 1). */
9883
+ video: _enum(["copy", "encode"])
9884
+ });
8915
9885
  var RtspRestreamEntrySchema = object({
8916
9886
  brokerId: string(),
8917
9887
  url: string(),
@@ -9311,6 +10281,56 @@ method(object({
9311
10281
  }), {
9312
10282
  kind: "mutation",
9313
10283
  auth: "admin"
10284
+ }), method(object({
10285
+ deviceId: number(),
10286
+ /** Absent = the largest H.264 rendition at or below 1080p, which is
10287
+ * also the one that can be copied. Falls back to whatever the ring
10288
+ * actually retained, and the answer says which. */
10289
+ profile: CamProfileSchema.optional(),
10290
+ aroundMs: number(),
10291
+ preSeconds: number().min(0).max(20).default(4),
10292
+ postSeconds: number().min(0).max(20).default(6),
10293
+ kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
10294
+ /** GIF geometry. The video keeps the source's own. */
10295
+ gifMaxWidth: number().int().min(120).max(1280).default(640),
10296
+ /**
10297
+ * The gif's own PLAYBACK rate in frames per second — what the finished
10298
+ * gif runs at, not how many source frames feed it. The decimation that
10299
+ * feeds it samples `gifFps / gifSpeed` source frames per second, so at
10300
+ * the defaults a 12 fps gif is built out of 3 source frames a second.
10301
+ */
10302
+ gifFps: number().int().min(1).max(15).default(12),
10303
+ /**
10304
+ * How fast the GIF plays against real time, independent of `speed`.
10305
+ *
10306
+ * 4× by default, by operator request: a notification gif is glanced at
10307
+ * on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
10308
+ * a separate knob from `speed` even though both now default to 4 —
10309
+ * a caller wanting a real-time video and a fast gif must not have to
10310
+ * choose.
10311
+ */
10312
+ gifSpeed: number().min(1).max(8).default(4),
10313
+ /**
10314
+ * Playback rate of the VIDEO. Also 4× by default, by operator decision.
10315
+ *
10316
+ * `1` is real time and is the ONLY value that allows the copy branch —
10317
+ * anything else forces `libx264` over the window. That was priced
10318
+ * before it was chosen: a per-event burst measured at 0.23 s and 254 KB
10319
+ * on a real 615 720p cut, against 922 KB for the copy it replaces. A
10320
+ * re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
10321
+ * once the decode is forced the width stops being free.
10322
+ */
10323
+ speed: number().min(1).max(8).default(4)
10324
+ }), EventMediaProductionSchema, {
10325
+ kind: "mutation",
10326
+ auth: "admin"
10327
+ }), method(object({ handle: string() }), object({
10328
+ base64: string(),
10329
+ mime: string(),
10330
+ bytes: number().int()
10331
+ }).nullable(), {
10332
+ kind: "mutation",
10333
+ auth: "admin"
9314
10334
  }), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
9315
10335
  probed: boolean(),
9316
10336
  summary: string()
@@ -9383,7 +10403,25 @@ method(object({
9383
10403
  }), _void(), {
9384
10404
  kind: "mutation",
9385
10405
  auth: "admin"
9386
- }), method(object({ brokerId: string() }), boolean()), object({
10406
+ }), method(object({ brokerId: string() }), boolean()), method(object({ deviceId: number().int() }), object({
10407
+ muted: boolean(),
10408
+ /**
10409
+ * How many live non-derived brokers currently hold the mute. Purely
10410
+ * diagnostic: `muted` is the policy and is authoritative on its own
10411
+ * (it applies to brokers that do not exist yet), while this says
10412
+ * whether anything is presently being silenced.
10413
+ */
10414
+ appliedBrokers: number().int().nonnegative()
10415
+ })), method(object({
10416
+ deviceId: number().int(),
10417
+ muted: boolean()
10418
+ }), object({
10419
+ muted: boolean(),
10420
+ appliedBrokers: number().int().nonnegative()
10421
+ }), {
10422
+ kind: "mutation",
10423
+ auth: "admin"
10424
+ }), object({
9387
10425
  deviceId: number().int().nonnegative(),
9388
10426
  camStreamId: string(),
9389
10427
  profile: CamProfileSchema
@@ -9580,25 +10618,6 @@ var cameraStreamsCapability = {
9580
10618
  lastChangedAt: number()
9581
10619
  })
9582
10620
  };
9583
- /**
9584
- * core-blocks — user-authored TypeScript, stored in the kernel and executed in
9585
- * its own process.
9586
- *
9587
- * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
9588
- *
9589
- * The first use is **owning devices without being a device provider**: a block
9590
- * declares devices under a system or custom integration and drives their state,
9591
- * with the same `ctx` an addon gets. Automations come later; nothing here
9592
- * models a trigger.
9593
- *
9594
- * **Stated plainly, because it does not change by being true:** a block has an
9595
- * addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
9596
- * with no review step. What makes that survivable is not a sandbox, it is
9597
- * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
9598
- * so a block that throws or never returns is marked `failed` and visible
9599
- * instead of taking the hub with it (D6). Every method here is admin-only, and
9600
- * must stay so.
9601
- */
9602
10621
  /** Where a block runs. The operator chooses — a block driving a device on an
9603
10622
  * agent is the reason placement is not fixed to the hub. */
9604
10623
  var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
@@ -9670,6 +10689,9 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
9670
10689
  }), object({ block: CoreBlockSchema }), {
9671
10690
  kind: "mutation",
9672
10691
  auth: "admin"
10692
+ }), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
10693
+ kind: "mutation",
10694
+ auth: "admin"
9673
10695
  }), method(object({ code: string() }), CoreBlockCompileResultSchema, {
9674
10696
  kind: "mutation",
9675
10697
  auth: "admin"
@@ -10206,843 +11228,248 @@ var deviceDiscoveryCapability = {
10206
11228
  }
10207
11229
  };
10208
11230
  /**
10209
- * `device-adoption` — generic discovery + adoption surface,
10210
- * system-scoped singleton. The de-HA-ified successor to `ha-discovery`:
10211
- * keyed by `integrationId` (the addon resolves integrationId → broker
10212
- * internally) rather than a connection-specific `brokerId`, and it
10213
- * REUSES the shared `DiscoveredChildDevice` candidate type from
10214
- * `device-discovery.cap.ts` so a single shared adoption panel renders
10215
- * any integration's candidate tree.
10216
- *
10217
- * UI flow (Integrations page → integration detail):
10218
- * 1. `listCandidates({integrationId, page, pageSize, filter})` →
10219
- * paginated candidate list, each row collapsed.
10220
- * 2. User expands a row → `getCandidate({integrationId,
10221
- * childNativeId})` returns the single candidate (with its nested
10222
- * `children`) for the accordion render.
10223
- * 3. User clicks "Adopt" on one or more candidates →
10224
- * `adopt({integrationId, childNativeIds, perCandidate?})`
10225
- * materialises one parent CamStack device per candidate plus its
10226
- * accessory children. `perCandidate[childNativeId]` carries an
10227
- * optional display-name override + a `hiddenChildIds` pre-hide set
10228
- * (the parent's `accessories.hiddenChildIds` gates UI visibility).
10229
- * 4. User clicks "Release" → `release({integrationId, camDeviceId})`
10230
- * removes the parent + every child from the kernel registry.
10231
- */
10232
- /**
10233
- * Provider-declared discovery *granularity* filter (the keystone of the
10234
- * single-entity-import design). A provider advertises which discovery
10235
- * granularities it supports via `listCandidateFilters`; the operator picks
10236
- * one and it is passed back as the opaque `filter` string on
10237
- * `listCandidates`/`adopt`.
10238
- *
10239
- * - **`id`** is provider-defined and OPAQUE to the cap + UI layers — only the
10240
- * declaring provider interprets it. The reserved id `'devices'` is the
10241
- * universal default (every provider's current behavior IS the `devices`
10242
- * filter). A provider that declares no filters is treated as a single
10243
- * implicit `{ id:'devices', label:'Devices', isDefault:true }`.
10244
- * - **`label`** is the human label for the UI selector.
10245
- * - **`isDefault`** marks the default filter; exactly one SHOULD be default.
10246
- */
10247
- var AdoptionFilterSchema = object({
10248
- id: string(),
10249
- label: string(),
10250
- isDefault: boolean().optional()
10251
- });
10252
- /**
10253
- * Candidate-list TEXT/query filter. Mirrors the prior `ha-discovery` filter,
10254
- * de-HA-ified — `area` / `manufacturer` stay free-form strings so any
10255
- * integration can populate them from its own metadata. Distinct from the
10256
- * granularity `AdoptionFilter` above: this narrows the candidate set within a
10257
- * chosen granularity, whereas the granularity `filter` selects WHAT a
10258
- * candidate is (a device vs an entity).
10259
- */
10260
- var CandidateQueryFilterSchema = object({
10261
- /** Substring filter on name + manufacturer + model. */
10262
- search: string().optional(),
10263
- /** Area-name exact match. */
10264
- area: string().optional(),
10265
- /** Manufacturer exact match. */
10266
- manufacturer: string().optional(),
10267
- /** When true, only return candidates the operator already adopted. */
10268
- adoptedOnly: boolean().optional(),
10269
- /** When true, only return candidates the operator hasn't adopted yet. */
10270
- unadoptedOnly: boolean().optional()
10271
- });
10272
- var ListCandidatesInputSchema = object({
10273
- integrationId: string(),
10274
- page: number().int().positive().default(1),
10275
- pageSize: number().int().positive().max(2e4).default(50),
10276
- /**
10277
- * Optional provider-declared discovery GRANULARITY id (opaque; see
10278
- * `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
10279
- * exactly the pre-existing behavior (fully back-compatible).
10280
- */
10281
- filter: string().optional(),
10282
- /** Optional candidate-list text/query narrowing within the granularity. */
10283
- filterText: CandidateQueryFilterSchema.optional()
10284
- });
10285
- var ListCandidatesOutputSchema = object({
10286
- candidates: array(DiscoveredChildDeviceSchema).readonly(),
10287
- totalCount: number().int().nonnegative(),
10288
- page: number().int().positive(),
10289
- pageSize: number().int().positive()
10290
- });
10291
- var GetCandidateInputSchema = object({
10292
- integrationId: string(),
10293
- childNativeId: string()
10294
- });
10295
- var AdoptionStatusSchema = object({
10296
- /** Last refresh timestamp (ms epoch) — null when never refreshed. */
10297
- lastDiscoveryAt: number().int().nonnegative().nullable(),
10298
- /** Count of candidates in the discovery cache. */
10299
- candidateCount: number().int().nonnegative(),
10300
- /** Count of candidates the operator has already adopted. */
10301
- adoptedCount: number().int().nonnegative(),
10302
- /** Last error message from a refresh attempt. */
10303
- lastError: string().nullable()
10304
- });
10305
- var PerCandidateSchema = object({
10306
- /** Override the default display name for this candidate's parent. */
10307
- name: string().min(1).optional(),
10308
- /** Pre-hide a subset of entity-children — created (state still flows)
10309
- * but listed in the parent's `accessories.hiddenChildIds`. */
10310
- hiddenChildIds: array(string()).optional()
10311
- });
10312
- var AdoptInputSchema = object({
10313
- integrationId: string(),
10314
- /**
10315
- * Candidate native ids to adopt. Their MEANING is filter-relative: under the
10316
- * default `'devices'` granularity these are device-native ids; under a
10317
- * provider-declared granularity (e.g. HA `'entities'`) they are that
10318
- * granularity's native ids (e.g. entity ids). The field name is kept stable
10319
- * to avoid a breaking rename.
10320
- */
10321
- childNativeIds: array(string()).min(1),
10322
- /**
10323
- * Optional provider-declared discovery GRANULARITY id (opaque; see
10324
- * `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
10325
- * exactly the pre-existing behavior (fully back-compatible).
10326
- */
10327
- filter: string().optional(),
10328
- /** When true, import each adopted device's source-system location (e.g. HA
10329
- * area) into CamStack — fuzzy-match an existing location or create it, then
10330
- * assign. Omitted/false = no location work (back-compat). */
10331
- importLocations: boolean().optional(),
10332
- perCandidate: record(string(), PerCandidateSchema).optional()
10333
- });
10334
- var AdoptResultSchema = object({ adopted: array(object({
10335
- childNativeId: string(),
10336
- parentDeviceId: number().int().nonnegative(),
10337
- accessoryDeviceIds: array(number().int().nonnegative()).readonly()
10338
- })).readonly() });
10339
- var ReleaseInputSchema = object({
10340
- integrationId: string(),
10341
- /** Parent CamStack device id (NOT an accessory child id). Removing
10342
- * the parent cascades into every accessory. */
10343
- camDeviceId: number().int().nonnegative()
10344
- });
10345
- var ResyncInputSchema = object({
10346
- /** Parent CamStack device id of an adopted device. The provider resolves its
10347
- * source (integration/broker + native id) and re-aligns the device's
10348
- * structural spec (type/role/capabilities/units) with the live mapping,
10349
- * rebuilding any child whose class changed while preserving operator edits. */
10350
- camDeviceId: number().int().nonnegative(),
10351
- /** "Resync from zero" (#19). When true, the kernel PURGES every accessory
10352
- * child of `camDeviceId` BEFORE the provider re-derives the device, so the
10353
- * children are rebuilt fresh from source — correct names, coords, and units —
10354
- * instead of being preserved by the incremental reconcile. Use to recover from
10355
- * legacy generic/placeholder names that the normal name-precedence keeps frozen
10356
- * (the operator's explicit reset). Push-driven integrations (no-op resync)
10357
- * rebuild on their next snapshot; pull/command integrations rebuild in `resync`.
10358
- * Operator edits on the PARENT (its name, layout, primary-child pick) survive —
10359
- * only the children are torn down. Omitted/false ⇒ the normal incremental
10360
- * re-sync that preserves children. */
10361
- resetToSource: boolean().optional()
10362
- });
10363
- var ResyncResultSchema = object({
10364
- /** True when the persisted spec actually changed (children may have been rebuilt). */
10365
- changed: boolean(),
10366
- /** Number of child devices rebuilt into a new class by this re-sync. */
10367
- rebuiltChildren: number().int().nonnegative(),
10368
- /** Number of accessory children torn down by a `resetToSource` purge before the
10369
- * provider re-derived the device. 0/absent for a normal incremental re-sync. */
10370
- removedChildren: number().int().nonnegative().optional()
10371
- });
10372
- method(object({ integrationId: string() }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }), method(ListCandidatesInputSchema, ListCandidatesOutputSchema, { auth: "admin" }), method(GetCandidateInputSchema, DiscoveredChildDeviceSchema.nullable(), { auth: "admin" }), method(object({ integrationId: string() }), AdoptionStatusSchema, {
10373
- kind: "mutation",
10374
- auth: "admin"
10375
- }), method(AdoptInputSchema, AdoptResultSchema, {
10376
- kind: "mutation",
10377
- auth: "admin"
10378
- }), method(ReleaseInputSchema, _void(), {
10379
- kind: "mutation",
10380
- auth: "admin"
10381
- }), method(ResyncInputSchema, ResyncResultSchema, {
10382
- kind: "mutation",
10383
- auth: "admin"
10384
- });
10385
- /**
10386
- * `device-export` — collection cap for addons that export camstack
10387
- * devices to external ecosystems (HomeAssistant via MQTT discovery,
10388
- * HomeKit/HAP, Alexa Smart Home, …).
10389
- *
10390
- * No `ecosystem` enum — the addon id identifies the export target.
10391
- * Each addon owns its mapping logic in its own settings UI. The cap
10392
- * exposes only the COMMON surface: link state, supported device
10393
- * kinds (so the UI can filter the device picker), and per-device
10394
- * expose/unexpose.
10395
- */
10396
- var LinkStateSchema = _enum([
10397
- "unlinked",
10398
- "linked",
10399
- "error"
10400
- ]);
10401
- /**
10402
- * A single label/value row surfaced in the export panel's "Setup"
10403
- * section. Rendered with a copy-to-clipboard button. `secret: true`
10404
- * rows are masked by default with a reveal toggle (client ids,
10405
- * secrets, …).
10406
- */
10407
- var ExportSetupFieldSchema = object({
10408
- label: string(),
10409
- value: string(),
10410
- /** Mask the value by default + render a reveal toggle (client id, secrets). */
10411
- secret: boolean().optional()
10412
- });
10413
- /**
10414
- * Generic, addon-agnostic pairing/account block. Any export addon can
10415
- * surface a scannable QR, a set of copyable label/value rows, and a
10416
- * free-form operator note — the `DeviceExportPanel` renders whatever
10417
- * the provider supplies and skips the section entirely when `setup`
10418
- * is unset.
10419
- */
10420
- var ExportSetupSchema = object({
10421
- /** A string to render as a scannable QR — HAP `X-HM://…` URI, a pairing URL, etc. Omitted when there's nothing to scan. */
10422
- qr: string().optional(),
10423
- /** Label/value rows shown with a copy button (HAP setup code, OAuth URLs, client id, linked-account count, …). */
10424
- fields: array(ExportSetupFieldSchema).readonly().optional(),
10425
- /** Free-form operator instructions rendered above the fields. */
10426
- note: string().optional()
10427
- });
10428
- var DeviceExportStatusSchema = object({
10429
- linkState: LinkStateSchema,
10430
- exposedDeviceCount: number(),
10431
- error: string().optional(),
10432
- /**
10433
- * Optional pairing/account info the panel renders in a generic
10434
- * "Setup" section. Addon-agnostic — the addon id identifies the
10435
- * export target, never an `ecosystem` key here.
10436
- */
10437
- setup: ExportSetupSchema.optional()
10438
- });
10439
- var DeviceKindSchema = string();
10440
- var ExposedDeviceSchema = object({
10441
- deviceId: string(),
10442
- exposedAs: string().optional(),
10443
- capabilities: array(string()).optional()
10444
- });
10445
- var ExposeInputSchema = object({
10446
- deviceId: string(),
10447
- capabilities: array(string()).optional()
10448
- });
10449
- var UnexposeInputSchema = object({ deviceId: string() });
10450
- method(_void(), DeviceExportStatusSchema), method(_void(), array(DeviceKindSchema)), method(_void(), array(ExposedDeviceSchema)), method(ExposeInputSchema, _void(), { kind: "mutation" }), method(UnexposeInputSchema, _void(), { kind: "mutation" });
10451
- /**
10452
- * Resource-bound constants for the safe expression engine.
10453
- *
10454
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
10455
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
10456
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
10457
- * work a single author-supplied expression can request, so a hostile or
10458
- * accidental pathological string can never spend unbounded CPU/memory.
10459
- */
10460
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
10461
- * rejected without allocation. */
10462
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
10463
- /** A legal binding / identifier name. */
10464
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
10465
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
10466
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
10467
- var RESERVED_BINDING_NAMES = new Set([
10468
- "now",
10469
- "true",
10470
- "false",
10471
- "null"
10472
- ]);
10473
- /**
10474
- * Error types for the safe expression engine. Two distinct classes so callers
10475
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
10476
- * failure — both are non-fatal to the host: read paths degrade to "skip link".
10477
- */
10478
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
10479
- * the failure is anchored to a character (author-facing inline feedback). */
10480
- var ExpressionParseError = class extends Error {
10481
- position;
10482
- constructor(message, position) {
10483
- super(message);
10484
- this.name = "ExpressionParseError";
10485
- this.position = position;
10486
- }
10487
- };
10488
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
10489
- * result, unknown builtin, step-budget exceeded). */
10490
- var ExpressionEvalError = class extends Error {
10491
- constructor(message) {
10492
- super(message);
10493
- this.name = "ExpressionEvalError";
10494
- }
10495
- };
10496
- /**
10497
- * Frozen, null-prototype builtin function table for the expression engine
10498
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
10499
- * parser rejects any callee not in it, and the evaluator gates each call on an
10500
- * own-property check against it.
10501
- *
10502
- * Because the object has a NULL prototype AND is `Object.freeze`d:
10503
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
10504
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
10505
- * (there is no `Object.prototype` in the chain), so those names are not
10506
- * callable — they are simply "unknown function" at parse time.
10507
- *
10508
- * Every numeric argument is validated as a finite number and every numeric
10509
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
10510
- * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
10511
- * closed rather than emitting a garbage value.
10512
- */
10513
- function asFiniteNumber(value, name, index) {
10514
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
10515
- return value;
10516
- }
10517
- function asString$1(value, name, index) {
10518
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
10519
- return value;
10520
- }
10521
- function finiteResult(value, name) {
10522
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
10523
- return value;
10524
- }
10525
- function allFiniteNumbers(args, name) {
10526
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
10527
- }
10528
- var INF = Number.POSITIVE_INFINITY;
10529
- var table = {
10530
- min: {
10531
- minArgs: 1,
10532
- maxArgs: INF,
10533
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
10534
- },
10535
- max: {
10536
- minArgs: 1,
10537
- maxArgs: INF,
10538
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
10539
- },
10540
- abs: {
10541
- minArgs: 1,
10542
- maxArgs: 1,
10543
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
10544
- },
10545
- floor: {
10546
- minArgs: 1,
10547
- maxArgs: 1,
10548
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
10549
- },
10550
- ceil: {
10551
- minArgs: 1,
10552
- maxArgs: 1,
10553
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
10554
- },
10555
- sqrt: {
10556
- minArgs: 1,
10557
- maxArgs: 1,
10558
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
10559
- },
10560
- round: {
10561
- minArgs: 1,
10562
- maxArgs: 2,
10563
- apply: (args) => {
10564
- const x = asFiniteNumber(args[0], "round", 0);
10565
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
10566
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
10567
- const factor = 10 ** digits;
10568
- return finiteResult(Math.round(x * factor) / factor, "round");
10569
- }
10570
- },
10571
- pow: {
10572
- minArgs: 2,
10573
- maxArgs: 2,
10574
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
10575
- },
10576
- clamp: {
10577
- minArgs: 3,
10578
- maxArgs: 3,
10579
- apply: (args) => {
10580
- const x = asFiniteNumber(args[0], "clamp", 0);
10581
- const lo = asFiniteNumber(args[1], "clamp", 1);
10582
- const hi = asFiniteNumber(args[2], "clamp", 2);
10583
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
10584
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
10585
- }
10586
- },
10587
- avg: {
10588
- minArgs: 1,
10589
- maxArgs: INF,
10590
- apply: (args) => {
10591
- const nums = allFiniteNumbers(args, "avg");
10592
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
10593
- }
10594
- },
10595
- sum: {
10596
- minArgs: 1,
10597
- maxArgs: INF,
10598
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
10599
- },
10600
- coalesce: {
10601
- minArgs: 1,
10602
- maxArgs: INF,
10603
- apply: (args) => {
10604
- for (const a of args) if (a !== null) return a;
10605
- return null;
10606
- }
10607
- },
10608
- age: {
10609
- minArgs: 2,
10610
- maxArgs: 2,
10611
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
10612
- },
10613
- convert: {
10614
- minArgs: 3,
10615
- maxArgs: 3,
10616
- apply: (args, hooks) => {
10617
- const x = asFiniteNumber(args[0], "convert", 0);
10618
- const from = asString$1(args[1], "convert", 1).trim();
10619
- const to = asString$1(args[2], "convert", 2).trim();
10620
- if (hooks.convert) {
10621
- const out = hooks.convert(x, from, to);
10622
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
10623
- return finiteResult(out, "convert");
10624
- }
10625
- if (from === to) return x;
10626
- throw new ExpressionEvalError("convert: unit conversion table not installed");
10627
- }
10628
- }
10629
- };
10630
- Object.freeze(Object.assign(Object.create(null), table));
10631
- /** The set of valid builtin names — used by the parser to reject unknown
10632
- * callees at parse time (immediate author feedback). */
10633
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
10634
- /**
10635
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
10636
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
10637
- * single/double-quoted strings with a tiny escape set, identifiers, the three
10638
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
10639
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
10640
- * is a parse error with a source position, so member access / assignment /
10641
- * template literals are lexically impossible.
10642
- */
10643
- var KEYWORDS = new Set([
10644
- "true",
10645
- "false",
10646
- "null"
10647
- ]);
10648
- function isDigit(ch) {
10649
- return ch >= "0" && ch <= "9";
10650
- }
10651
- function isIdentStart(ch) {
10652
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
10653
- }
10654
- function isIdentPart(ch) {
10655
- return isIdentStart(ch) || isDigit(ch);
10656
- }
10657
- function isWhitespace(ch) {
10658
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
10659
- }
10660
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
10661
- * Throws `ExpressionParseError` on any illegal character or unterminated
10662
- * string. */
10663
- function tokenize(source) {
10664
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
10665
- const tokens = [];
10666
- let i = 0;
10667
- const n = source.length;
10668
- while (i < n) {
10669
- const ch = source[i];
10670
- if (isWhitespace(ch)) {
10671
- i += 1;
10672
- continue;
10673
- }
10674
- if (isDigit(ch)) {
10675
- const start = i;
10676
- while (i < n && isDigit(source[i])) i += 1;
10677
- if (i < n && source[i] === ".") {
10678
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
10679
- i += 1;
10680
- while (i < n && isDigit(source[i])) i += 1;
10681
- }
10682
- const text = source.slice(start, i);
10683
- const value = Number(text);
10684
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
10685
- tokens.push({
10686
- type: "number",
10687
- value,
10688
- pos: start
10689
- });
10690
- continue;
10691
- }
10692
- if (ch === "'" || ch === "\"") {
10693
- const quote = ch;
10694
- const start = i;
10695
- i += 1;
10696
- let out = "";
10697
- let closed = false;
10698
- while (i < n) {
10699
- const c = source[i];
10700
- if (c === "\\") {
10701
- const next = i + 1 < n ? source[i + 1] : "";
10702
- if (next === "\\" || next === "'" || next === "\"") {
10703
- out += next;
10704
- i += 2;
10705
- continue;
10706
- }
10707
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
10708
- }
10709
- if (c === quote) {
10710
- closed = true;
10711
- i += 1;
10712
- break;
10713
- }
10714
- out += c;
10715
- i += 1;
10716
- }
10717
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
10718
- tokens.push({
10719
- type: "string",
10720
- value: out,
10721
- pos: start
10722
- });
10723
- continue;
10724
- }
10725
- if (isIdentStart(ch)) {
10726
- const start = i;
10727
- while (i < n && isIdentPart(source[i])) i += 1;
10728
- const text = source.slice(start, i);
10729
- if (KEYWORDS.has(text)) tokens.push({
10730
- type: "keyword",
10731
- keyword: keywordOf(text),
10732
- pos: start
10733
- });
10734
- else tokens.push({
10735
- type: "identifier",
10736
- name: text,
10737
- pos: start
10738
- });
10739
- continue;
10740
- }
10741
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
10742
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
10743
- tokens.push({
10744
- type: "punct",
10745
- punct: two,
10746
- pos: i
10747
- });
10748
- i += 2;
10749
- continue;
10750
- }
10751
- if (isSinglePunct(ch)) {
10752
- tokens.push({
10753
- type: "punct",
10754
- punct: ch,
10755
- pos: i
10756
- });
10757
- i += 1;
10758
- continue;
10759
- }
10760
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
10761
- }
10762
- tokens.push({
10763
- type: "eof",
10764
- pos: n
10765
- });
10766
- return tokens;
10767
- }
10768
- function keywordOf(text) {
10769
- if (text === "true") return "true";
10770
- if (text === "false") return "false";
10771
- return "null";
10772
- }
10773
- function isSinglePunct(ch) {
10774
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
10775
- }
10776
- /**
10777
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
10778
- *
10779
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
10780
- * → relational → additive → multiplicative → unary `! -` → call / primary.
10781
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
10782
- * string validated against the builtin table at parse time, so an unknown
10783
- * function is rejected immediately (author feedback) and a persisted expression
10784
- * that references a since-removed builtin degrades at read.
10785
- *
10786
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
10787
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
10788
- */
10789
- /** Binary/logical operator precedence (higher binds tighter). */
10790
- var BINARY_PRECEDENCE = {
10791
- "||": 1,
10792
- "&&": 2,
10793
- "==": 3,
10794
- "!=": 3,
10795
- "<": 4,
10796
- "<=": 4,
10797
- ">": 4,
10798
- ">=": 4,
10799
- "+": 5,
10800
- "-": 5,
10801
- "*": 6,
10802
- "/": 6,
10803
- "%": 6
10804
- };
10805
- function isLogicalOp(op) {
10806
- return op === "&&" || op === "||";
10807
- }
10808
- function isBinaryOp(op) {
10809
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
10810
- }
10811
- var Parser = class {
10812
- tokens;
10813
- pos = 0;
10814
- nodeCount = 0;
10815
- identifiers = /* @__PURE__ */ new Set();
10816
- callees = /* @__PURE__ */ new Set();
10817
- constructor(tokens) {
10818
- this.tokens = tokens;
10819
- }
10820
- parse() {
10821
- const ast = this.parseTernary();
10822
- const tok = this.peek();
10823
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
10824
- return {
10825
- ast,
10826
- identifiers: this.identifiers,
10827
- callees: this.callees,
10828
- nodeCount: this.nodeCount
10829
- };
10830
- }
10831
- peek() {
10832
- return this.tokens[this.pos];
10833
- }
10834
- next() {
10835
- return this.tokens[this.pos++];
10836
- }
10837
- /** Consume a punctuator token, erroring if the next token isn't it. */
10838
- expectPunct(punct) {
10839
- const tok = this.peek();
10840
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
10841
- this.pos += 1;
10842
- }
10843
- matchPunct(punct) {
10844
- const tok = this.peek();
10845
- if (tok.type === "punct" && tok.punct === punct) {
10846
- this.pos += 1;
10847
- return true;
10848
- }
10849
- return false;
10850
- }
10851
- countNode() {
10852
- this.nodeCount += 1;
10853
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
10854
- }
10855
- parseTernary() {
10856
- const test = this.parseBinary(1);
10857
- if (this.matchPunct("?")) {
10858
- const consequent = this.parseTernary();
10859
- this.expectPunct(":");
10860
- const alternate = this.parseTernary();
10861
- this.countNode();
10862
- return {
10863
- kind: "conditional",
10864
- test,
10865
- consequent,
10866
- alternate
10867
- };
10868
- }
10869
- return test;
10870
- }
10871
- parseBinary(minPrec) {
10872
- let left = this.parseUnary();
10873
- for (;;) {
10874
- const tok = this.peek();
10875
- if (tok.type !== "punct") break;
10876
- const prec = BINARY_PRECEDENCE[tok.punct];
10877
- if (prec === void 0 || prec < minPrec) break;
10878
- const op = tok.punct;
10879
- this.pos += 1;
10880
- const right = this.parseBinary(prec + 1);
10881
- this.countNode();
10882
- if (isLogicalOp(op)) left = {
10883
- kind: "logical",
10884
- op,
10885
- left,
10886
- right
10887
- };
10888
- else if (isBinaryOp(op)) left = {
10889
- kind: "binary",
10890
- op,
10891
- left,
10892
- right
10893
- };
10894
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
10895
- }
10896
- return left;
10897
- }
10898
- parseUnary() {
10899
- const tok = this.peek();
10900
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
10901
- const op = tok.punct;
10902
- this.pos += 1;
10903
- const operand = this.parseUnary();
10904
- this.countNode();
10905
- return {
10906
- kind: "unary",
10907
- op,
10908
- operand
10909
- };
10910
- }
10911
- return this.parsePrimary();
10912
- }
10913
- parsePrimary() {
10914
- const tok = this.next();
10915
- switch (tok.type) {
10916
- case "number":
10917
- this.countNode();
10918
- return {
10919
- kind: "literal",
10920
- value: tok.value
10921
- };
10922
- case "string":
10923
- this.countNode();
10924
- return {
10925
- kind: "literal",
10926
- value: tok.value
10927
- };
10928
- case "keyword":
10929
- this.countNode();
10930
- return {
10931
- kind: "literal",
10932
- value: tok.keyword === "null" ? null : tok.keyword === "true"
10933
- };
10934
- case "identifier": {
10935
- const nextTok = this.peek();
10936
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
10937
- this.identifiers.add(tok.name);
10938
- this.countNode();
10939
- return {
10940
- kind: "identifier",
10941
- name: tok.name
10942
- };
10943
- }
10944
- case "punct":
10945
- if (tok.punct === "(") {
10946
- const inner = this.parseTernary();
10947
- this.expectPunct(")");
10948
- return inner;
10949
- }
10950
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
10951
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
10952
- }
10953
- }
10954
- parseCall(callee, pos) {
10955
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
10956
- this.expectPunct("(");
10957
- const args = [];
10958
- if (!this.matchPunct(")")) for (;;) {
10959
- args.push(this.parseTernary());
10960
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
10961
- if (this.matchPunct(",")) continue;
10962
- this.expectPunct(")");
10963
- break;
10964
- }
10965
- this.callees.add(callee);
10966
- this.countNode();
10967
- return {
10968
- kind: "call",
10969
- callee,
10970
- args
10971
- };
10972
- }
10973
- };
10974
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
10975
- * `ExpressionParseError` on any lexical or grammatical failure. */
10976
- function parseExpression(source) {
10977
- return new Parser(tokenize(source)).parse();
10978
- }
11231
+ * `device-adoption` — generic discovery + adoption surface,
11232
+ * system-scoped singleton. The de-HA-ified successor to `ha-discovery`:
11233
+ * keyed by `integrationId` (the addon resolves integrationId → broker
11234
+ * internally) rather than a connection-specific `brokerId`, and it
11235
+ * REUSES the shared `DiscoveredChildDevice` candidate type from
11236
+ * `device-discovery.cap.ts` so a single shared adoption panel renders
11237
+ * any integration's candidate tree.
11238
+ *
11239
+ * UI flow (Integrations page → integration detail):
11240
+ * 1. `listCandidates({integrationId, page, pageSize, filter})` →
11241
+ * paginated candidate list, each row collapsed.
11242
+ * 2. User expands a row → `getCandidate({integrationId,
11243
+ * childNativeId})` returns the single candidate (with its nested
11244
+ * `children`) for the accordion render.
11245
+ * 3. User clicks "Adopt" on one or more candidates →
11246
+ * `adopt({integrationId, childNativeIds, perCandidate?})`
11247
+ * materialises one parent CamStack device per candidate plus its
11248
+ * accessory children. `perCandidate[childNativeId]` carries an
11249
+ * optional display-name override + a `hiddenChildIds` pre-hide set
11250
+ * (the parent's `accessories.hiddenChildIds` gates UI visibility).
11251
+ * 4. User clicks "Release" → `release({integrationId, camDeviceId})`
11252
+ * removes the parent + every child from the kernel registry.
11253
+ */
10979
11254
  /**
10980
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
10981
- * by expr"). The cache stores BOTH successes and failures (negative caching),
10982
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
10983
- * one per read on a hot resolve path.
11255
+ * Provider-declared discovery *granularity* filter (the keystone of the
11256
+ * single-entity-import design). A provider advertises which discovery
11257
+ * granularities it supports via `listCandidateFilters`; the operator picks
11258
+ * one and it is passed back as the opaque `filter` string on
11259
+ * `listCandidates`/`adopt`.
10984
11260
  *
10985
- * The cache is a module-level singleton: entries are pure, content-addressed
10986
- * ASTs keyed by the raw source string, so sharing one instance across all
10987
- * callers is safe and maximises hit rate.
11261
+ * - **`id`** is provider-defined and OPAQUE to the cap + UI layers — only the
11262
+ * declaring provider interprets it. The reserved id `'devices'` is the
11263
+ * universal default (every provider's current behavior IS the `devices`
11264
+ * filter). A provider that declares no filters is treated as a single
11265
+ * implicit `{ id:'devices', label:'Devices', isDefault:true }`.
11266
+ * - **`label`** is the human label for the UI selector.
11267
+ * - **`isDefault`** marks the default filter; exactly one SHOULD be default.
10988
11268
  */
10989
- var cache = /* @__PURE__ */ new Map();
10990
- function getCached(source) {
10991
- const hit = cache.get(source);
10992
- if (hit !== void 0) {
10993
- cache.delete(source);
10994
- cache.set(source, hit);
10995
- return hit;
10996
- }
10997
- let result;
10998
- try {
10999
- result = {
11000
- ok: true,
11001
- parsed: parseExpression(source)
11002
- };
11003
- } catch (err) {
11004
- result = {
11005
- ok: false,
11006
- error: err instanceof ExpressionParseError ? err.message : String(err)
11007
- };
11008
- }
11009
- cache.set(source, result);
11010
- if (cache.size > 256) {
11011
- const oldest = cache.keys().next().value;
11012
- if (oldest !== void 0) cache.delete(oldest);
11013
- }
11014
- return result;
11015
- }
11016
- /** Compile `source`, returning a discriminated result instead of throwing.
11017
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
11018
- function compileExpressionSafe(source) {
11019
- return getCached(source);
11020
- }
11021
- Object.freeze({});
11269
+ var AdoptionFilterSchema = object({
11270
+ id: string(),
11271
+ label: string(),
11272
+ isDefault: boolean().optional()
11273
+ });
11022
11274
  /**
11023
- * Author-time validation. Returns `null` when the source is valid, else a
11024
- * human-readable error message. Checks: the expression compiles; binding count
11025
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
11026
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
11027
- * FREE identifier of the AST is covered by a binding or the injected `now`.
11275
+ * Candidate-list TEXT/query filter. Mirrors the prior `ha-discovery` filter,
11276
+ * de-HA-ified `area` / `manufacturer` stay free-form strings so any
11277
+ * integration can populate them from its own metadata. Distinct from the
11278
+ * granularity `AdoptionFilter` above: this narrows the candidate set within a
11279
+ * chosen granularity, whereas the granularity `filter` selects WHAT a
11280
+ * candidate is (a device vs an entity).
11028
11281
  */
11029
- function validateExpressionSource(src) {
11030
- const names = Object.keys(src.bindings);
11031
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
11032
- for (const name of names) {
11033
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
11034
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
11035
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
11036
- }
11037
- const compiled = compileExpressionSafe(src.expr);
11038
- if (!compiled.ok) return compiled.error;
11039
- const bound = new Set(names);
11040
- for (const id of compiled.parsed.identifiers) {
11041
- if (id === "now") continue;
11042
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
11043
- }
11044
- return null;
11045
- }
11282
+ var CandidateQueryFilterSchema = object({
11283
+ /** Substring filter on name + manufacturer + model. */
11284
+ search: string().optional(),
11285
+ /** Area-name exact match. */
11286
+ area: string().optional(),
11287
+ /** Manufacturer exact match. */
11288
+ manufacturer: string().optional(),
11289
+ /** When true, only return candidates the operator already adopted. */
11290
+ adoptedOnly: boolean().optional(),
11291
+ /** When true, only return candidates the operator hasn't adopted yet. */
11292
+ unadoptedOnly: boolean().optional()
11293
+ });
11294
+ var ListCandidatesInputSchema = object({
11295
+ integrationId: string(),
11296
+ page: number().int().positive().default(1),
11297
+ pageSize: number().int().positive().max(2e4).default(50),
11298
+ /**
11299
+ * Optional provider-declared discovery GRANULARITY id (opaque; see
11300
+ * `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
11301
+ * exactly the pre-existing behavior (fully back-compatible).
11302
+ */
11303
+ filter: string().optional(),
11304
+ /** Optional candidate-list text/query narrowing within the granularity. */
11305
+ filterText: CandidateQueryFilterSchema.optional()
11306
+ });
11307
+ var ListCandidatesOutputSchema = object({
11308
+ candidates: array(DiscoveredChildDeviceSchema).readonly(),
11309
+ totalCount: number().int().nonnegative(),
11310
+ page: number().int().positive(),
11311
+ pageSize: number().int().positive()
11312
+ });
11313
+ var GetCandidateInputSchema = object({
11314
+ integrationId: string(),
11315
+ childNativeId: string()
11316
+ });
11317
+ var AdoptionStatusSchema = object({
11318
+ /** Last refresh timestamp (ms epoch) — null when never refreshed. */
11319
+ lastDiscoveryAt: number().int().nonnegative().nullable(),
11320
+ /** Count of candidates in the discovery cache. */
11321
+ candidateCount: number().int().nonnegative(),
11322
+ /** Count of candidates the operator has already adopted. */
11323
+ adoptedCount: number().int().nonnegative(),
11324
+ /** Last error message from a refresh attempt. */
11325
+ lastError: string().nullable()
11326
+ });
11327
+ var PerCandidateSchema = object({
11328
+ /** Override the default display name for this candidate's parent. */
11329
+ name: string().min(1).optional(),
11330
+ /** Pre-hide a subset of entity-children — created (state still flows)
11331
+ * but listed in the parent's `accessories.hiddenChildIds`. */
11332
+ hiddenChildIds: array(string()).optional()
11333
+ });
11334
+ var AdoptInputSchema = object({
11335
+ integrationId: string(),
11336
+ /**
11337
+ * Candidate native ids to adopt. Their MEANING is filter-relative: under the
11338
+ * default `'devices'` granularity these are device-native ids; under a
11339
+ * provider-declared granularity (e.g. HA `'entities'`) they are that
11340
+ * granularity's native ids (e.g. entity ids). The field name is kept stable
11341
+ * to avoid a breaking rename.
11342
+ */
11343
+ childNativeIds: array(string()).min(1),
11344
+ /**
11345
+ * Optional provider-declared discovery GRANULARITY id (opaque; see
11346
+ * `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
11347
+ * exactly the pre-existing behavior (fully back-compatible).
11348
+ */
11349
+ filter: string().optional(),
11350
+ /** When true, import each adopted device's source-system location (e.g. HA
11351
+ * area) into CamStack — fuzzy-match an existing location or create it, then
11352
+ * assign. Omitted/false = no location work (back-compat). */
11353
+ importLocations: boolean().optional(),
11354
+ perCandidate: record(string(), PerCandidateSchema).optional()
11355
+ });
11356
+ var AdoptResultSchema = object({ adopted: array(object({
11357
+ childNativeId: string(),
11358
+ parentDeviceId: number().int().nonnegative(),
11359
+ accessoryDeviceIds: array(number().int().nonnegative()).readonly()
11360
+ })).readonly() });
11361
+ var ReleaseInputSchema = object({
11362
+ integrationId: string(),
11363
+ /** Parent CamStack device id (NOT an accessory child id). Removing
11364
+ * the parent cascades into every accessory. */
11365
+ camDeviceId: number().int().nonnegative()
11366
+ });
11367
+ var ResyncInputSchema = object({
11368
+ /** Parent CamStack device id of an adopted device. The provider resolves its
11369
+ * source (integration/broker + native id) and re-aligns the device's
11370
+ * structural spec (type/role/capabilities/units) with the live mapping,
11371
+ * rebuilding any child whose class changed while preserving operator edits. */
11372
+ camDeviceId: number().int().nonnegative(),
11373
+ /** "Resync from zero" (#19). When true, the kernel PURGES every accessory
11374
+ * child of `camDeviceId` BEFORE the provider re-derives the device, so the
11375
+ * children are rebuilt fresh from source — correct names, coords, and units —
11376
+ * instead of being preserved by the incremental reconcile. Use to recover from
11377
+ * legacy generic/placeholder names that the normal name-precedence keeps frozen
11378
+ * (the operator's explicit reset). Push-driven integrations (no-op resync)
11379
+ * rebuild on their next snapshot; pull/command integrations rebuild in `resync`.
11380
+ * Operator edits on the PARENT (its name, layout, primary-child pick) survive —
11381
+ * only the children are torn down. Omitted/false ⇒ the normal incremental
11382
+ * re-sync that preserves children. */
11383
+ resetToSource: boolean().optional()
11384
+ });
11385
+ var ResyncResultSchema = object({
11386
+ /** True when the persisted spec actually changed (children may have been rebuilt). */
11387
+ changed: boolean(),
11388
+ /** Number of child devices rebuilt into a new class by this re-sync. */
11389
+ rebuiltChildren: number().int().nonnegative(),
11390
+ /** Number of accessory children torn down by a `resetToSource` purge before the
11391
+ * provider re-derived the device. 0/absent for a normal incremental re-sync. */
11392
+ removedChildren: number().int().nonnegative().optional()
11393
+ });
11394
+ method(object({ integrationId: string() }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }), method(ListCandidatesInputSchema, ListCandidatesOutputSchema, { auth: "admin" }), method(GetCandidateInputSchema, DiscoveredChildDeviceSchema.nullable(), { auth: "admin" }), method(object({ integrationId: string() }), AdoptionStatusSchema, {
11395
+ kind: "mutation",
11396
+ auth: "admin"
11397
+ }), method(AdoptInputSchema, AdoptResultSchema, {
11398
+ kind: "mutation",
11399
+ auth: "admin"
11400
+ }), method(ReleaseInputSchema, _void(), {
11401
+ kind: "mutation",
11402
+ auth: "admin"
11403
+ }), method(ResyncInputSchema, ResyncResultSchema, {
11404
+ kind: "mutation",
11405
+ auth: "admin"
11406
+ });
11407
+ /**
11408
+ * `device-export` — collection cap for addons that export camstack
11409
+ * devices to external ecosystems (HomeAssistant via MQTT discovery,
11410
+ * HomeKit/HAP, Alexa Smart Home, …).
11411
+ *
11412
+ * No `ecosystem` enum — the addon id identifies the export target.
11413
+ * Each addon owns its mapping logic in its own settings UI. The cap
11414
+ * exposes only the COMMON surface: link state, supported device
11415
+ * kinds (so the UI can filter the device picker), and per-device
11416
+ * expose/unexpose.
11417
+ */
11418
+ var LinkStateSchema = _enum([
11419
+ "unlinked",
11420
+ "linked",
11421
+ "error"
11422
+ ]);
11423
+ /**
11424
+ * A single label/value row surfaced in the export panel's "Setup"
11425
+ * section. Rendered with a copy-to-clipboard button. `secret: true`
11426
+ * rows are masked by default with a reveal toggle (client ids,
11427
+ * secrets, …).
11428
+ */
11429
+ var ExportSetupFieldSchema = object({
11430
+ label: string(),
11431
+ value: string(),
11432
+ /** Mask the value by default + render a reveal toggle (client id, secrets). */
11433
+ secret: boolean().optional()
11434
+ });
11435
+ /**
11436
+ * Generic, addon-agnostic pairing/account block. Any export addon can
11437
+ * surface a scannable QR, a set of copyable label/value rows, and a
11438
+ * free-form operator note — the `DeviceExportPanel` renders whatever
11439
+ * the provider supplies and skips the section entirely when `setup`
11440
+ * is unset.
11441
+ */
11442
+ var ExportSetupSchema = object({
11443
+ /** A string to render as a scannable QR — HAP `X-HM://…` URI, a pairing URL, etc. Omitted when there's nothing to scan. */
11444
+ qr: string().optional(),
11445
+ /** Label/value rows shown with a copy button (HAP setup code, OAuth URLs, client id, linked-account count, …). */
11446
+ fields: array(ExportSetupFieldSchema).readonly().optional(),
11447
+ /** Free-form operator instructions rendered above the fields. */
11448
+ note: string().optional()
11449
+ });
11450
+ var DeviceExportStatusSchema = object({
11451
+ linkState: LinkStateSchema,
11452
+ exposedDeviceCount: number(),
11453
+ error: string().optional(),
11454
+ /**
11455
+ * Optional pairing/account info the panel renders in a generic
11456
+ * "Setup" section. Addon-agnostic — the addon id identifies the
11457
+ * export target, never an `ecosystem` key here.
11458
+ */
11459
+ setup: ExportSetupSchema.optional()
11460
+ });
11461
+ var DeviceKindSchema = string();
11462
+ var ExposedDeviceSchema = object({
11463
+ deviceId: string(),
11464
+ exposedAs: string().optional(),
11465
+ capabilities: array(string()).optional()
11466
+ });
11467
+ var ExposeInputSchema = object({
11468
+ deviceId: string(),
11469
+ capabilities: array(string()).optional()
11470
+ });
11471
+ var UnexposeInputSchema = object({ deviceId: string() });
11472
+ method(_void(), DeviceExportStatusSchema), method(_void(), array(DeviceKindSchema)), method(_void(), array(ExposedDeviceSchema)), method(ExposeInputSchema, _void(), { kind: "mutation" }), method(UnexposeInputSchema, _void(), { kind: "mutation" });
11046
11473
  var ProviderStatusSchema = object({
11047
11474
  connected: boolean(),
11048
11475
  deviceCount: number(),
@@ -11222,92 +11649,6 @@ var ChildLayoutEntrySchema = object({
11222
11649
  order: number().optional(),
11223
11650
  collapsed: boolean().optional()
11224
11651
  });
11225
- /** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
11226
- * `device-management.ts`. Source is a union: a FIELD source copies a sibling
11227
- * accessory's status field (`kind` optional/absent for wire compat); a
11228
- * LITERAL source carries a per-device constant (no sibling is read); a
11229
- * GLOBAL source (P2e) copies ANY device's status field, addressed by the
11230
- * source device's full re-sync-stable `stableId`. */
11231
- var DeviceLinkFieldSourceSchema = object({
11232
- kind: literal("field").optional(),
11233
- sourceKey: string(),
11234
- cap: string(),
11235
- fieldPath: string()
11236
- });
11237
- var DeviceLinkLiteralSourceSchema = object({
11238
- kind: literal("literal"),
11239
- value: union([
11240
- string(),
11241
- number(),
11242
- boolean(),
11243
- _null()
11244
- ])
11245
- });
11246
- var DeviceLinkGlobalSourceSchema = object({
11247
- kind: literal("global"),
11248
- sourceStableId: string(),
11249
- cap: string(),
11250
- fieldPath: string()
11251
- });
11252
- /** Expression source (Stage X): compute the target field from N named bindings
11253
- * via the safe expression engine. Bindings are field | literal | global — never
11254
- * another expression (no nesting). The `superRefine` runs the SAME author-time
11255
- * validation as `validateExpressionSource` (compiles the expr, checks binding
11256
- * names + identifier coverage) so every wire boundary that parses a DeviceLink
11257
- * (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
11258
- * Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
11259
- var DeviceLinkExpressionSourceSchema = object({
11260
- kind: literal("expression"),
11261
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
11262
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), union([
11263
- DeviceLinkFieldSourceSchema,
11264
- DeviceLinkLiteralSourceSchema,
11265
- DeviceLinkGlobalSourceSchema
11266
- ]))
11267
- }).superRefine((src, ctx) => {
11268
- const err = validateExpressionSource(src);
11269
- if (err !== null) ctx.addIssue({
11270
- code: "custom",
11271
- message: err,
11272
- path: ["expr"]
11273
- });
11274
- });
11275
- var DeviceLinkSchema = object({
11276
- id: string(),
11277
- source: union([
11278
- DeviceLinkFieldSourceSchema,
11279
- DeviceLinkLiteralSourceSchema,
11280
- DeviceLinkGlobalSourceSchema,
11281
- DeviceLinkExpressionSourceSchema
11282
- ]),
11283
- target: object({
11284
- cap: string(),
11285
- fieldPath: string(),
11286
- itemKey: string().optional()
11287
- }),
11288
- transform: discriminatedUnion("kind", [
11289
- object({ kind: literal("identity") }),
11290
- object({
11291
- kind: literal("enum-map"),
11292
- mapping: record(string(), union([
11293
- string(),
11294
- number(),
11295
- boolean()
11296
- ])),
11297
- fallback: union([
11298
- string(),
11299
- number(),
11300
- boolean()
11301
- ]).optional()
11302
- }),
11303
- object({
11304
- kind: literal("linear"),
11305
- scale: number(),
11306
- offset: number(),
11307
- clamp: tuple([number(), number()]).readonly().optional()
11308
- })
11309
- ]).optional()
11310
- });
11311
11652
  /** Cap-wire shape of a per-cap display refinement — mirrors
11312
11653
  * `DeviceCapDisplayOverride` in `device-management.ts`. */
11313
11654
  var DeviceCapDisplayOverrideSchema = object({
@@ -11387,8 +11728,6 @@ var DeviceInfoSchema = object({
11387
11728
  * named accordion sections (with optional intra-section order). See
11388
11729
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
11389
11730
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
11390
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
11391
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
11392
11731
  /** Operator-authored per-device display override. See `DeviceMeta.display`. */
11393
11732
  display: DeviceDisplayOverrideSchema.optional()
11394
11733
  });
@@ -11397,7 +11736,7 @@ var ConfigEntrySchema = object({
11397
11736
  value: unknown(),
11398
11737
  description: string().optional()
11399
11738
  });
11400
- var DeviceLinkModeSchema = _enum(["auto", "manual"]);
11739
+ var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
11401
11740
  /** One resolved linked device — the compact projection consumers need. */
11402
11741
  var LinkedDeviceSchema = object({
11403
11742
  deviceId: number(),
@@ -11460,8 +11799,6 @@ var DeviceMetaSchema = object({
11460
11799
  * accordion sections (with optional intra-section order). See
11461
11800
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
11462
11801
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
11463
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
11464
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
11465
11802
  /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
11466
11803
  * Optional: only present for accessory children that carry a known role. */
11467
11804
  role: string().nullable().optional(),
@@ -11554,12 +11891,6 @@ method(object({
11554
11891
  }), _void(), {
11555
11892
  kind: "mutation",
11556
11893
  auth: "admin"
11557
- }), method(object({
11558
- deviceId: number(),
11559
- deviceLinks: array(DeviceLinkSchema).readonly()
11560
- }), _void(), {
11561
- kind: "mutation",
11562
- auth: "admin"
11563
11894
  }), method(object({
11564
11895
  deviceId: number(),
11565
11896
  display: DeviceDisplayOverrideSchema.nullable()
@@ -11641,7 +11972,7 @@ method(object({
11641
11972
  * shipping 293 rows to find 12. */
11642
11973
  isCamera: boolean().optional()
11643
11974
  }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
11644
- mode: DeviceLinkModeSchema,
11975
+ mode: LinkedDevicesModeSchema,
11645
11976
  devices: array(LinkedDeviceSchema)
11646
11977
  })), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
11647
11978
  deviceId: number(),
@@ -11674,11 +12005,7 @@ method(object({
11674
12005
  deviceId: number(),
11675
12006
  entries: array(object({
11676
12007
  capName: string(),
11677
- kind: _enum([
11678
- "native",
11679
- "wrapped",
11680
- "linked"
11681
- ]),
12008
+ kind: _enum(["native", "wrapped"]),
11682
12009
  providerAddonId: string(),
11683
12010
  providerNodeId: string(),
11684
12011
  nativeAddonId: string()
@@ -11687,11 +12014,7 @@ method(object({
11687
12014
  deviceId: number(),
11688
12015
  entries: array(object({
11689
12016
  capName: string(),
11690
- kind: _enum([
11691
- "native",
11692
- "wrapped",
11693
- "linked"
11694
- ]),
12017
+ kind: _enum(["native", "wrapped"]),
11695
12018
  providerAddonId: string(),
11696
12019
  providerNodeId: string(),
11697
12020
  nativeAddonId: string()
@@ -11792,6 +12115,15 @@ method(object({
11792
12115
  }), method(ReleaseInputSchema.extend({ addonId: string() }), _void(), {
11793
12116
  kind: "mutation",
11794
12117
  auth: "admin"
12118
+ }), method(AdoptInputSchema.extend({ addonId: string() }), object({ jobId: string() }), {
12119
+ kind: "mutation",
12120
+ auth: "admin"
12121
+ }), method(object({
12122
+ addonId: string(),
12123
+ integrationId: string().optional()
12124
+ }), array(AdoptionJobSchema).readonly(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
12125
+ kind: "mutation",
12126
+ auth: "admin"
11795
12127
  }), method(ResyncInputSchema, ResyncResultSchema, {
11796
12128
  kind: "mutation",
11797
12129
  auth: "admin"
@@ -12494,7 +12826,7 @@ var MotionAnalysisResultSchema = object({
12494
12826
  frameHeight: number(),
12495
12827
  analysisMs: number()
12496
12828
  });
12497
- method(object({
12829
+ DeviceType.Camera, method(object({
12498
12830
  deviceId: number(),
12499
12831
  frame: FrameInputSchema.optional(),
12500
12832
  frameHandle: FrameHandleSchema.optional()
@@ -12514,7 +12846,7 @@ method(object({
12514
12846
  * Why: pub/sub routing over the system event-bus loses fidelity
12515
12847
  * (callback shape, QoS guarantees, will/retain semantics) and adds
12516
12848
  * refcount bookkeeping that addons would rather own themselves. The
12517
- * canonical consumer (`addon-export-ha-mqtt`) needs raw `mqtt.js`
12849
+ * canonical consumer needs raw `mqtt.js`
12518
12850
  * features anyway — give it the connection config, get out of the way.
12519
12851
  *
12520
12852
  * Consumer flow:
@@ -13707,8 +14039,35 @@ var NcDeliverySchema = _enum([
13707
14039
  "immediate",
13708
14040
  "track-end",
13709
14041
  "device-event",
13710
- "package-event"
14042
+ "package-event",
14043
+ "system-event"
13711
14044
  ]);
14045
+ /**
14046
+ * Stable Notification Center vocabulary over infrastructure/liveness events.
14047
+ * Bus categories are normalized into these intent-level kinds so rules do not
14048
+ * depend on a provider's raw event name or payload shape.
14049
+ */
14050
+ var NcSystemEventKindSchema = _enum([
14051
+ "camera-online",
14052
+ "camera-offline",
14053
+ "stream-online",
14054
+ "stream-offline",
14055
+ "node-online",
14056
+ "node-offline",
14057
+ "addon-update-available",
14058
+ "server-update-available"
14059
+ ]);
14060
+ /**
14061
+ * One coherent system-event condition. `kinds` is the required opt-in safety
14062
+ * gate; the remaining lists are optional narrowing filters relevant to the
14063
+ * selected kinds.
14064
+ */
14065
+ var NcSystemEventConditionSchema = object({
14066
+ kinds: array(NcSystemEventKindSchema).min(1),
14067
+ deviceIds: array(number().int()).min(1).optional(),
14068
+ nodeIds: array(string().min(1)).min(1).optional(),
14069
+ packageNames: array(string().min(1)).min(1).optional()
14070
+ });
13712
14071
  /** Weekly schedule — OR of windows; absence on the rule = always active. */
13713
14072
  var NcScheduleSchema = object({
13714
14073
  windows: array(object({
@@ -14013,6 +14372,8 @@ var NcConditionsSchema = object({
14013
14372
  "picked-up",
14014
14373
  "both"
14015
14374
  ]).optional(),
14375
+ /** Infrastructure/liveness/update event matcher (`system-event` delivery). */
14376
+ systemEvent: NcSystemEventConditionSchema.optional(),
14016
14377
  /**
14017
14378
  * PERSONAL-RULE custom zones (viewer-drawn). Inline normalized polygons
14018
14379
  * (MaskShape vocabulary). A record passes when its bbox overlaps ANY
@@ -14232,7 +14593,8 @@ var NcTestResultSchema = object({
14232
14593
  "object-event",
14233
14594
  "track",
14234
14595
  "device-event",
14235
- "package-event"
14596
+ "package-event",
14597
+ "system-event"
14236
14598
  ]),
14237
14599
  deviceId: number(),
14238
14600
  timestamp: number(),
@@ -14254,7 +14616,8 @@ var NcConditionDescriptorSchema = object({
14254
14616
  "schedule",
14255
14617
  "device",
14256
14618
  "package",
14257
- "occupancy"
14619
+ "occupancy",
14620
+ "system"
14258
14621
  ]),
14259
14622
  label: string(),
14260
14623
  /** Editor widget the UI renders — never hardcode per-condition forms. */
@@ -14272,7 +14635,8 @@ var NcConditionDescriptorSchema = object({
14272
14635
  "crossingSelect",
14273
14636
  "polygonDraw",
14274
14637
  "occupancy",
14275
- "deviceState"
14638
+ "deviceState",
14639
+ "systemEvent"
14276
14640
  ]),
14277
14641
  operator: _enum([
14278
14642
  "in",
@@ -14331,7 +14695,8 @@ var NcHistoryRecordKindSchema = _enum([
14331
14695
  "object-event",
14332
14696
  "track-end",
14333
14697
  "device-event",
14334
- "package-event"
14698
+ "package-event",
14699
+ "system-event"
14335
14700
  ]);
14336
14701
  /** Subject summary frozen on the row at fire time (survives rule/record edits). */
14337
14702
  var NcHistorySubjectSchema = object({
@@ -14339,7 +14704,14 @@ var NcHistorySubjectSchema = object({
14339
14704
  label: string().optional(),
14340
14705
  confidence: number().optional(),
14341
14706
  zones: array(string()),
14342
- timestamp: number()
14707
+ timestamp: number(),
14708
+ systemEvent: object({
14709
+ kind: NcSystemEventKindSchema,
14710
+ subject: string(),
14711
+ title: string(),
14712
+ body: string(),
14713
+ data: record(string(), unknown())
14714
+ }).optional()
14343
14715
  });
14344
14716
  /**
14345
14717
  * One delivery-history row. This is a read-only VIEW over the durable
@@ -14699,6 +15071,76 @@ object({
14699
15071
  * Each provider returns a static descriptor; the core enumerates them
14700
15072
  * to validate the `integration=` query param and resolve the consent
14701
15073
  * label + the scopes baked into the issued token.
15074
+ *
15075
+ * ## Declaring one
15076
+ *
15077
+ * An OAuth client is integration-specific knowledge — who the client is, what
15078
+ * it may ask for, where it may be sent — so it is declared by the ADDON that
15079
+ * owns the integration, never by the kernel and never as a branch inside
15080
+ * `oauth2-routes.ts` ([D101](../../../../docs/decisions/adr-0101.md)). Three
15081
+ * steps, no others:
15082
+ *
15083
+ * 1. Add `{ "name": "oauth-integration" }` to the addon's `camstack.addons[]`
15084
+ * manifest entry. This is also what tells the hub, at addon-LOAD time, that
15085
+ * a descriptor is owed — see "the boot window" below.
15086
+ * 2. Return a provider from `onInitialize()`:
15087
+ *
15088
+ * ```ts
15089
+ * const provider: IOauthIntegrationProvider = {
15090
+ * getDescriptor: async () => ({
15091
+ * integrationId: 'my-thing', // the `integration=` query param
15092
+ * displayName: 'My Thing',
15093
+ * requestedScopes: [ … ], // see below
15094
+ * allowedRedirectPrefixes: ['https://callback.example/'],
15095
+ * }),
15096
+ * }
15097
+ * return [{ capability: oauthIntegrationCapability, provider }]
15098
+ * ```
15099
+ *
15100
+ * The descriptor must be **static** — it is read on the authorize path, so
15101
+ * never put an await on network or disk behind it, and never register it
15102
+ * behind one either (a provider is registered only once `onInitialize`
15103
+ * RETURNS, so anything awaited before the return delays linking).
15104
+ * 3. Nothing else. There is no allow-list to join, no id to register with the
15105
+ * core, and no per-integration branch anywhere: `/api/oauth2/authorize` and
15106
+ * `/api/oauth2/integrations` are built from this collection alone.
15107
+ *
15108
+ * **Scopes. `requestedScopes` has exactly ONE meaning: what the integration
15109
+ * NEEDS to function.** Not a blast radius, not a conservative
15110
+ * under-declaration, not a description of some other path the addon happens to
15111
+ * have. Derive it from what the client actually calls **with this token** —
15112
+ * every tRPC path against `METHOD_ACCESS_MAP`, plus an `addon:` grant for every
15113
+ * addon HTTP route it posts to — and write the call that justifies each entry
15114
+ * next to it. Two integrations once used this field to mean two different
15115
+ * things; the operator ruled there is one meaning, and any third integration
15116
+ * inherits it (2026-08-09).
15117
+ *
15118
+ * This is not documentation, it is the ENFORCEMENT INPUT. Since
15119
+ * [D103](../../../../docs/decisions/adr-0103.md) the `/addon/:addonId/*` gate
15120
+ * checks an integration token's grant before letting it reach an
15121
+ * `access: 'authenticated'` route, so an **under-declaration is an integration
15122
+ * that stops working** — a missing `addon:` entry means `403 Token scope
15123
+ * mismatch` on every control the client tries to actuate. Widen the descriptor
15124
+ * honestly rather than weakening a check to make a route pass.
15125
+ *
15126
+ * Prefer a narrow `capability:` scope to a `category:` one unless the client
15127
+ * genuinely needs a whole family; a category scope grants every future member
15128
+ * of that category too. `category:system [create]` has been rejected once and
15129
+ * should stay rejected: it hands `addons.installPackage` to an integration.
15130
+ *
15131
+ * Calls the ADDON itself makes over `ctx.api` run as the addon and are not
15132
+ * scope-checked, so they are not what this field describes — but reaching the
15133
+ * addon's route in the first place IS, and that is the entry to declare.
15134
+ *
15135
+ * **The boot window.** An addon registers its provider after its runner forks
15136
+ * and initialises, so between hub start and that moment this collection is
15137
+ * incomplete and an `integrationId` can be legitimately absent. The core does
15138
+ * not wait, poll or cache around this ([D3](../../../../docs/decisions/adr-0003.md)):
15139
+ * it compares the manifest declarers against the registered providers and
15140
+ * answers `503 temporarily_unavailable` (with `Retry-After` and the pending
15141
+ * addon ids) instead of `400 unknown integration`, and reports
15142
+ * `complete: false` on `GET /api/oauth2/integrations`. A client should retry
15143
+ * while the list is incomplete rather than conclude the hub cannot do OAuth.
14702
15144
  */
14703
15145
  var OauthIntegrationDescriptorSchema = object({
14704
15146
  /** Stable id used as the `integration=` query param, e.g. 'export-alexa'. */
@@ -14711,13 +15153,48 @@ var OauthIntegrationDescriptorSchema = object({
14711
15153
  * redirect_uri that does not start with one of these. Required —
14712
15154
  * an empty list means the integration can never complete linking. */
14713
15155
  allowedRedirectPrefixes: array(string()).min(1),
15156
+ /** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
15157
+ * RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
15158
+ * `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
15159
+ * whose address the hub cannot know in advance (a Home Assistant at
15160
+ * `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
15161
+ * exactly; a public host never satisfies this branch, so it is not a
15162
+ * wildcard prefix by another name. */
15163
+ allowedPrivateHostPaths: array(string()).optional(),
15164
+ /** When true this is a PUBLIC client (source is published, no secret can be
15165
+ * protected) and PKCE is mandatory: `/authorize` refuses without an S256
15166
+ * `code_challenge`, `/token` refuses without the matching `code_verifier`. */
15167
+ requiresPkce: boolean().optional(),
14714
15168
  /** Optional public origin (no trailing slash) that this integration's
14715
15169
  * issued codes/tokens should carry as the `hubUrl` claim — typically the
14716
15170
  * operator-selected external-access endpoint resolved by the addon. When
14717
15171
  * present, /api/oauth2/authorize bakes THIS into the code instead of the
14718
15172
  * hub-global `publicHubUrl()`, so a forked exporter addon (which can't set
14719
15173
  * the hub's env) drives the claim that its cloud Lambda routes back on. */
14720
- hubUrl: string().optional()
15174
+ hubUrl: string().optional(),
15175
+ /**
15176
+ * How long a REFRESH token issued for this integration lives — seconds, or
15177
+ * `'never'` for a token minted with no `exp` claim at all. Omit to keep the
15178
+ * 30-day default, which is what every link used before this field existed.
15179
+ *
15180
+ * Declared here for the same reason `requestedScopes` is: the integration
15181
+ * knows what it needs. Amazon's account linking and a Home Assistant config
15182
+ * entry are both meant to survive indefinitely, and re-linking is a manual
15183
+ * user action, so a 30-day expiry silently unlinks a working integration.
15184
+ *
15185
+ * **The security posture, stated so it is owned deliberately.** A refresh
15186
+ * token that never expires is permanent access if it leaks. What bounds it is
15187
+ * revocation, not time: `oauthRefresh` re-reads the session on every use and
15188
+ * returns `null` once `revokedAt` is set, as does `oauthVerifyAccessToken`.
15189
+ * The one gap is the ACCESS token — it is a plain signed JWT that nothing
15190
+ * re-checks against the session on the `/trpc` and `/addon/*` paths, so
15191
+ * revoking a link takes effect there only after its remaining hour. That hour
15192
+ * is why the access TTL is not configurable.
15193
+ *
15194
+ * The value is baked into the authorization code at `/authorize` and travels
15195
+ * on the tokens, so editing this field changes FUTURE links only.
15196
+ */
15197
+ refreshTokenTtlSec: union([number().int().positive(), literal("never")]).optional()
14721
15198
  });
14722
15199
  method(_void(), OauthIntegrationDescriptorSchema);
14723
15200
  /**
@@ -14861,7 +15338,7 @@ var TrackEnvelopeSchema = object({
14861
15338
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
14862
15339
  * keeps every scalar the list surfaces actually render (ids, class(es),
14863
15340
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
14864
- * zonesVisited, bestEventId, envelope) and returns `positions` /
15341
+ * zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
14865
15342
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
14866
15343
  * `getTrack`. Mirrors the event-store `projection` convention
14867
15344
  * (`getObjectEvents` et al.).
@@ -14903,6 +15380,30 @@ var TrackSourceSchema = _enum([
14903
15380
  "audio"
14904
15381
  ]);
14905
15382
  /**
15383
+ * Where a track sits in the RETRAIN lifecycle (D81).
15384
+ *
15385
+ * - `none` — never marked, or un-marked. Evictable.
15386
+ * - `staging` — the operator wants this track as training material and has not
15387
+ * finished with it. **This is the only state retention holds**: the track and
15388
+ * everything it owns (object events, crops, keyframes, CLIP vector) survive
15389
+ * the device's age window.
15390
+ * - `trained` — the retrain page has taken what it needed. The frames it chose
15391
+ * were COPIED into the retrain dataset at selection time, so the dataset no
15392
+ * longer depends on the track's media and the track becomes EVICTABLE again.
15393
+ * Terminal for the plain `markForTrain` toggle: returning it to `staging` is
15394
+ * a deliberate action of the retrain page, not a side effect of a checkbox.
15395
+ *
15396
+ * There is no `null`. The state is stored `TEXT NOT NULL DEFAULT 'none'` because
15397
+ * the store's filter language has only positive equality and `whereIn` — no
15398
+ * negation, no IS NULL — so a NULL would be unselectable by ANY predicate and
15399
+ * would make the entire pre-column history immortal in one deploy.
15400
+ */
15401
+ var RetrainStatusSchema = _enum([
15402
+ "none",
15403
+ "staging",
15404
+ "trained"
15405
+ ]);
15406
+ /**
14906
15407
  * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
14907
15408
  * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
14908
15409
  * so the two surfaces cannot drift.
@@ -14912,18 +15413,31 @@ var TrackSourceSchema = _enum([
14912
15413
  * columns existed read as absent, and a consumer that needs a boolean should say
14913
15414
  * `flag === true`, not `flag !== false`.
14914
15415
  *
14915
- * What the flags DO is deliberately UNDEFINED at the time of writing: they are
14916
- * operator curation, and the behaviour they drive will be specified separately.
14917
- * In particular a `markForTrain` track is NOT pinned against retention — see
14918
- * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
15416
+ * `markForTrain` is the WIRE FACE of {@link RetrainStatusSchema}, not a column:
15417
+ * it is exactly `retrainStatus === 'staging'`, in both directions. Writing
15418
+ * `true` moves `none → staging`, writing `false` moves `staging none`, and a
15419
+ * `trained` track reports `false` while refusing both writes. The boolean is
15420
+ * kept because three surfaces drive a toggle off it; anything that needs to tell
15421
+ * "never marked" from "already trained" must read `retrainStatus`.
15422
+ *
15423
+ * `debug` does NOT pin; it is attention, not durability.
14919
15424
  */
14920
15425
  var TrackFlagFields = {
14921
- /** Operator marked this track as training material. */
15426
+ /** Operator marked this track as training material — i.e. `retrainStatus` is
15427
+ * `'staging'`. */
14922
15428
  markForTrain: boolean().optional(),
14923
15429
  /** Operator marked this track for diagnostic attention. */
14924
15430
  debug: boolean().optional()
14925
15431
  };
14926
15432
  /**
15433
+ * The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
15434
+ * Deliberately NOT part of {@link TrackFlagFields}: that group also builds the
15435
+ * write patch, and the status is not something the toggle sets — it is what the
15436
+ * toggle's boolean is derived from. Absent on an in-RAM track never touched;
15437
+ * always present on a persisted row (the column default materialises `'none'`).
15438
+ */
15439
+ var TrackRetrainFields = { retrainStatus: RetrainStatusSchema.optional() };
15440
+ /**
14927
15441
  * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
14928
15442
  * one flag can never clear the other — the toggles are independent and are
14929
15443
  * driven from three surfaces that do not know about each other.
@@ -14937,13 +15451,92 @@ var TrackFlagsPatchSchema = object(TrackFlagFields);
14937
15451
  var TrackFlagsSchema = object({
14938
15452
  trackId: string(),
14939
15453
  markForTrain: boolean(),
14940
- debug: boolean()
15454
+ debug: boolean(),
15455
+ /** The lifecycle state the boolean was derived from. Required here (unlike on
15456
+ * a track row) because this shape is only ever produced by the write body,
15457
+ * which always knows it — and a surface that has just written needs to render
15458
+ * `trained` without a re-fetch. */
15459
+ retrainStatus: RetrainStatusSchema
15460
+ });
15461
+ union([literal(1), literal(2)]);
15462
+ /**
15463
+ * WHO decided a label, and when. Carried per tier so a value can be traced to
15464
+ * the step and model that produced it — which is what makes the write rule
15465
+ * arguable after the fact ("why is 592's label `dog` and not `Canis lupus`?")
15466
+ * and what lets a migrated, UNATTRIBUTED value be told apart from a real one.
15467
+ *
15468
+ * `stepId` is the pipeline step id (`animal-classifier`, `bird-classifier`,
15469
+ * `plate-ocr`, `face-embedding`, `object-detection`), or the sentinel
15470
+ * `migration:4g` for a value the 4g migration moved from the single-slot era —
15471
+ * that value has no provenance, and the write rule lets ANY properly-attributed
15472
+ * write of the same tier replace it regardless of score.
15473
+ */
15474
+ var LabelAttributionSchema = object({
15475
+ stepId: string(),
15476
+ modelId: string().optional(),
15477
+ decidedAt: number()
15478
+ });
15479
+ /**
15480
+ * The TIERED label model (roadmap 4g), spread into `TrackSchema` and
15481
+ * `ObjectEventSchema` from ONE place so the two surfaces cannot drift — a
15482
+ * track and its events always answer the same question the same way.
15483
+ *
15484
+ * Two scalar columns, not an array: every consumer wants "the coarse one" or
15485
+ * "the fine one", and an array made both a scan. `label` is tier 1, `subLabel`
15486
+ * is tier 2, and each carries its own score + attribution.
15487
+ *
15488
+ * **Reading it.** What a human should be shown is `subLabel ?? label` — the
15489
+ * finest thing known. Before 4g the single `label` column held the finest
15490
+ * value, so a consumer that has not been updated reads the tier-1 slot and
15491
+ * shows nothing on a species-only row; that is why the migration puts every
15492
+ * pre-4g value in tier 2 (it cannot regress a display that reads the fallback)
15493
+ * and why the read surfaces were changed in the same train.
15494
+ *
15495
+ * **Writing it.** The slots are independent, which is the whole point: a
15496
+ * tier-1 write (`bird`) can never overwrite a tier-2 value (`Turdus
15497
+ * migratorius`), so fineness cannot regress by construction. Within a tier the
15498
+ * higher score wins. One rule, one implementation — see
15499
+ * `pipeline/label-tier.ts` in addon-post-analysis.
15500
+ */
15501
+ var TieredLabelFields = {
15502
+ /** Tier 1 — the sub-class. See {@link LabelTierSchema}. */
15503
+ label: string().optional(),
15504
+ /** Confidence of the tier-1 value, as reported by the deciding step. */
15505
+ labelScore: number().optional(),
15506
+ /** Provenance of the tier-1 value. See {@link LabelAttributionSchema}. */
15507
+ labelMeta: LabelAttributionSchema.optional(),
15508
+ /** Tier 2 — the instance. See {@link LabelTierSchema}. */
15509
+ subLabel: string().optional(),
15510
+ /** Confidence of the tier-2 value, as reported by the deciding step. */
15511
+ subLabelScore: number().optional(),
15512
+ /** Provenance of the tier-2 value. See {@link LabelAttributionSchema}. */
15513
+ subLabelMeta: LabelAttributionSchema.optional()
15514
+ };
15515
+ /** Per-camera slice of a training-export estimate. */
15516
+ var TrainingExportDeviceTotalsSchema = object({
15517
+ deviceId: number(),
15518
+ tracks: number().int(),
15519
+ files: number().int(),
15520
+ bytes: number().int()
15521
+ });
15522
+ /**
15523
+ * What a training export WOULD contain. Computed from media index rows only —
15524
+ * no blob is read to produce this.
15525
+ */
15526
+ var TrainingExportSummarySchema = object({
15527
+ generatedAt: number(),
15528
+ trackCount: number().int(),
15529
+ fileCount: number().int(),
15530
+ byteCount: number().int(),
15531
+ /** More marked tracks exist than a single pass carries. */
15532
+ truncated: boolean(),
15533
+ devices: array(TrainingExportDeviceTotalsSchema).readonly()
14941
15534
  });
14942
15535
  var TrackSchema = object({
14943
15536
  trackId: string(),
14944
15537
  deviceId: number(),
14945
15538
  className: string(),
14946
- label: string().optional(),
15539
+ ...TieredLabelFields,
14947
15540
  producingDeviceName: string().optional(),
14948
15541
  /** Track provenance. Absent ⇒ `pipeline` (legacy rows). */
14949
15542
  source: TrackSourceSchema.optional(),
@@ -14954,8 +15547,26 @@ var TrackSchema = object({
14954
15547
  /** Periodic snapshots at snapshotIntervalMs cadence (subject to
14955
15548
  * saveThumbnails policy). */
14956
15549
  snapshots: array(TrackSnapshotSchema).readonly(),
14957
- /** Deduplicated zones the track has entered at least once. */
15550
+ /** Deduplicated zones the track has entered at least once. Zone IDS. */
14958
15551
  zonesVisited: array(string()).readonly(),
15552
+ /**
15553
+ * Human NAMES for {@link zonesVisited}, resolved at READ time against the
15554
+ * `zones` capability.
15555
+ *
15556
+ * `zonesVisited` persists ids (`cfeec78c-8d69-…`), which no operator can type
15557
+ * and no card can render — so every free-text search surface was structurally
15558
+ * unable to answer "show me the tracks in Uscio", and did not fail loudly, it
15559
+ * just returned nothing. Resolving here rather than in each client keeps ONE
15560
+ * derivation and costs the clients no extra call (the `zones` cap is
15561
+ * per-device, so a client-side resolve would be a per-camera fan-out on a
15562
+ * surface built to avoid exactly that).
15563
+ *
15564
+ * Resolved, never invented: a zone deleted since the track was written has no
15565
+ * name and is DROPPED, so this array can be shorter than `zonesVisited` — the
15566
+ * two are not positionally aligned. Absent when the track visited no zone, or
15567
+ * when the zone catalogue could not be read.
15568
+ */
15569
+ zoneNames: array(string()).readonly().optional(),
14959
15570
  /** Deduplicated set of detector classes observed for this track over its
14960
15571
  * life (a track may be reclassified, e.g. person→vehicle). Absent on
14961
15572
  * legacy rows written before class accumulation shipped. */
@@ -14982,7 +15593,26 @@ var TrackSchema = object({
14982
15593
  * Populated from the persisted envelope columns on historical reads;
14983
15594
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
14984
15595
  envelope: TrackEnvelopeSchema.optional(),
14985
- ...TrackFlagFields
15596
+ /**
15597
+ * A face DETECTOR found a face on this track — nothing more. It says the
15598
+ * detail plane produced a `face` detail; it does NOT say the face was
15599
+ * embedded, matched, above `minFacePx`, or that the recognizer was even
15600
+ * enabled. Set once and never cleared.
15601
+ *
15602
+ * **This exists so "face present but not recognised" is expressible.** A
15603
+ * recognised identity lands in `subLabel` (attributed to the face chain via
15604
+ * `subLabelMeta.stepId`), so before this field a track with an unmatched face
15605
+ * and a track with no face at all were byte-identical on the wire and no
15606
+ * surface could tell them apart. The read is `hasFace === true && subLabel
15607
+ * === undefined`.
15608
+ *
15609
+ * **Absent ≠ false.** Every row written before the column existed omits it,
15610
+ * and so does every server that predates the field — a consumer must test
15611
+ * `=== true` and render nothing otherwise, never infer "no face".
15612
+ */
15613
+ hasFace: boolean().optional(),
15614
+ ...TrackFlagFields,
15615
+ ...TrackRetrainFields
14986
15616
  });
14987
15617
  var BaseEventFields = {
14988
15618
  id: string(),
@@ -15055,7 +15685,7 @@ var ObjectEventSchema = object({
15055
15685
  /** Omitted in slim projection. */
15056
15686
  trackId: string().optional(),
15057
15687
  className: string(),
15058
- label: string().optional(),
15688
+ ...TieredLabelFields,
15059
15689
  /** Omitted in slim projection. */
15060
15690
  confidence: number().optional(),
15061
15691
  /** Heavy JSON — omitted in slim projection. */
@@ -15136,6 +15766,173 @@ var MediaFileSchema = object({
15136
15766
  * stored blob and a `?variant=thumb` rendering without fetching either.
15137
15767
  */
15138
15768
  var MediaFileInfoSchema = MediaFileSchema.omit({ base64: true });
15769
+ /**
15770
+ * The MACRO tier of an annotation — a CLOSED set.
15771
+ *
15772
+ * This is what the exported detector predicts, so a typo here is a new class
15773
+ * with one example in it. `label` and `subLabel` are open strings by contrast:
15774
+ * the whole point of the page is teaching the model things it does not know
15775
+ * yet, and constraining that vocabulary would make it useless.
15776
+ *
15777
+ * A macro class is NEVER a label. The provider refuses a write whose `label` or
15778
+ * `subLabel` is one of these values, in any casing, because once `person`
15779
+ * exists in both tiers "every person box" stops being answerable without
15780
+ * knowing every string anyone ever typed — and the damage is retroactive.
15781
+ */
15782
+ var RetrainMacroClassSchema = _enum([
15783
+ "person",
15784
+ "vehicle",
15785
+ "animal",
15786
+ "package",
15787
+ "face",
15788
+ "plate"
15789
+ ]);
15790
+ /** A subject to learn, or a phantom to unlearn (taught by OMISSION). */
15791
+ var RetrainAnnotationKindSchema = _enum(["subject", "model_error"]);
15792
+ /** Did a human draw this box, or did the assist propose it? */
15793
+ var RetrainAnnotationSourceSchema = _enum(["operator", "assist"]);
15794
+ /** Normalised `[0,1]` rectangle against the FULL frame — the canonical form. */
15795
+ var RetrainBboxSchema = object({
15796
+ x: number(),
15797
+ y: number(),
15798
+ w: number(),
15799
+ h: number()
15800
+ });
15801
+ /**
15802
+ * One annotated subject.
15803
+ *
15804
+ * `bbox` is normalised against the full frame, ALWAYS. The per-model shapes
15805
+ * (letterboxed root / zone-cropped package / subject-cropped classifier) are
15806
+ * derived from it at export and never stored — storing them is how one feature
15807
+ * space ends up holding two crops of the same subject (D52).
15808
+ */
15809
+ var RetrainAnnotationSchema = object({
15810
+ id: string(),
15811
+ trackId: string(),
15812
+ deviceId: number(),
15813
+ /** The COPY in retrain storage — never the source track's media key. */
15814
+ mediaKey: string(),
15815
+ bbox: RetrainBboxSchema,
15816
+ macroClass: RetrainMacroClassSchema,
15817
+ label: string().optional(),
15818
+ subLabel: string().optional(),
15819
+ kind: RetrainAnnotationKindSchema,
15820
+ source: RetrainAnnotationSourceSchema,
15821
+ /** Which model proposed this box — or, on a `model_error`, drew the phantom. */
15822
+ assistModelId: string().optional(),
15823
+ assistScore: number().optional(),
15824
+ exportedInBatch: string().optional(),
15825
+ createdAt: number()
15826
+ });
15827
+ /** The write form — the server owns `id`, `createdAt` and the frame binding. */
15828
+ var RetrainAnnotationDraftSchema = RetrainAnnotationSchema.omit({
15829
+ id: true,
15830
+ trackId: true,
15831
+ deviceId: true,
15832
+ mediaKey: true,
15833
+ createdAt: true,
15834
+ exportedInBatch: true
15835
+ });
15836
+ /** A track sitting in `staging`, with everything the worklist needs to rank it. */
15837
+ var RetrainTrackSchema = object({
15838
+ trackId: string(),
15839
+ deviceId: number(),
15840
+ className: string(),
15841
+ label: string().optional(),
15842
+ firstSeen: number(),
15843
+ lastSeen: number(),
15844
+ /** How many frames the dataset already holds from this track. */
15845
+ frameCount: number().int(),
15846
+ /** How many subjects have been annotated on those frames. `0` with
15847
+ * `frameCount: 0` is exactly "staging, still to work". */
15848
+ annotationCount: number().int()
15849
+ });
15850
+ /** A frame the picker may offer — an index row, no blob was read to produce it. */
15851
+ var RetrainFrameCandidateSchema = object({
15852
+ mediaKey: string(),
15853
+ kind: MediaFileKindEnum,
15854
+ timestamp: number(),
15855
+ sizeBytes: number().int(),
15856
+ /** A copy of this original already exists — selecting it is free and cannot
15857
+ * fail, whatever became of the original. */
15858
+ copied: boolean()
15859
+ });
15860
+ /** A frame the dataset OWNS: bytes copied at selection time. */
15861
+ var RetrainFrameSchema = object({
15862
+ frameId: string(),
15863
+ deviceId: number(),
15864
+ trackId: string(),
15865
+ /** Provenance only. It may already point at nothing — that is expected. */
15866
+ sourceMediaKey: string(),
15867
+ sourceKind: MediaFileKindEnum,
15868
+ sizeBytes: number().int(),
15869
+ width: number().int(),
15870
+ height: number().int(),
15871
+ copiedAt: number()
15872
+ });
15873
+ /** Why a copy-on-select could not be honoured — named, never a silent skip. */
15874
+ var RetrainCopyRefusalSchema = _enum([
15875
+ "source-missing",
15876
+ "unreadable-image",
15877
+ "write-failed"
15878
+ ]);
15879
+ var RetrainFrameSelectionSchema = object({
15880
+ copied: array(RetrainFrameSchema).readonly(),
15881
+ refused: array(object({
15882
+ sourceMediaKey: string(),
15883
+ reason: RetrainCopyRefusalSchema
15884
+ })).readonly()
15885
+ });
15886
+ var RetrainFrameListSchema = object({
15887
+ candidates: array(RetrainFrameCandidateSchema).readonly(),
15888
+ copies: array(RetrainFrameSchema).readonly(),
15889
+ /** What the page pre-selects — the native key frame when one survives. */
15890
+ autoPickMediaKey: string().optional()
15891
+ });
15892
+ /** What the operator asked the assist to look for. */
15893
+ var RetrainAssistSubjectSchema = discriminatedUnion("kind", [object({
15894
+ kind: literal("package"),
15895
+ zone: RetrainBboxSchema.optional()
15896
+ }), object({
15897
+ kind: literal("objects"),
15898
+ modelId: string(),
15899
+ minScore: number().optional()
15900
+ })]);
15901
+ /**
15902
+ * The assist's answer — a discriminated union, because "the model saw nothing"
15903
+ * and "this node cannot run that model" lead to different next moves and a
15904
+ * nullable result cannot tell them apart.
15905
+ */
15906
+ var RetrainAssistResultSchema = discriminatedUnion("kind", [object({
15907
+ kind: literal("proposed"),
15908
+ modelId: string(),
15909
+ stepId: string(),
15910
+ minScore: number(),
15911
+ /** Drafts, ready to edit. `source: 'assist'` until the operator touches one. */
15912
+ proposals: array(RetrainAnnotationDraftSchema).readonly(),
15913
+ /** Returned by the runner but removed by the threshold. */
15914
+ belowThreshold: number().int()
15915
+ }), object({
15916
+ kind: literal("refused"),
15917
+ /** `no-zone` is ours; the rest are the runner's own refusal vocabulary. */
15918
+ reason: string(),
15919
+ detail: string().optional()
15920
+ })]);
15921
+ /** The outcome of a lifecycle move owned by the retrain page. */
15922
+ var RetrainTransitionResultSchema = object({
15923
+ trackId: string(),
15924
+ /** Where the track ended up, whatever happened. */
15925
+ retrainStatus: RetrainStatusSchema,
15926
+ /** `false` ⇒ the move was refused or was a no-op; `reason` says which. */
15927
+ changed: boolean(),
15928
+ reason: _enum([
15929
+ "unknown-track",
15930
+ "no-frames-copied",
15931
+ "not-staging",
15932
+ "not-trained",
15933
+ "unchanged"
15934
+ ]).optional()
15935
+ });
15139
15936
  var DEFAULT_EVENT_QUERY_LIMIT = 1e3;
15140
15937
  var MAX_EVENT_QUERY_LIMIT = 5e3;
15141
15938
  var DeviceEventQueryInput = object({
@@ -15190,13 +15987,14 @@ var KeyEventSchema = object({
15190
15987
  /** Track start time (firstSeen). */
15191
15988
  timestamp: number(),
15192
15989
  className: string(),
15193
- label: string().optional(),
15990
+ ...TieredLabelFields,
15194
15991
  importance: number(),
15195
15992
  /** Highest-confidence ObjectEvent id for the track (empty when none). */
15196
15993
  bestEventId: string(),
15197
15994
  /** Track lifetime in ms (lastSeen - firstSeen). */
15198
15995
  windowMs: number().optional(),
15199
- ...TrackFlagFields
15996
+ ...TrackFlagFields,
15997
+ ...TrackRetrainFields
15200
15998
  });
15201
15999
  object({
15202
16000
  trackId: string(),
@@ -15457,6 +16255,85 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
15457
16255
  }), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
15458
16256
  kind: "query",
15459
16257
  auth: "admin"
16258
+ }), method(object({ deviceIds: array(number()).optional() }), TrainingExportSummarySchema, {
16259
+ kind: "query",
16260
+ auth: "admin"
16261
+ }), method(object({ deviceIds: array(number()).optional() }), object({ url: string() }), {
16262
+ kind: "query",
16263
+ auth: "admin"
16264
+ }), method(object({
16265
+ /** Empty ⇒ every camera that has staging tracks. A LIST, not a single
16266
+ * `deviceId`, deliberately: `deviceId` would make this device-bound and
16267
+ * route it at one camera's owner, and "every camera" would stop being
16268
+ * expressible at all. */
16269
+ deviceIds: array(number()).optional(),
16270
+ limit: number().int().min(1).max(500).optional()
16271
+ }), array(RetrainTrackSchema).readonly(), {
16272
+ kind: "query",
16273
+ auth: "admin"
16274
+ }), method(object({ trackId: string() }), RetrainFrameListSchema, {
16275
+ kind: "query",
16276
+ auth: "admin"
16277
+ }), method(object({
16278
+ deviceId: number(),
16279
+ trackId: string(),
16280
+ mediaKeys: array(string()).min(1)
16281
+ }), RetrainFrameSelectionSchema, {
16282
+ kind: "mutation",
16283
+ auth: "admin"
16284
+ }), method(object({
16285
+ deviceId: number(),
16286
+ trackId: string(),
16287
+ frameId: string()
16288
+ }), object({
16289
+ removed: boolean(),
16290
+ removedAnnotations: number().int()
16291
+ }), {
16292
+ kind: "mutation",
16293
+ auth: "admin"
16294
+ }), method(object({ frameId: string() }), object({
16295
+ base64: string(),
16296
+ width: number().int(),
16297
+ height: number().int()
16298
+ }), {
16299
+ kind: "query",
16300
+ auth: "admin"
16301
+ }), method(object({
16302
+ deviceId: number(),
16303
+ trackId: string(),
16304
+ frameId: string(),
16305
+ subject: RetrainAssistSubjectSchema,
16306
+ /** Which node runs it. Absent ⇒ wherever an unowned call lands. */
16307
+ nodeId: string().optional()
16308
+ }), RetrainAssistResultSchema, {
16309
+ kind: "mutation",
16310
+ auth: "admin"
16311
+ }), method(object({ trackId: string() }), array(RetrainAnnotationSchema).readonly(), {
16312
+ kind: "query",
16313
+ auth: "admin"
16314
+ }), method(object({
16315
+ deviceId: number(),
16316
+ trackId: string(),
16317
+ frameId: string(),
16318
+ annotations: array(RetrainAnnotationDraftSchema)
16319
+ }), array(RetrainAnnotationSchema).readonly(), {
16320
+ kind: "mutation",
16321
+ auth: "admin"
16322
+ }), method(object({
16323
+ deviceId: number(),
16324
+ trackId: string()
16325
+ }), RetrainTransitionResultSchema, {
16326
+ kind: "mutation",
16327
+ auth: "admin"
16328
+ }), method(object({
16329
+ deviceId: number(),
16330
+ trackId: string()
16331
+ }), RetrainTransitionResultSchema, {
16332
+ kind: "mutation",
16333
+ auth: "admin"
16334
+ }), method(object({ deviceIds: array(number()).optional() }), object({ url: string() }), {
16335
+ kind: "query",
16336
+ auth: "admin"
15460
16337
  }), method(object({
15461
16338
  eventId: string(),
15462
16339
  kind: MediaFileKindEnum.optional()
@@ -16069,6 +16946,22 @@ var DetailResultSchema = object({
16069
16946
  bbox: NativeCropBboxSchema.optional(),
16070
16947
  embedding: string().optional(),
16071
16948
  label: string().optional(),
16949
+ /**
16950
+ * The tier `label` occupies, copied VERBATIM from the producing step's
16951
+ * `StepDefinition.labelTier` (roadmap 4g). Present only when `label` is.
16952
+ *
16953
+ * It rides the wire rather than being resolved by the consumer because the
16954
+ * declaration lives with the step definition, which only the executing node
16955
+ * has: post-analysis holds no step registry, and re-deriving the tier from
16956
+ * `className` there would be exactly the inference this model exists to
16957
+ * forbid. A `label` that arrives WITHOUT this field is refused by the write
16958
+ * rule and logged (`label tier undeclared`) — an older runner therefore
16959
+ * stops enriching rather than guessing, which is why addon-pipeline is
16960
+ * deployed BEFORE addon-post-analysis.
16961
+ */
16962
+ labelTier: union([literal(1), literal(2)]).optional(),
16963
+ /** Model that produced `label` — carried into the tier's attribution. */
16964
+ labelModelId: string().optional(),
16072
16965
  alignedCropJpeg: string().optional(),
16073
16966
  /** Face short side (px) measured on the NATIVE crop surface. The `bbox`
16074
16967
  * above is detection-frame px (≈6× smaller on a 4K camera) — min-face-size
@@ -16776,6 +17669,23 @@ var CameraRecordingStatusSchema = object({
16776
17669
  active: boolean(),
16777
17670
  storageBytes: number()
16778
17671
  });
17672
+ /** One stage of the fan-out that could NOT be read, and how long it cost. */
17673
+ var CameraStatusDegradationSchema = object({
17674
+ stage: _enum([
17675
+ "source",
17676
+ "broker",
17677
+ "detection",
17678
+ "recording",
17679
+ "switches"
17680
+ ]),
17681
+ reason: _enum([
17682
+ "timeout",
17683
+ "error",
17684
+ "partial"
17685
+ ]),
17686
+ /** Wall-clock ms spent on the stage before it was abandoned. */
17687
+ elapsedMs: number()
17688
+ });
16779
17689
  /**
16780
17690
  * Aggregated per-camera pipeline status — server-composed, single call.
16781
17691
  *
@@ -16806,9 +17716,28 @@ var CameraStatusSchema = object({
16806
17716
  * differently — a quiet camera that looks identical to a dead one is the
16807
17717
  * silence-reads-as-never-happened trap this repo keeps paying for.
16808
17718
  *
16809
- * Empty when nothing is off. Never contains a switch no provider offers.
17719
+ * Empty when nothing is off, and never contains a switch no provider offers
17720
+ * — but an empty list is only a POSITIVE claim when `degraded` does not name
17721
+ * `'switches'`. When it does, the switch set could not be read and nothing
17722
+ * here may be rendered as "the operator turned nothing off": that is the
17723
+ * D62 failure (a camera we could not read painted as broken) in the very
17724
+ * field that exists to prevent it.
16810
17725
  */
16811
17726
  switchedOff: array(CameraSwitchIdSchema).readonly(),
17727
+ /**
17728
+ * Stages of the bounded fan-out that were CUT SHORT — a timeout or a
17729
+ * rejection — and whose block is therefore `null` because we could not
17730
+ * READ it, not because there is nothing there.
17731
+ *
17732
+ * Without this, three different facts arrive as the same `null`: "the stage
17733
+ * timed out", "the stage failed", and "this camera legitimately has no
17734
+ * decoder / no recording". Every surface that draws a conclusion from a null
17735
+ * block (or from an empty `switchedOff`) must consult this first; a stage
17736
+ * named here supports no conclusion at all, only "unknown".
17737
+ *
17738
+ * Empty on a clean read — the overwhelmingly common case.
17739
+ */
17740
+ degraded: array(CameraStatusDegradationSchema).readonly(),
16812
17741
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
16813
17742
  fetchedAt: number()
16814
17743
  });
@@ -17371,6 +18300,77 @@ var snapshotCapability = {
17371
18300
  lastCapturedAt: number().nullable(),
17372
18301
  cacheAgeMs: number().nullable(),
17373
18302
  etag: string().nullable()
18303
+ }))),
18304
+ /**
18305
+ * Signed, expiring links to a CLIENT-SIZED frame — and the demand signal
18306
+ * that makes those frames current.
18307
+ *
18308
+ * ## The problem it replaces
18309
+ *
18310
+ * `getSnapshotOverview` is cache-only by contract: it answers from whatever
18311
+ * the wrapper happens to hold and never captures. Under D93 the client
18312
+ * versions its image URL on that answer, and an image REQUEST is what enrols
18313
+ * a camera in the keep-warm loop. Both of those are satisfiable by the
18314
+ * client's own image cache — `expo-image` is URL-keyed and never revalidates
18315
+ * — so a URL painted in a previous session comes off disk with no network,
18316
+ * no enrolment, and nothing warming. Measured on the live hub: reopening
18317
+ * after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
18318
+ * HTTP requests, and the fleet only recovered because a later poll happened
18319
+ * to observe a different identity.
18320
+ *
18321
+ * ## The two properties that fix it
18322
+ *
18323
+ * **It is an RPC, so no client cache can answer it.** The demand signal
18324
+ * always reaches the wrapper. This method therefore MAY create keep-warm
18325
+ * subscriptions, where `getSnapshotOverview` must never (D93) — the
18326
+ * distinction is not "one is newer" but that the overview poll is app-wide
18327
+ * (a creating overview would warm every camera on the install) while this is
18328
+ * called by a rendered surface naming the tiles it is actually painting, at
18329
+ * the width it is painting them.
18330
+ *
18331
+ * **It waits, briefly and boundedly, for the capture it triggered.** The
18332
+ * returned `capturedAt` is the frame the link will serve, not the frame the
18333
+ * cache held when the client asked, so a first paint is honest and current
18334
+ * instead of a generation behind. A device that does not settle inside the
18335
+ * bound still gets a link and its real (older) `capturedAt` — the next poll
18336
+ * carries it forward.
18337
+ *
18338
+ * `force` is never set on behalf of a client here. A sleeping battery camera
18339
+ * is reported with `sleeping: true` and the last frame it produced, however
18340
+ * old; the wrapper's existing sleep gate owns that decision and this method
18341
+ * adds no second one.
18342
+ */
18343
+ getSnapshotLinks: systemMethod(object({
18344
+ /** The tiles a surface is actually rendering. One entry per (device,
18345
+ * width) the caller will paint — the width is snapped to the server's
18346
+ * ladder and becomes part of the link's SIGNED identity. */
18347
+ targets: array(object({
18348
+ deviceId: number(),
18349
+ /** Target width in px. Omit for the frame as captured — correct
18350
+ * for a full-bleed surface, wrong (and expensive) for a grid. */
18351
+ width: number().int().positive().optional()
18352
+ })).min(1).max(200) }), array(object({
18353
+ deviceId: number(),
18354
+ /** Root-relative signed path, or null when the link plane is not
18355
+ * served (no data-plane facility). Present even for a device that has
18356
+ * never captured — the request is what triggers the first one (D94). */
18357
+ url: string().nullable(),
18358
+ /** Epoch ms of the frame this link serves. Null = never captured.
18359
+ * THE honest age: the tRPC path carried none before this. */
18360
+ capturedAt: number().nullable(),
18361
+ /** Age of that frame at the moment the answer was built. */
18362
+ ageMs: number().nullable(),
18363
+ /** Epoch ms after which `url` stops verifying. */
18364
+ expiresAt: number().nullable(),
18365
+ /** Ladder rung the bytes are at; null = the frame as captured. */
18366
+ width: number().nullable(),
18367
+ /** The device has never produced a frame. An empty state, not a
18368
+ * failure — and never a reason to withhold the link (D94). */
18369
+ neverCaptured: boolean(),
18370
+ /** A sleeping battery camera: the frame is deliberately stale and will
18371
+ * NOT refresh in the background. A surface should say so rather than
18372
+ * present it as current. */
18373
+ sleeping: boolean()
17374
18374
  })))
17375
18375
  },
17376
18376
  status: {
@@ -17416,13 +18416,35 @@ var SsoBridgeClaimsSchema = object({
17416
18416
  integrationId: string().optional(),
17417
18417
  /** JWT ID — unique per issued code; consumed-set enforces single-use. */
17418
18418
  jti: string().optional(),
18419
+ /** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
18420
+ * client. Its PRESENCE is what makes the verifier mandatory at exchange,
18421
+ * so the requirement travels with the code and not with mutable config. */
18422
+ codeChallenge: string().optional(),
17419
18423
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
17420
18424
  * tokens so the verify path can check the session is not revoked. */
17421
- sessionId: string().optional()
18425
+ sessionId: string().optional(),
18426
+ /**
18427
+ * The refresh lifetime this LINK was created with, in seconds, or `'never'`.
18428
+ * Baked into the code at `/authorize` from the integration's descriptor and
18429
+ * carried forward so `oauthRefresh` re-mints with the same lifetime. It rides
18430
+ * on the token rather than being re-read from the descriptor on purpose:
18431
+ * editing a descriptor must not retroactively extend or shorten a link the
18432
+ * operator already consented to.
18433
+ */
18434
+ refreshTtl: union([number().int().positive(), literal("never")]).optional()
17422
18435
  });
17423
18436
  method(object({
17424
18437
  claims: SsoBridgeClaimsSchema,
17425
- ttlSec: number().int().positive().optional()
18438
+ /**
18439
+ * Seconds, or `'never'` for a token minted with NO `exp` claim.
18440
+ *
18441
+ * `'never'` is a literal rather than `undefined`/`0` because omitting
18442
+ * this field already means "the 5-minute SSO hand-off default", and
18443
+ * `jwt.sign` THROWS on `{ expiresIn: undefined }` — a "no expiry" that
18444
+ * went through the numeric path would fail at mint time and break
18445
+ * linking rather than produce an eternal token.
18446
+ */
18447
+ ttlSec: union([number().int().positive(), literal("never")]).optional()
17426
18448
  }), object({ token: string() })), method(object({ token: string() }), SsoBridgeClaimsSchema.nullable());
17427
18449
  var ProviderListEntrySchema = discriminatedUnion("shouldSaveDiskSpace", [object({
17428
18450
  providerId: string().min(1),
@@ -17968,7 +18990,7 @@ var ClipPlaybackSchema = object({
17968
18990
  playbackEndpoints: array(string()).optional(),
17969
18991
  token: string().optional()
17970
18992
  });
17971
- method(object({
18993
+ DeviceType.Camera, method(object({
17972
18994
  deviceId: number(),
17973
18995
  since: number(),
17974
18996
  until: number(),
@@ -19403,6 +20425,37 @@ onColorChanged: { data: object({
19403
20425
  */
19404
20426
  runtimeState: ColorStatusSchema
19405
20427
  };
20428
+ var ConnectionTestOutcomeSchema = discriminatedUnion("outcome", [
20429
+ object({
20430
+ outcome: literal("validated"),
20431
+ /** Round-trip of the sign-in, when the provider measured it. */
20432
+ latencyMs: number().nonnegative().optional(),
20433
+ /** Optional human detail worth showing next to the tick
20434
+ * ("3 devices visible on this account"). */
20435
+ detail: string().optional()
20436
+ }).strict(),
20437
+ object({
20438
+ outcome: literal("rejected"),
20439
+ error: string()
20440
+ }).strict(),
20441
+ object({
20442
+ outcome: literal("inconclusive"),
20443
+ error: string()
20444
+ }).strict()
20445
+ ]);
20446
+ var ConnectionTestInputSchema = object({
20447
+ /** Candidate integration settings, exactly as the create form collected them. */
20448
+ settings: record(string(), unknown()) });
20449
+ /**
20450
+ * What the provider's test actually DOES, so the UI can say it in words before
20451
+ * the operator presses the button ("Signs in to the Dreo cloud"). Purely
20452
+ * descriptive — it never changes routing.
20453
+ */
20454
+ var ConnectionTestDescriptorSchema = object({ label: string() });
20455
+ method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
20456
+ kind: "mutation",
20457
+ auth: "admin"
20458
+ }), method(_void(), ConnectionTestDescriptorSchema, { auth: "admin" });
19406
20459
  /**
19407
20460
  * Upstream-system connectivity sensor — distinct from `device-status`,
19408
20461
  * which is the kernel-managed online/offline flag for the device's
@@ -20037,7 +21090,29 @@ var FaceInfoSchema = object({
20037
21090
  recognizedIdentityId: string().optional(),
20038
21091
  identityName: string().optional(),
20039
21092
  assigned: boolean(),
21093
+ /**
21094
+ * The crop, inline, base64.
21095
+ *
21096
+ * **Prefer {@link cropUrl}.** At the 500 rows the Faces view asks for this
21097
+ * field alone is ~2.87 MiB, re-sent in full on every operator assign and
21098
+ * every 30 s poll, base64-inflated over the msgpack socket and held in the
21099
+ * query heap. It stays for callers that have not migrated; `includeCrops:
21100
+ * false` turns it off once they have.
21101
+ */
20040
21102
  base64: string().optional(),
21103
+ /**
21104
+ * Same crop, as a data-plane URL for `<img src>` — the move the admin
21105
+ * snapshot surfaces made on 2026-08-08.
21106
+ *
21107
+ * Served by the `event-media` plane, which resolves a raw MediaStore key and
21108
+ * is `access: 'authenticated'`: a bare `<img>` carries the `camstack_session`
21109
+ * cookie, so no header plumbing is needed. The bytes then ride the browser's
21110
+ * HTTP cache with an ETag and `immutable`, instead of the WebSocket.
21111
+ *
21112
+ * Absent when the face has no stored crop, or when the addon has no data
21113
+ * plane — callers fall back to {@link base64}.
21114
+ */
21115
+ cropUrl: string().optional(),
20041
21116
  /** Design B: the face bbox (pixel space) on the key frame — lets a detail
20042
21117
  * view draw the box over the native `keyFrameMediaKey` frame. Absent on
20043
21118
  * legacy rows written before design B. */
@@ -20102,7 +21177,23 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
20102
21177
  auth: "admin"
20103
21178
  }), method(object({
20104
21179
  limit: number().int().positive().optional(),
20105
- filter: FaceFilterEnum.optional()
21180
+ filter: FaceFilterEnum.optional(),
21181
+ /**
21182
+ * Inline the base64 crop on every row. Default `true` — the existing
21183
+ * behaviour, kept so no caller breaks.
21184
+ *
21185
+ * Set `false` once the caller renders {@link FaceInfo.cropUrl}: that
21186
+ * drops ~2.87 MiB per 500-row page to a few KiB of metadata and lets
21187
+ * the browser cache the images.
21188
+ *
21189
+ * **This is an INPUT field, so it does not reach the addon until the
21190
+ * next train.** The hub router validates cap inputs against its own
21191
+ * compiled Zod, which strips a key it does not know — verified today
21192
+ * on the OUTPUT side, where an additive field DOES arrive immediately
21193
+ * (`Track.hasFace`). Until the train ships, sending `false` is
21194
+ * harmless and simply keeps the crops inline.
21195
+ */
21196
+ includeCrops: boolean().optional()
20106
21197
  }).optional(), array(FaceInfoSchema).readonly()), method(object({
20107
21198
  deviceId: number().int(),
20108
21199
  trackId: string()
@@ -20726,15 +21817,57 @@ var AvailableIntegrationTypeSchema = object({
20726
21817
  * flow can import (e.g. HA areas). Drives the adopt modal's "import
20727
21818
  * locations" checkbox. Provider-declared in the addon manifest. */
20728
21819
  supportsLocationImport: boolean(),
21820
+ /**
21821
+ * True when this integration DECLARES a pre-creation test (the
21822
+ * `connection-test` cap, or a broker whose settings it stores). Drives the
21823
+ * Test button: an integration that cannot be tested must say so up front
21824
+ * rather than offering a button that always answers the same nonsense.
21825
+ */
21826
+ canTest: boolean(),
20729
21827
  existingInstances: array(object({
20730
21828
  id: string(),
20731
21829
  name: string()
20732
21830
  })),
20733
21831
  canAdd: boolean()
20734
21832
  });
21833
+ /**
21834
+ * Why a test could not be answered as a plain boolean.
21835
+ *
21836
+ * `success` alone collapsed four different situations into one red box, and the
21837
+ * one that mattered most — "nobody ever asked the remote anything" — looked
21838
+ * exactly like "the remote said no". The status is the discriminator:
21839
+ *
21840
+ * - `validated` — a provider-declared test ran and the remote ACCEPTED.
21841
+ * - `rejected` — a provider-declared test ran and the remote REFUSED.
21842
+ * The only status that blocks `integrations.create`.
21843
+ * - `inconclusive` — a test IS declared but could not complete (timeout,
21844
+ * DNS, 5xx). Nothing was observed; not a failure.
21845
+ * - `unsupported` — this integration declares NO test. Nothing was
21846
+ * observed either; not a failure, and not a pass.
21847
+ *
21848
+ * `unsupported` and `inconclusive` both carry `success: false` so an older
21849
+ * client can never read them as a green tick, and both carry an `error` string
21850
+ * that SAYS the test did not run rather than inventing a failure.
21851
+ */
21852
+ var TestConnectionStatusEnum = _enum([
21853
+ "validated",
21854
+ "rejected",
21855
+ "inconclusive",
21856
+ "unsupported"
21857
+ ]);
20735
21858
  var TestConnectionResultSchema$1 = object({
21859
+ /** True ONLY for `validated`. Never true for a test that did not run. */
20736
21860
  success: boolean(),
20737
- error: string().optional()
21861
+ error: string().optional(),
21862
+ /** Optional for wire back-compat with clients built before the tri-state;
21863
+ * the server always sets it. */
21864
+ status: TestConnectionStatusEnum.optional(),
21865
+ /** Addon id whose declared test answered — `null` when none did. Lets the UI
21866
+ * attribute a result instead of blaming "the integration". */
21867
+ testedBy: string().nullable().optional(),
21868
+ latencyMs: number().nonnegative().optional(),
21869
+ /** Human detail from a `validated` result ("3 devices on this account"). */
21870
+ detail: string().optional()
20738
21871
  });
20739
21872
  var CreateIntegrationInputSchema = object({
20740
21873
  addonId: string(),
@@ -22268,6 +23401,173 @@ setOverlay: method(object({
22268
23401
  }] }
22269
23402
  };
22270
23403
  /**
23404
+ * `osd-manager` — the ORCHESTRATOR over the device-scope `osd` cap.
23405
+ *
23406
+ * The `osd` cap is the firmware contract: it probes a camera's overlay
23407
+ * SLOTS and writes literal text into one. It has no idea WHERE that text
23408
+ * comes from, and it must not — a driver that grew a "show the temperature
23409
+ * here" feature would grow it once per vendor.
23410
+ *
23411
+ * This cap owns the other half: a per-(camera, slot) BINDING that says
23412
+ * which value feeds the slot, how it is formatted, and under which
23413
+ * conditions it is shown at all. One addon renders every binding on every
23414
+ * camera, so a new source costs zero driver code.
23415
+ *
23416
+ * Three deliberate choices, each with a rejected alternative:
23417
+ *
23418
+ * 1. A source is `(capName, valuePath)` over the kernel's device
23419
+ * runtime-state mirror — NOT a closed enum of source kinds. Every
23420
+ * cap-keyed slice a device publishes is bindable the day the cap
23421
+ * ships. The rejected alternative (one enum member per source, with
23422
+ * a resolver branch each) is what makes "add the humidity too" a
23423
+ * code change.
23424
+ * 2. The display gate reuses `NcConditionsSchema` verbatim — the
23425
+ * notification centre's condition vocabulary — rather than a parallel
23426
+ * model. An operator who has learned one condition editor has learned
23427
+ * both.
23428
+ * 3. Because the renderer's facts are device STATE and not a detection
23429
+ * record, only a SUBSET of that vocabulary can be answered here.
23430
+ * `setSlotBinding` REJECTS the rest at write time (see
23431
+ * `getConditionSupport`). It does not accept-then-fail-closed: a
23432
+ * condition that can never be true renders a permanently blank
23433
+ * overlay, and a blank overlay looks exactly like a broken camera.
23434
+ */
23435
+ /** Where a slot's value comes from. */
23436
+ var OsdSourceSchema = discriminatedUnion("kind", [
23437
+ object({
23438
+ kind: literal("static"),
23439
+ text: string().max(64)
23440
+ }),
23441
+ object({
23442
+ kind: literal("clock"),
23443
+ /** Token pattern: `YYYY MM DD HH mm ss`. Everything else is literal. */
23444
+ pattern: string().min(1).max(32).default("HH:mm"),
23445
+ /** IANA zone. Omitted = the server's zone. */
23446
+ timezone: string().min(1).max(64).optional()
23447
+ }),
23448
+ object({
23449
+ kind: literal("device-state"),
23450
+ deviceId: number().int().optional(),
23451
+ capName: string().min(1).max(64),
23452
+ /** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
23453
+ valuePath: string().min(1).max(64)
23454
+ })
23455
+ ]);
23456
+ var OsdSlotBindingSchema = object({
23457
+ /** Off = the manager stops driving this slot. It does NOT clear it. */
23458
+ enabled: boolean().default(true),
23459
+ source: OsdSourceSchema,
23460
+ /** `${value}` and `${unit}` are substituted; every occurrence. */
23461
+ template: string().max(96).default("${value}"),
23462
+ /** Truncate with an ellipsis past this length. Absent = no limit. */
23463
+ maxCharacters: number().int().min(4).max(64).optional(),
23464
+ /**
23465
+ * Decimal places for a numeric value. `0` yields an integer — the
23466
+ * documented workaround for firmwares that reject `.` in overlay text.
23467
+ */
23468
+ maxDecimals: number().int().min(0).max(4).default(1),
23469
+ /** Appended via `${unit}`. The state mirror does not carry units. */
23470
+ unitLabel: string().max(8).optional(),
23471
+ /** Raw value → display text, e.g. `{"true":"MOTION","false":""}`. */
23472
+ valueMap: record(string(), string()).optional(),
23473
+ /** Time windows in which the slot is shown. Absent = always. */
23474
+ schedule: NcScheduleSchema.optional(),
23475
+ /**
23476
+ * Display gate, in the notification centre's condition vocabulary.
23477
+ * Only the keys reported by `getConditionSupport` are accepted.
23478
+ */
23479
+ conditions: NcConditionsSchema.optional(),
23480
+ /** Rendered when the gate is closed or the value unreadable. Empty = hide. */
23481
+ fallbackText: string().max(64).default("")
23482
+ });
23483
+ /** One camera slot, as the operator sees it: firmware truth + our binding. */
23484
+ var OsdSlotViewSchema = object({
23485
+ slotId: string(),
23486
+ kind: OsdOverlayKindEnum,
23487
+ /** Firmware refuses text edits (a timestamp, the channel name). */
23488
+ readOnly: boolean(),
23489
+ cameraEnabled: boolean(),
23490
+ cameraText: string().optional(),
23491
+ binding: OsdSlotBindingSchema.nullable()
23492
+ });
23493
+ /**
23494
+ * What happened to one slot on one render pass. `unchanged` exists so the
23495
+ * operator can tell "we are driving this and the value is steady" from
23496
+ * "we never got there" — and so the loop can prove it is not rewriting
23497
+ * identical text to the camera every tick.
23498
+ */
23499
+ var OsdRenderOutcomeEnum = _enum([
23500
+ "written",
23501
+ "unchanged",
23502
+ "gated",
23503
+ "unreadable",
23504
+ "disabled",
23505
+ "unbound",
23506
+ "failed"
23507
+ ]);
23508
+ var OsdRenderResultSchema = object({
23509
+ slotId: string(),
23510
+ outcome: OsdRenderOutcomeEnum,
23511
+ /** The text the slot should carry. Empty = the slot is switched off. */
23512
+ text: string(),
23513
+ /** Why, whenever the outcome is not a plain write. Never silent. */
23514
+ reason: string().optional()
23515
+ });
23516
+ var OsdSourceValueTypeEnum = _enum([
23517
+ "number",
23518
+ "boolean",
23519
+ "string",
23520
+ "enum"
23521
+ ]);
23522
+ /**
23523
+ * One bindable value, derived from a cap's `runtimeState` schema — never
23524
+ * hand-listed. The editor renders from this, so a cap that ships a new
23525
+ * state field becomes bindable with no UI change.
23526
+ */
23527
+ var OsdSourceOptionSchema = object({
23528
+ deviceId: number().int(),
23529
+ deviceName: string(),
23530
+ capName: string(),
23531
+ valuePath: string(),
23532
+ label: string(),
23533
+ valueType: OsdSourceValueTypeEnum,
23534
+ /** Present for `enum`; the editor offers these as `valueMap` keys. */
23535
+ enumValues: array(string()).readonly().optional()
23536
+ });
23537
+ method(object({ deviceId: number().int() }), object({
23538
+ supported: boolean(),
23539
+ slots: array(OsdSlotViewSchema)
23540
+ }), { auth: "admin" }), method(object({ deviceId: number().int() }), object({ sources: array(OsdSourceOptionSchema) }), { auth: "admin" }), method(object({}), object({
23541
+ supported: array(string()),
23542
+ catalog: array(NcConditionDescriptorSchema)
23543
+ }), { auth: "admin" }), method(object({
23544
+ deviceId: number().int(),
23545
+ slotId: string().min(1),
23546
+ binding: OsdSlotBindingSchema
23547
+ }), object({
23548
+ slot: OsdSlotViewSchema,
23549
+ render: OsdRenderResultSchema
23550
+ }), {
23551
+ kind: "mutation",
23552
+ auth: "admin"
23553
+ }), method(object({
23554
+ deviceId: number().int(),
23555
+ slotId: string().min(1)
23556
+ }), object({ success: literal(true) }), {
23557
+ kind: "mutation",
23558
+ auth: "admin"
23559
+ }), method(object({
23560
+ deviceId: number().int(),
23561
+ slotId: string().min(1),
23562
+ binding: OsdSlotBindingSchema.optional()
23563
+ }), OsdRenderResultSchema, {
23564
+ kind: "mutation",
23565
+ auth: "admin"
23566
+ }), method(object({ deviceId: number().int() }), object({ results: array(OsdRenderResultSchema) }), {
23567
+ kind: "mutation",
23568
+ auth: "admin"
23569
+ });
23570
+ /**
22271
23571
  * Feeder connectivity / power status — mirrors the HA petkit device-status
22272
23572
  * enum: `normal` (online, mains), `offline` (not reaching PetKit cloud),
22273
23573
  * `on_batteries` (running on battery backup). `null` until first reported.
@@ -24632,13 +25932,23 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
24632
25932
  username: string(),
24633
25933
  scopes: array(TokenScopeSchema),
24634
25934
  redirectUri: string(),
24635
- hubUrl: string()
25935
+ hubUrl: string(),
25936
+ /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
25937
+ * that carries one can ONLY be exchanged with the matching verifier. */
25938
+ codeChallenge: string().optional(),
25939
+ /** The integration's declared refresh lifetime — seconds, or `'never'`.
25940
+ * From `OauthIntegrationDescriptor.refreshTokenTtlSec`. Baked into the
25941
+ * code so the link carries its own lifetime; omit for the 30-day
25942
+ * default. */
25943
+ refreshTtlSec: union([number().int().positive(), literal("never")]).optional()
24636
25944
  }), object({ code: string() }), {
24637
25945
  kind: "mutation",
24638
25946
  access: "create"
24639
25947
  }), method(object({
24640
25948
  code: string(),
24641
- redirectUri: string()
25949
+ redirectUri: string(),
25950
+ /** PKCE verifier. REQUIRED when the code carries a challenge. */
25951
+ codeVerifier: string().optional()
24642
25952
  }), object({
24643
25953
  accessToken: string(),
24644
25954
  refreshToken: string(),
@@ -26377,11 +27687,52 @@ function startReachabilityPoll(options) {
26377
27687
  }
26378
27688
  var LAST_FETCHED_FIELD = "lastFetchedAt";
26379
27689
  function createRuntimeStateBridge(params) {
26380
- const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty } = params;
27690
+ const { runtimeState, cap, ownDeviceId, refresh, staleMs, empty, logger } = params;
27691
+ const missCooldownMs = params.refreshMissCooldownMs ?? 6e4;
27692
+ /** Epoch ms until which a refresh is not re-attempted. 0 = no cooldown. */
27693
+ let missCooldownUntil = 0;
27694
+ const readFetchedAt = () => {
27695
+ const value = runtimeState.getCapState(cap.name)?.[LAST_FETCHED_FIELD];
27696
+ return typeof value === "number" ? value : 0;
27697
+ };
27698
+ /**
27699
+ * Stop re-reading this camera for `missCooldownMs`, and say so. The
27700
+ * warn is the ONLY line an operator gets for a cap that has silently
27701
+ * been answering with defaults, so it names the cap and carries
27702
+ * `tags.deviceId` — a miss is always asked per-camera.
27703
+ */
27704
+ const openMissCooldown = (err) => {
27705
+ missCooldownUntil = Date.now() + missCooldownMs;
27706
+ logger?.warn(`${cap.name}: refresh did not land — serving the last slice and not re-reading the camera for ${String(missCooldownMs)}ms`, {
27707
+ tags: { deviceId: ownDeviceId },
27708
+ meta: {
27709
+ cooldownMs: missCooldownMs,
27710
+ error: err === void 0 ? null : err instanceof Error ? err.message : String(err)
27711
+ }
27712
+ });
27713
+ };
26381
27714
  const ensureFresh = async () => {
26382
27715
  const slice = runtimeState.getCapState(cap.name);
26383
- const fetchedAt = typeof slice?.[LAST_FETCHED_FIELD] === "number" ? slice[LAST_FETCHED_FIELD] : 0;
26384
- if (!slice || Date.now() - fetchedAt > staleMs) await refresh();
27716
+ const fetchedAt = readFetchedAt();
27717
+ if (slice && Date.now() - fetchedAt <= staleMs) {
27718
+ missCooldownUntil = 0;
27719
+ return;
27720
+ }
27721
+ if (Date.now() < missCooldownUntil) return;
27722
+ try {
27723
+ await refresh();
27724
+ } catch (err) {
27725
+ openMissCooldown(err);
27726
+ throw err;
27727
+ }
27728
+ if (readFetchedAt() > fetchedAt) {
27729
+ if (missCooldownUntil !== 0) {
27730
+ missCooldownUntil = 0;
27731
+ logger?.info(`${cap.name}: refresh landed again — resuming normal polling`, { tags: { deviceId: ownDeviceId } });
27732
+ }
27733
+ return;
27734
+ }
27735
+ openMissCooldown(void 0);
26385
27736
  };
26386
27737
  const projectStatus = () => {
26387
27738
  const slice = runtimeState.getCapState(cap.name);
@@ -27174,6 +28525,18 @@ Object.freeze({
27174
28525
  addonId: null,
27175
28526
  access: "create"
27176
28527
  },
28528
+ "connectionTest.describeTest": {
28529
+ capName: "connection-test",
28530
+ capScope: "system",
28531
+ addonId: null,
28532
+ access: "view"
28533
+ },
28534
+ "connectionTest.testSettings": {
28535
+ capName: "connection-test",
28536
+ capScope: "system",
28537
+ addonId: null,
28538
+ access: "create"
28539
+ },
27177
28540
  "consumables.reset": {
27178
28541
  capName: "consumables",
27179
28542
  capScope: "device",
@@ -27222,6 +28585,12 @@ Object.freeze({
27222
28585
  addonId: null,
27223
28586
  access: "view"
27224
28587
  },
28588
+ "coreBlocks.restart": {
28589
+ capName: "core-blocks",
28590
+ capScope: "system",
28591
+ addonId: null,
28592
+ access: "create"
28593
+ },
27225
28594
  "coreBlocks.setEnabled": {
27226
28595
  capName: "core-blocks",
27227
28596
  capScope: "system",
@@ -27558,6 +28927,12 @@ Object.freeze({
27558
28927
  addonId: null,
27559
28928
  access: "create"
27560
28929
  },
28930
+ "deviceManager.adoptionCancelJob": {
28931
+ capName: "device-manager",
28932
+ capScope: "system",
28933
+ addonId: null,
28934
+ access: "create"
28935
+ },
27561
28936
  "deviceManager.adoptionListCandidateFilters": {
27562
28937
  capName: "device-manager",
27563
28938
  capScope: "system",
@@ -27570,6 +28945,12 @@ Object.freeze({
27570
28945
  addonId: null,
27571
28946
  access: "view"
27572
28947
  },
28948
+ "deviceManager.adoptionListJobs": {
28949
+ capName: "device-manager",
28950
+ capScope: "system",
28951
+ addonId: null,
28952
+ access: "view"
28953
+ },
27573
28954
  "deviceManager.adoptionRefresh": {
27574
28955
  capName: "device-manager",
27575
28956
  capScope: "system",
@@ -27588,6 +28969,12 @@ Object.freeze({
27588
28969
  addonId: null,
27589
28970
  access: "create"
27590
28971
  },
28972
+ "deviceManager.adoptionStartJob": {
28973
+ capName: "device-manager",
28974
+ capScope: "system",
28975
+ addonId: null,
28976
+ access: "create"
28977
+ },
27591
28978
  "deviceManager.allocateDeviceId": {
27592
28979
  capName: "device-manager",
27593
28980
  capScope: "system",
@@ -27858,12 +29245,6 @@ Object.freeze({
27858
29245
  addonId: null,
27859
29246
  access: "create"
27860
29247
  },
27861
- "deviceManager.setDeviceLinks": {
27862
- capName: "device-manager",
27863
- capScope: "system",
27864
- addonId: null,
27865
- access: "create"
27866
- },
27867
29248
  "deviceManager.setDisabled": {
27868
29249
  capName: "device-manager",
27869
29250
  capScope: "system",
@@ -29262,6 +30643,48 @@ Object.freeze({
29262
30643
  addonId: null,
29263
30644
  access: "create"
29264
30645
  },
30646
+ "osdManager.clearSlotBinding": {
30647
+ capName: "osd-manager",
30648
+ capScope: "system",
30649
+ addonId: null,
30650
+ access: "delete"
30651
+ },
30652
+ "osdManager.getConditionSupport": {
30653
+ capName: "osd-manager",
30654
+ capScope: "system",
30655
+ addonId: null,
30656
+ access: "view"
30657
+ },
30658
+ "osdManager.getDeviceOsd": {
30659
+ capName: "osd-manager",
30660
+ capScope: "system",
30661
+ addonId: null,
30662
+ access: "view"
30663
+ },
30664
+ "osdManager.getSourceCatalog": {
30665
+ capName: "osd-manager",
30666
+ capScope: "system",
30667
+ addonId: null,
30668
+ access: "view"
30669
+ },
30670
+ "osdManager.previewSlot": {
30671
+ capName: "osd-manager",
30672
+ capScope: "system",
30673
+ addonId: null,
30674
+ access: "create"
30675
+ },
30676
+ "osdManager.renderDevice": {
30677
+ capName: "osd-manager",
30678
+ capScope: "system",
30679
+ addonId: null,
30680
+ access: "create"
30681
+ },
30682
+ "osdManager.setSlotBinding": {
30683
+ capName: "osd-manager",
30684
+ capScope: "system",
30685
+ addonId: null,
30686
+ access: "create"
30687
+ },
29265
30688
  "petFeeder.callPet": {
29266
30689
  capName: "pet-feeder",
29267
30690
  capScope: "device",
@@ -29334,6 +30757,12 @@ Object.freeze({
29334
30757
  addonId: null,
29335
30758
  access: "delete"
29336
30759
  },
30760
+ "pipelineAnalytics.completeRetrainTrack": {
30761
+ capName: "pipeline-analytics",
30762
+ capScope: "device",
30763
+ addonId: null,
30764
+ access: "create"
30765
+ },
29337
30766
  "pipelineAnalytics.deleteDeviceEvents": {
29338
30767
  capName: "pipeline-analytics",
29339
30768
  capScope: "device",
@@ -29346,6 +30775,12 @@ Object.freeze({
29346
30775
  addonId: null,
29347
30776
  access: "delete"
29348
30777
  },
30778
+ "pipelineAnalytics.deselectRetrainFrame": {
30779
+ capName: "pipeline-analytics",
30780
+ capScope: "device",
30781
+ addonId: null,
30782
+ access: "create"
30783
+ },
29349
30784
  "pipelineAnalytics.getActiveTracks": {
29350
30785
  capName: "pipeline-analytics",
29351
30786
  capScope: "device",
@@ -29406,6 +30841,18 @@ Object.freeze({
29406
30841
  addonId: null,
29407
30842
  access: "view"
29408
30843
  },
30844
+ "pipelineAnalytics.getRetrainExportUrl": {
30845
+ capName: "pipeline-analytics",
30846
+ capScope: "device",
30847
+ addonId: null,
30848
+ access: "view"
30849
+ },
30850
+ "pipelineAnalytics.getRetrainFrameImage": {
30851
+ capName: "pipeline-analytics",
30852
+ capScope: "device",
30853
+ addonId: null,
30854
+ access: "view"
30855
+ },
29409
30856
  "pipelineAnalytics.getSensorEvents": {
29410
30857
  capName: "pipeline-analytics",
29411
30858
  capScope: "device",
@@ -29424,6 +30871,18 @@ Object.freeze({
29424
30871
  addonId: null,
29425
30872
  access: "view"
29426
30873
  },
30874
+ "pipelineAnalytics.getTrainingExportSummary": {
30875
+ capName: "pipeline-analytics",
30876
+ capScope: "device",
30877
+ addonId: null,
30878
+ access: "view"
30879
+ },
30880
+ "pipelineAnalytics.getTrainingExportUrl": {
30881
+ capName: "pipeline-analytics",
30882
+ capScope: "device",
30883
+ addonId: null,
30884
+ access: "view"
30885
+ },
29427
30886
  "pipelineAnalytics.listEventKinds": {
29428
30887
  capName: "pipeline-analytics",
29429
30888
  capScope: "device",
@@ -29448,6 +30907,24 @@ Object.freeze({
29448
30907
  addonId: null,
29449
30908
  access: "view"
29450
30909
  },
30910
+ "pipelineAnalytics.listRetrainAnnotations": {
30911
+ capName: "pipeline-analytics",
30912
+ capScope: "device",
30913
+ addonId: null,
30914
+ access: "view"
30915
+ },
30916
+ "pipelineAnalytics.listRetrainFrames": {
30917
+ capName: "pipeline-analytics",
30918
+ capScope: "device",
30919
+ addonId: null,
30920
+ access: "view"
30921
+ },
30922
+ "pipelineAnalytics.listRetrainStaging": {
30923
+ capName: "pipeline-analytics",
30924
+ capScope: "device",
30925
+ addonId: null,
30926
+ access: "view"
30927
+ },
29451
30928
  "pipelineAnalytics.listTrackMedia": {
29452
30929
  capName: "pipeline-analytics",
29453
30930
  capScope: "device",
@@ -29460,6 +30937,12 @@ Object.freeze({
29460
30937
  addonId: null,
29461
30938
  access: "view"
29462
30939
  },
30940
+ "pipelineAnalytics.proposeRetrainAnnotations": {
30941
+ capName: "pipeline-analytics",
30942
+ capScope: "device",
30943
+ addonId: null,
30944
+ access: "create"
30945
+ },
29463
30946
  "pipelineAnalytics.pruneEvents": {
29464
30947
  capName: "pipeline-analytics",
29465
30948
  capScope: "device",
@@ -29490,12 +30973,30 @@ Object.freeze({
29490
30973
  addonId: null,
29491
30974
  access: "create"
29492
30975
  },
30976
+ "pipelineAnalytics.restageRetrainTrack": {
30977
+ capName: "pipeline-analytics",
30978
+ capScope: "device",
30979
+ addonId: null,
30980
+ access: "create"
30981
+ },
30982
+ "pipelineAnalytics.saveRetrainAnnotations": {
30983
+ capName: "pipeline-analytics",
30984
+ capScope: "device",
30985
+ addonId: null,
30986
+ access: "create"
30987
+ },
29493
30988
  "pipelineAnalytics.searchObjectEvents": {
29494
30989
  capName: "pipeline-analytics",
29495
30990
  capScope: "device",
29496
30991
  addonId: null,
29497
30992
  access: "view"
29498
30993
  },
30994
+ "pipelineAnalytics.selectRetrainFrames": {
30995
+ capName: "pipeline-analytics",
30996
+ capScope: "device",
30997
+ addonId: null,
30998
+ access: "create"
30999
+ },
29499
31000
  "pipelineAnalytics.setTrackFlags": {
29500
31001
  capName: "pipeline-analytics",
29501
31002
  capScope: "device",
@@ -30612,6 +32113,12 @@ Object.freeze({
30612
32113
  addonId: null,
30613
32114
  access: "view"
30614
32115
  },
32116
+ "snapshot.getSnapshotLinks": {
32117
+ capName: "snapshot",
32118
+ capScope: "device",
32119
+ addonId: null,
32120
+ access: "view"
32121
+ },
30615
32122
  "snapshot.getSnapshotOverview": {
30616
32123
  capName: "snapshot",
30617
32124
  capScope: "device",
@@ -30888,6 +32395,12 @@ Object.freeze({
30888
32395
  addonId: null,
30889
32396
  access: "create"
30890
32397
  },
32398
+ "streamBroker.fetchEventMedia": {
32399
+ capName: "stream-broker",
32400
+ capScope: "system",
32401
+ addonId: null,
32402
+ access: "create"
32403
+ },
30891
32404
  "streamBroker.getAllRtspEntries": {
30892
32405
  capName: "stream-broker",
30893
32406
  capScope: "system",
@@ -30900,6 +32413,12 @@ Object.freeze({
30900
32413
  addonId: null,
30901
32414
  access: "view"
30902
32415
  },
32416
+ "streamBroker.getDeviceAudioMute": {
32417
+ capName: "stream-broker",
32418
+ capScope: "system",
32419
+ addonId: null,
32420
+ access: "view"
32421
+ },
30903
32422
  "streamBroker.getPreBufferInfo": {
30904
32423
  capName: "stream-broker",
30905
32424
  capScope: "system",
@@ -30966,6 +32485,12 @@ Object.freeze({
30966
32485
  addonId: null,
30967
32486
  access: "create"
30968
32487
  },
32488
+ "streamBroker.produceEventMedia": {
32489
+ capName: "stream-broker",
32490
+ capScope: "system",
32491
+ addonId: null,
32492
+ access: "create"
32493
+ },
30969
32494
  "streamBroker.publishCameraStream": {
30970
32495
  capName: "stream-broker",
30971
32496
  capScope: "system",
@@ -31020,6 +32545,12 @@ Object.freeze({
31020
32545
  addonId: null,
31021
32546
  access: "create"
31022
32547
  },
32548
+ "streamBroker.setDeviceAudioMute": {
32549
+ capName: "stream-broker",
32550
+ capScope: "system",
32551
+ addonId: null,
32552
+ access: "create"
32553
+ },
31023
32554
  "streamBroker.setPreBufferDuration": {
31024
32555
  capName: "stream-broker",
31025
32556
  capScope: "system",
@@ -33134,6 +34665,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
33134
34665
  ownDeviceId: this.id,
33135
34666
  refresh: refreshFromCamera,
33136
34667
  staleMs: STALE_MS,
34668
+ logger: this.ctx.logger,
33137
34669
  empty: () => ({ lastFetchedAt: 0 })
33138
34670
  });
33139
34671
  const provider = {
@@ -33225,6 +34757,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
33225
34757
  ownDeviceId: this.id,
33226
34758
  refresh: refreshFromCamera,
33227
34759
  staleMs: STALE_MS,
34760
+ logger: this.ctx.logger,
33228
34761
  empty: () => ({
33229
34762
  enabled: false,
33230
34763
  sensitivity: 0,
@@ -33327,6 +34860,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
33327
34860
  ownDeviceId: this.id,
33328
34861
  refresh: refreshFromCamera,
33329
34862
  staleMs: STALE_MS,
34863
+ logger: this.ctx.logger,
33330
34864
  empty: () => ({
33331
34865
  enabled: false,
33332
34866
  regions: [],
@@ -33483,6 +35017,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
33483
35017
  ownDeviceId: this.id,
33484
35018
  refresh: refreshFromCamera,
33485
35019
  staleMs: STALE_MS,
35020
+ logger: this.ctx.logger,
33486
35021
  empty: () => ({
33487
35022
  mode: "auto",
33488
35023
  lastFetchedAt: 0
@@ -33573,6 +35108,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
33573
35108
  ownDeviceId: this.id,
33574
35109
  refresh: refreshFromCamera,
33575
35110
  staleMs: STALE_MS,
35111
+ logger: this.ctx.logger,
33576
35112
  empty: () => ({ lastFetchedAt: 0 })
33577
35113
  }).getStatus,
33578
35114
  getOptions: async ({ deviceId }) => {