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