@camstack/addon-export-hap 1.2.16 → 1.2.18

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.
@@ -48,7 +48,7 @@ function carryForward(base, existing, keys) {
48
48
  return out;
49
49
  }
50
50
  //#endregion
51
- //#region ../types/dist/event-category-41fKf-q9.mjs
51
+ //#region ../types/dist/event-category-Cv9dO26A.mjs
52
52
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
53
53
  EventCategory["SystemBoot"] = "system.boot";
54
54
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -64,6 +64,13 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
64
64
  */
65
65
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
66
66
  /**
67
+ * A newer addon or server-root package version was found by the
68
+ * authoritative registry check. Emitted once per
69
+ * `(target, packageName, currentVersion, latestVersion)` transition; repeated
70
+ * polling of the same result is deduplicated by the checker.
71
+ */
72
+ EventCategory["UpdateAvailable"] = "update.available";
73
+ /**
67
74
  * Readiness transition for a capability provider. Every producer emits
68
75
  * this event on `onInitialize` completion, `onDestroy`, and
69
76
  * `$node.reconnect`; every consumer that needs to gate on a cross-process
@@ -7054,7 +7061,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
7054
7061
  input: unknown()
7055
7062
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
7056
7063
  //#endregion
7057
- //#region ../types/dist/fmp4-box-splitter-B53u9-Nu.mjs
7064
+ //#region ../types/dist/canonical-hash-rO1sRmEK.mjs
7058
7065
  var AUDIO_ENCODER_BY_CODEC = {
7059
7066
  opus: "libopus",
7060
7067
  aac: "aac",
@@ -7348,37 +7355,6 @@ function buildFfmpegArgs(inv) {
7348
7355
  ...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
7349
7356
  ];
7350
7357
  }
7351
- /**
7352
- * Deterministic SHA-256 hash of an arbitrary serialisable value. The
7353
- * canonical form sorts object keys alphabetically at every depth so two
7354
- * structurally-equal inputs with different key insertion orders produce
7355
- * the same hash. Returns a 64-char lowercase hex digest.
7356
- *
7357
- * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
7358
- * accessory-rebuild work when the upstream shape is byte-identical to
7359
- * the last applied state — preventing user-visible "re-discovery"
7360
- * notifications on every addon-runner respawn. Each respawn re-fires
7361
- * `DeviceBindingsChanged` for every cap registration, which without
7362
- * this guard would propagate redundant pushes.
7363
- *
7364
- * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
7365
- * subscription. The proper fix is a single "device ready" lifecycle
7366
- * barrier so exports react only when the full cap set has landed —
7367
- * tracked separately for post-HA-integration work.
7368
- */
7369
- function canonicalHash(value) {
7370
- const canonical = JSON.stringify(value, replaceWithSortedKeys);
7371
- return createHash("sha256").update(canonical ?? "").digest("hex");
7372
- }
7373
- function replaceWithSortedKeys(_key, value) {
7374
- if (value && typeof value === "object" && !Array.isArray(value)) {
7375
- const obj = value;
7376
- const out = {};
7377
- for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7378
- return out;
7379
- }
7380
- return value;
7381
- }
7382
7358
  var DEFAULT_MAX_UNIT_BYTES = 16 * 1024 * 1024;
7383
7359
  /** Header size for a normal box, and for one carrying a 64-bit `largesize`. */
7384
7360
  var BOX_HEADER_BYTES = 8;
@@ -7551,6 +7527,37 @@ var Fmp4BoxSplitter = class {
7551
7527
  return [];
7552
7528
  }
7553
7529
  };
7530
+ /**
7531
+ * Deterministic SHA-256 hash of an arbitrary serialisable value. The
7532
+ * canonical form sorts object keys alphabetically at every depth so two
7533
+ * structurally-equal inputs with different key insertion orders produce
7534
+ * the same hash. Returns a 64-char lowercase hex digest.
7535
+ *
7536
+ * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
7537
+ * accessory-rebuild work when the upstream shape is byte-identical to
7538
+ * the last applied state — preventing user-visible "re-discovery"
7539
+ * notifications on every addon-runner respawn. Each respawn re-fires
7540
+ * `DeviceBindingsChanged` for every cap registration, which without
7541
+ * this guard would propagate redundant pushes.
7542
+ *
7543
+ * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
7544
+ * subscription. The proper fix is a single "device ready" lifecycle
7545
+ * barrier so exports react only when the full cap set has landed —
7546
+ * tracked separately for post-HA-integration work.
7547
+ */
7548
+ function canonicalHash(value) {
7549
+ const canonical = JSON.stringify(value, replaceWithSortedKeys);
7550
+ return createHash("sha256").update(canonical ?? "").digest("hex");
7551
+ }
7552
+ function replaceWithSortedKeys(_key, value) {
7553
+ if (value && typeof value === "object" && !Array.isArray(value)) {
7554
+ const obj = value;
7555
+ const out = {};
7556
+ for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7557
+ return out;
7558
+ }
7559
+ return value;
7560
+ }
7554
7561
  //#endregion
7555
7562
  //#region ../types/dist/err-msg-IQTHeDzc.mjs
7556
7563
  /**
@@ -7730,6 +7737,104 @@ object({
7730
7737
  })
7731
7738
  });
7732
7739
  /**
7740
+ * Adoption job — the background form of `device-adoption.adopt`.
7741
+ *
7742
+ * ## Why this exists
7743
+ *
7744
+ * `adopt({childNativeIds: [...]})` materialises one CamStack device per
7745
+ * candidate PLUS every accessory child, and the whole array shares ONE UDS
7746
+ * request deadline (60s). Measured on the live hub against Home Assistant:
7747
+ * each device the kernel creates costs ~450 ms — `devices.create` pre-seeds
7748
+ * meta with up to eleven SEQUENTIAL round trips (`setName`, `setType`,
7749
+ * `setRole`, … `persistConfig`) before the class is constructed — and an
7750
+ * accessory child costs the same as its parent. So the real unit of work is
7751
+ * the CHILD, not the candidate:
7752
+ *
7753
+ * - 25 candidates averaging 6 children → ~150 devices → **>60s, times out**
7754
+ * - ONE candidate with 217 children → ~217 devices → **>60s, times out**
7755
+ *
7756
+ * That second line is why this is a job and not a smaller batch. No chunking,
7757
+ * no bounded concurrency over candidates and no per-call tuning can fix a
7758
+ * shape where **N=1 already exceeds the deadline** — the count that blows the
7759
+ * budget is the source system's accessory fan-out, which the operator does not
7760
+ * choose and cannot see. A design that only works below some N is the same bug
7761
+ * deferred.
7762
+ *
7763
+ * ## What the timeout did NOT do
7764
+ *
7765
+ * It did not stop the work. The UDS deadline ends the CALLER's wait; the
7766
+ * provider's loop runs to completion. Measured: a 25-candidate adopt that
7767
+ * "failed" at 60s had adopted 17 by 87s and all 25 by ~130s. The operator saw
7768
+ * an error and had no way to learn that. Every field below exists so that
7769
+ * question has an answer.
7770
+ *
7771
+ * ## Idempotency
7772
+ *
7773
+ * Jobs are in-RAM; a restart forgets them. That is safe here because adoption
7774
+ * is keyed by a stable id (`ha:<broker>:dev:<nativeId>` and equivalents), so
7775
+ * re-running a job re-adopts nothing: an already-adopted candidate is SKIPPED
7776
+ * by the engine before any provider call and lands in `alreadyAdopted`. It is
7777
+ * never a duplicate device, and never an error the operator has to interpret.
7778
+ */
7779
+ var AdoptionJobStateSchema = _enum([
7780
+ "running",
7781
+ "done",
7782
+ "failed",
7783
+ "cancelled"
7784
+ ]);
7785
+ /**
7786
+ * Per-candidate result. Every candidate the job was asked to adopt ends in
7787
+ * exactly one of these buckets — there is no silent drop, and the operator can
7788
+ * always answer "which of my 25 landed?".
7789
+ *
7790
+ * - `adopted` — created now by this job.
7791
+ * - `already-adopted` — a device for this candidate existed before the job
7792
+ * reached it (a re-run, or a retry after a timeout). Not an error.
7793
+ * - `failed` — the provider threw; `error` carries the message.
7794
+ * - `cancelled` — the operator cancelled before this candidate was reached.
7795
+ */
7796
+ var AdoptionOutcomeSchema = _enum([
7797
+ "adopted",
7798
+ "already-adopted",
7799
+ "failed",
7800
+ "cancelled"
7801
+ ]);
7802
+ var AdoptionCandidateResultSchema = object({
7803
+ childNativeId: string(),
7804
+ outcome: AdoptionOutcomeSchema,
7805
+ /** The materialised parent device id — null for `failed` / `cancelled`. */
7806
+ parentDeviceId: number().int().nonnegative().nullable(),
7807
+ /** Accessory children created for this candidate. */
7808
+ accessoryCount: number().int().nonnegative(),
7809
+ /** Failure message; null unless `outcome === 'failed'`. */
7810
+ error: string().nullable()
7811
+ });
7812
+ var AdoptionJobSchema = object({
7813
+ jobId: string(),
7814
+ /** The integration provider this job adopts through (the `addonId` pin). */
7815
+ addonId: string(),
7816
+ integrationId: string(),
7817
+ state: AdoptionJobStateSchema,
7818
+ /** Candidates the job was asked to adopt. Known up front, so never null. */
7819
+ total: number().int().nonnegative(),
7820
+ /** Candidates that have reached a terminal bucket. */
7821
+ processed: number().int().nonnegative(),
7822
+ adopted: number().int().nonnegative(),
7823
+ alreadyAdopted: number().int().nonnegative(),
7824
+ failed: number().int().nonnegative(),
7825
+ /** Accessory child devices created across every candidate — the real unit
7826
+ * of work, surfaced so a slow job is legible rather than mysterious. */
7827
+ accessoriesCreated: number().int().nonnegative(),
7828
+ /** The candidate currently being adopted; null when idle or finished. */
7829
+ currentChildNativeId: string().nullable(),
7830
+ /** One entry per candidate, in the order they were processed. */
7831
+ results: array(AdoptionCandidateResultSchema).readonly(),
7832
+ startedAt: number(),
7833
+ finishedAt: number().nullable(),
7834
+ /** Set only when the job itself broke (not a per-candidate failure). */
7835
+ error: string().nullable()
7836
+ });
7837
+ /**
7733
7838
  * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7734
7839
  * pipeline functions an operator thinks in terms of.
7735
7840
  *
@@ -8603,358 +8708,1132 @@ var ConvertResultSchema = object({
8603
8708
  })).readonly()
8604
8709
  });
8605
8710
  /**
8606
- * `addon-pages` system-scoped singleton aggregator cap. Public-facing
8607
- * surface that admin-ui consumes through `useAddonPagesListPages()`.
8608
- *
8609
- * The provider iterates every `addon-pages-source` (collection) provider
8610
- * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
8611
- * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
8612
- * filesystem `mtime` cache-buster lets the browser pick up addon
8613
- * rebuilds without manual reload.
8614
- *
8615
- * The hub-local builtin `addon-pages-aggregator` (see
8616
- * `@camstack/system/builtins/addon-pages-aggregator`) registers the
8617
- * provider. Splitting the public aggregator from the raw collection
8618
- * keeps both ends in codegen — there's no hand-written
8619
- * `addon-pages.router.ts` wrapper anymore.
8711
+ * Error types for the safe expression engine. Two distinct classes so callers
8712
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
8713
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
8620
8714
  */
8621
- var AddonPageDeclarationSchema$1 = object({
8622
- id: string(),
8623
- label: string(),
8624
- icon: string(),
8625
- path: string(),
8626
- remoteName: string(),
8627
- bundle: string(),
8628
- section: string().optional(),
8629
- sectionLabel: string().optional()
8630
- });
8631
- var AddonPageInfoSchema = object({
8632
- addonId: string(),
8633
- page: AddonPageDeclarationSchema$1,
8634
- bundleUrl: string()
8635
- });
8636
- method(_void(), array(AddonPageInfoSchema).readonly());
8715
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
8716
+ * the failure is anchored to a character (author-facing inline feedback). */
8717
+ var ExpressionParseError = class extends Error {
8718
+ position;
8719
+ constructor(message, position) {
8720
+ super(message);
8721
+ this.name = "ExpressionParseError";
8722
+ this.position = position;
8723
+ }
8724
+ };
8725
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
8726
+ * result, unknown builtin, step-budget exceeded). */
8727
+ var ExpressionEvalError = class extends Error {
8728
+ constructor(message) {
8729
+ super(message);
8730
+ this.name = "ExpressionEvalError";
8731
+ }
8732
+ };
8637
8733
  /**
8638
- * `addon-pages-source` collection cap exposing per-provider raw page
8639
- * declarations. Every addon that contributes a UI page registers a
8640
- * provider here. The hub-side singleton aggregator (`addon-pages` cap,
8641
- * see `addon-pages.cap.ts`) walks this collection, stamps versioned
8642
- * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
8643
- * that admin-ui consumes.
8734
+ * Frozen, null-prototype builtin function table for the expression engine
8735
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
8736
+ * parser rejects any callee not in it, and the evaluator gates each call on an
8737
+ * own-property check against it.
8644
8738
  *
8645
- * The split exists because the public listing has a different output
8646
- * shape than the per-provider raw declarations, and we want both ends
8647
- * to flow through codegen instead of relying on a hand-written wrapper.
8648
- */
8649
- var AddonPageDeclarationSchema = object({
8650
- id: string(),
8651
- label: string(),
8652
- icon: string(),
8653
- path: string(),
8654
- /**
8655
- * Module Federation remote name — must match the `name` field on the
8656
- * page addon's `federation()` plugin config. Used by admin-ui's
8657
- * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
8658
- * Conventionally `addon_<id>_page` (snake_case; MF names cannot
8659
- * contain hyphens).
8660
- */
8661
- remoteName: string(),
8662
- /**
8663
- * Bundle filename inside the addon's `dist/` dir served at
8664
- * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
8665
- * is always `'remoteEntry.js'`; the value is kept on the metadata so
8666
- * the static-file route can compute an mtime-based cache-buster URL
8667
- * without a separate filesystem stat.
8668
- */
8669
- bundle: string(),
8670
- /**
8671
- * Sidebar section this page docks into. Well-known ids: `'detection'`,
8672
- * `'cluster'`, `'administration'` — the page renders inside that group.
8673
- * Any OTHER string creates (or joins) a custom section rendered after
8674
- * the built-in groups; its label comes from `sectionLabel` (first
8675
- * declaration wins), falling back to the id. Absent → the legacy
8676
- * "Addon Pages" group.
8677
- */
8678
- section: string().optional(),
8679
- /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
8680
- sectionLabel: string().optional()
8681
- });
8682
- method(_void(), array(AddonPageDeclarationSchema).readonly());
8683
- var AddonHttpRouteSchema = object({
8684
- method: _enum([
8685
- "GET",
8686
- "POST",
8687
- "PUT",
8688
- "DELETE",
8689
- "PATCH"
8690
- ]),
8691
- path: string(),
8692
- access: _enum([
8693
- "public",
8694
- "authenticated",
8695
- "admin"
8696
- ]).optional(),
8697
- description: string().optional()
8698
- });
8699
- /**
8700
- * Cross-process route invocation envelope. The hub captures the
8701
- * request as plain data, ships it to the worker via Moleculer, and
8702
- * the worker runs the local handler against a capturing reply. The
8703
- * envelope returned describes what the handler intended (status,
8704
- * headers, body, or a redirect) so the hub can translate it back to
8705
- * the Fastify reply that's actually wired to the socket.
8739
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
8740
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
8741
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
8742
+ * (there is no `Object.prototype` in the chain), so those names are not
8743
+ * callable they are simply "unknown function" at parse time.
8744
+ *
8745
+ * Every numeric argument is validated as a finite number and every numeric
8746
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
8747
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
8748
+ * closed rather than emitting a garbage value.
8706
8749
  */
8707
- var InvokeRequestSchema = object({
8708
- method: string(),
8709
- path: string(),
8710
- params: record(string(), string()),
8711
- query: record(string(), string()),
8712
- body: unknown(),
8713
- headers: record(string(), string()),
8714
- user: object({
8715
- id: string(),
8716
- username: string(),
8717
- isAdmin: boolean()
8718
- }).optional(),
8719
- scopedToken: unknown().optional()
8720
- });
8721
- var InvokeReplyEnvelopeSchema = object({
8722
- status: number().int(),
8723
- headers: record(string(), string()),
8724
- /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
8725
- * sending `body`. Status defaults to 302 when this is set unless
8726
- * the handler called `reply.code(...)` explicitly. */
8727
- redirectUrl: string().nullable(),
8728
- /** JSON-serializable body. `undefined` is treated as "no body". */
8729
- body: unknown().optional(),
8730
- /** Set when the handler called `reply.type(mime)`. */
8731
- contentType: string().optional()
8732
- });
8733
- method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
8734
- var ConfigTabDeclarationSchema = object({
8735
- id: string(),
8736
- label: string(),
8737
- icon: string(),
8738
- order: number().optional()
8739
- });
8740
- var ConfigSectionWithValuesSchema = object({
8741
- id: string(),
8742
- title: string(),
8743
- description: string().optional(),
8744
- style: _enum(["card", "accordion"]).optional(),
8745
- defaultCollapsed: boolean().optional(),
8746
- columns: union([
8747
- literal(1),
8748
- literal(2),
8749
- literal(3),
8750
- literal(4)
8751
- ]).optional(),
8752
- tab: string().optional(),
8753
- location: _enum(["settings", "top-tab"]).optional(),
8754
- order: number().optional(),
8755
- fields: array(any())
8756
- });
8757
- var SettingsSchemaWithValuesSchema = object({
8758
- tabs: array(ConfigTabDeclarationSchema).optional(),
8759
- sections: array(ConfigSectionWithValuesSchema)
8760
- });
8761
- /** Patch object keys are field names, values are the new field values. */
8762
- var SettingsPatchSchema = record(string(), unknown());
8763
- /** Standard success response for update operations. */
8764
- var SettingsUpdateResultSchema = object({ success: literal(true) });
8765
- method(object({
8766
- addonId: string(),
8767
- nodeId: string().optional(),
8768
- overlay: record(string(), unknown()).optional(),
8769
- cap: string().optional()
8770
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8771
- addonId: string(),
8772
- nodeId: string().optional(),
8773
- patch: SettingsPatchSchema
8774
- }), SettingsUpdateResultSchema, {
8775
- kind: "mutation",
8776
- auth: "admin"
8777
- }), method(object({
8778
- addonId: string(),
8779
- deviceId: number(),
8780
- nodeId: string().optional()
8781
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8782
- addonId: string(),
8783
- deviceId: number(),
8784
- nodeId: string().optional(),
8785
- patch: SettingsPatchSchema
8786
- }), SettingsUpdateResultSchema, {
8787
- kind: "mutation",
8788
- auth: "admin"
8789
- });
8750
+ function asFiniteNumber(value, name, index) {
8751
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
8752
+ return value;
8753
+ }
8754
+ function asString$1(value, name, index) {
8755
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
8756
+ return value;
8757
+ }
8758
+ function finiteResult(value, name) {
8759
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
8760
+ return value;
8761
+ }
8762
+ function allFiniteNumbers(args, name) {
8763
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
8764
+ }
8765
+ var INF = Number.POSITIVE_INFINITY;
8766
+ var table = {
8767
+ min: {
8768
+ minArgs: 1,
8769
+ maxArgs: INF,
8770
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
8771
+ },
8772
+ max: {
8773
+ minArgs: 1,
8774
+ maxArgs: INF,
8775
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
8776
+ },
8777
+ abs: {
8778
+ minArgs: 1,
8779
+ maxArgs: 1,
8780
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
8781
+ },
8782
+ floor: {
8783
+ minArgs: 1,
8784
+ maxArgs: 1,
8785
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
8786
+ },
8787
+ ceil: {
8788
+ minArgs: 1,
8789
+ maxArgs: 1,
8790
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
8791
+ },
8792
+ sqrt: {
8793
+ minArgs: 1,
8794
+ maxArgs: 1,
8795
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
8796
+ },
8797
+ round: {
8798
+ minArgs: 1,
8799
+ maxArgs: 2,
8800
+ apply: (args) => {
8801
+ const x = asFiniteNumber(args[0], "round", 0);
8802
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
8803
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
8804
+ const factor = 10 ** digits;
8805
+ return finiteResult(Math.round(x * factor) / factor, "round");
8806
+ }
8807
+ },
8808
+ pow: {
8809
+ minArgs: 2,
8810
+ maxArgs: 2,
8811
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
8812
+ },
8813
+ clamp: {
8814
+ minArgs: 3,
8815
+ maxArgs: 3,
8816
+ apply: (args) => {
8817
+ const x = asFiniteNumber(args[0], "clamp", 0);
8818
+ const lo = asFiniteNumber(args[1], "clamp", 1);
8819
+ const hi = asFiniteNumber(args[2], "clamp", 2);
8820
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
8821
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
8822
+ }
8823
+ },
8824
+ avg: {
8825
+ minArgs: 1,
8826
+ maxArgs: INF,
8827
+ apply: (args) => {
8828
+ const nums = allFiniteNumbers(args, "avg");
8829
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
8830
+ }
8831
+ },
8832
+ sum: {
8833
+ minArgs: 1,
8834
+ maxArgs: INF,
8835
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
8836
+ },
8837
+ coalesce: {
8838
+ minArgs: 1,
8839
+ maxArgs: INF,
8840
+ apply: (args) => {
8841
+ for (const a of args) if (a !== null) return a;
8842
+ return null;
8843
+ }
8844
+ },
8845
+ age: {
8846
+ minArgs: 2,
8847
+ maxArgs: 2,
8848
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
8849
+ },
8850
+ convert: {
8851
+ minArgs: 3,
8852
+ maxArgs: 3,
8853
+ apply: (args, hooks) => {
8854
+ const x = asFiniteNumber(args[0], "convert", 0);
8855
+ const from = asString$1(args[1], "convert", 1).trim();
8856
+ const to = asString$1(args[2], "convert", 2).trim();
8857
+ if (hooks.convert) {
8858
+ const out = hooks.convert(x, from, to);
8859
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
8860
+ return finiteResult(out, "convert");
8861
+ }
8862
+ if (from === to) return x;
8863
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
8864
+ }
8865
+ }
8866
+ };
8867
+ Object.freeze(Object.assign(Object.create(null), table));
8868
+ /** The set of valid builtin names — used by the parser to reject unknown
8869
+ * callees at parse time (immediate author feedback). */
8870
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
8790
8871
  /**
8791
- * `addon-widgets-source` collection cap exposing per-addon raw widget
8792
- * declarations. Mirrors the addon-pages split: every addon shipping
8793
- * widgets registers a provider on this collection cap; the hub-local
8794
- * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
8795
- * collection, stamps versioned `bundleUrl`s onto each declaration, and
8796
- * exposes the public listing surface that admin-ui consumes.
8797
- *
8798
- * The split exists because the public listing has a different output
8799
- * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
8800
- * per-provider raw declarations. Both ends flow through codegen.
8872
+ * Resource-bound constants for the safe expression engine.
8801
8873
  *
8802
- * Unified UI-contribution model (Task 10): a widget descriptor IS a
8803
- * `UiContribution` with `kind:'remote'`. The host renders it through the
8804
- * same `ContributionRenderer` / Module-Federation path as every other
8805
- * contributed UI surface no bespoke widget-rendering path. The widget-
8806
- * only metadata (sizing hints, `requires`) lives as extra fields on the
8807
- * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
8808
- * `kind` / `remote`) carries identity + placement + the MF remote.
8874
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
8875
+ * loops, recursion, lambdas or member access see `ast.ts`), so evaluation is
8876
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
8877
+ * work a single author-supplied expression can request, so a hostile or
8878
+ * accidental pathological string can never spend unbounded CPU/memory.
8809
8879
  */
8810
- /** Where the widget makes sense to render maps to a contribution `tab`. */
8811
- var WidgetHostEnum = _enum([
8812
- "device-tab",
8813
- "dashboard",
8814
- "integration-detail"
8815
- ]);
8816
- var WidgetSizeEnum = _enum([
8817
- "xs",
8818
- "sm",
8819
- "md",
8820
- "lg",
8821
- "xl"
8880
+ /** Max source length (chars) checked BEFORE tokenizing so a huge string is
8881
+ * rejected without allocation. */
8882
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
8883
+ /** A legal binding / identifier name. */
8884
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
8885
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
8886
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
8887
+ var RESERVED_BINDING_NAMES = new Set([
8888
+ "now",
8889
+ "true",
8890
+ "false",
8891
+ "null"
8822
8892
  ]);
8823
8893
  /**
8824
- * MF remote descriptor mirrors `UiContributionRemote` from
8825
- * `capability-definition.ts`. Widget remotes expose a single
8826
- * `'./widgets'` module whose default export is a
8827
- * `Record<componentKey, Component>` map; `componentKey` (the widget
8828
- * `stableId`) picks the entry the host mounts.
8894
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
8895
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
8896
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
8897
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
8898
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
8899
+ * is a parse error with a source position, so member access / assignment /
8900
+ * template literals are lexically impossible.
8829
8901
  */
8830
- var WidgetRemoteSchema = object({
8831
- remoteName: string(),
8832
- exposedModule: string(),
8833
- componentKey: string().optional()
8834
- });
8835
- /**
8836
- * One widget declaration a `UiContribution` (`kind:'remote'`) plus
8837
- * widget-only metadata. The `UiContribution` core fields:
8838
- *
8839
- * - `tab` — where the widget hosts. A widget that runs on the
8840
- * dashboard declares `tab:'dashboard'`; a device-tab
8841
- * widget declares the target device-detail tab id.
8842
- * - `subTab` — optional sub-tab within `tab`.
8843
- * - `label` — operator-facing label.
8844
- * - `order` — ordering within `(tab, subTab)`.
8845
- * - `kind` — always `'remote'` for widgets.
8846
- * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
8847
- *
8848
- * Widget-only fields retained alongside the contribution core:
8849
- *
8850
- * - `stableId` — stable identity within the addon (the MF
8851
- * `componentKey`; kept top-level so consumers have
8852
- * a stable key without reaching into `remote`).
8853
- * - `description` / `icon` — picker metadata.
8854
- * - `bundle` — entry filename inside the addon `dist/` dir; the
8855
- * aggregator stamps a versioned `bundleUrl` from it.
8856
- * - `hosts` — every host the widget supports (a widget can run
8857
- * both on the dashboard and a device tab). `tab`
8858
- * is the PRIMARY host; `hosts` is the full set the
8859
- * picker filters on.
8860
- * - `requires` — host-context requirements validated at mount.
8861
- * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
8862
- * — dashboard placement hints.
8863
- */
8864
- var WidgetMetadataSchema = object({
8865
- /** Primary host tab `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
8866
- tab: string(),
8867
- /** Optional sub-tab within `tab`. */
8868
- subTab: string().optional(),
8869
- /** Operator-facing label. */
8870
- label: string(),
8871
- /** Ordering within `(tab, subTab)`, ascending. */
8872
- order: number().optional(),
8873
- /** Always `'remote'` — a widget is a Module Federation remote. */
8874
- kind: literal("remote"),
8875
- /** MF remote descriptor. */
8876
- remote: WidgetRemoteSchema,
8877
- /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
8878
- stableId: string(),
8879
- description: string().optional(),
8880
- icon: string().optional(),
8881
- /**
8882
- * Bundle filename inside the addon's `dist/` dir served at
8883
- * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
8884
- * this is always `'remoteEntry.js'` — the value is kept on the
8885
- * metadata so the static-file route can compute an mtime-based
8886
- * cache-buster URL without a separate filesystem stat.
8887
- */
8888
- bundle: string(),
8889
- /** Every host the widget supports. The picker filters on this set. */
8890
- hosts: array(WidgetHostEnum).readonly(),
8891
- /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
8892
- requires: object({
8893
- deviceContext: boolean().default(false),
8894
- integrationContext: boolean().default(false)
8895
- }),
8896
- /**
8897
- * Loadable BEFORE authentication. The normal widget registry listing
8898
- * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
8899
- * (the login page) cannot discover a widget through it. A widget that
8900
- * declares `preAuth: true` marks itself as safe to mount on a pre-auth
8901
- * screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
8902
- * login-method contribution channel (see `login-method.cap.ts`) rather
8903
- * than the authenticated registry, and its bundle is served by the
8904
- * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
8905
- */
8906
- preAuth: boolean().optional().default(false),
8907
- /** Dashboard placement HINTS (operator can override per instance). */
8908
- defaultSize: WidgetSizeEnum.default("md"),
8909
- allowedSizes: array(WidgetSizeEnum).readonly().default([
8910
- "sm",
8911
- "md",
8912
- "lg"
8913
- ]),
8914
- defaultColumns: number().int().min(1).max(12).default(6),
8915
- defaultRows: number().int().min(1).max(12).default(1)
8916
- });
8917
- method(_void(), array(WidgetMetadataSchema).readonly());
8902
+ var KEYWORDS = new Set([
8903
+ "true",
8904
+ "false",
8905
+ "null"
8906
+ ]);
8907
+ function isDigit(ch) {
8908
+ return ch >= "0" && ch <= "9";
8909
+ }
8910
+ function isIdentStart(ch) {
8911
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
8912
+ }
8913
+ function isIdentPart(ch) {
8914
+ return isIdentStart(ch) || isDigit(ch);
8915
+ }
8916
+ function isWhitespace(ch) {
8917
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
8918
+ }
8919
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
8920
+ * Throws `ExpressionParseError` on any illegal character or unterminated
8921
+ * string. */
8922
+ function tokenize(source) {
8923
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
8924
+ const tokens = [];
8925
+ let i = 0;
8926
+ const n = source.length;
8927
+ while (i < n) {
8928
+ const ch = source[i];
8929
+ if (isWhitespace(ch)) {
8930
+ i += 1;
8931
+ continue;
8932
+ }
8933
+ if (isDigit(ch)) {
8934
+ const start = i;
8935
+ while (i < n && isDigit(source[i])) i += 1;
8936
+ if (i < n && source[i] === ".") {
8937
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
8938
+ i += 1;
8939
+ while (i < n && isDigit(source[i])) i += 1;
8940
+ }
8941
+ const text = source.slice(start, i);
8942
+ const value = Number(text);
8943
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
8944
+ tokens.push({
8945
+ type: "number",
8946
+ value,
8947
+ pos: start
8948
+ });
8949
+ continue;
8950
+ }
8951
+ if (ch === "'" || ch === "\"") {
8952
+ const quote = ch;
8953
+ const start = i;
8954
+ i += 1;
8955
+ let out = "";
8956
+ let closed = false;
8957
+ while (i < n) {
8958
+ const c = source[i];
8959
+ if (c === "\\") {
8960
+ const next = i + 1 < n ? source[i + 1] : "";
8961
+ if (next === "\\" || next === "'" || next === "\"") {
8962
+ out += next;
8963
+ i += 2;
8964
+ continue;
8965
+ }
8966
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
8967
+ }
8968
+ if (c === quote) {
8969
+ closed = true;
8970
+ i += 1;
8971
+ break;
8972
+ }
8973
+ out += c;
8974
+ i += 1;
8975
+ }
8976
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
8977
+ tokens.push({
8978
+ type: "string",
8979
+ value: out,
8980
+ pos: start
8981
+ });
8982
+ continue;
8983
+ }
8984
+ if (isIdentStart(ch)) {
8985
+ const start = i;
8986
+ while (i < n && isIdentPart(source[i])) i += 1;
8987
+ const text = source.slice(start, i);
8988
+ if (KEYWORDS.has(text)) tokens.push({
8989
+ type: "keyword",
8990
+ keyword: keywordOf(text),
8991
+ pos: start
8992
+ });
8993
+ else tokens.push({
8994
+ type: "identifier",
8995
+ name: text,
8996
+ pos: start
8997
+ });
8998
+ continue;
8999
+ }
9000
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
9001
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
9002
+ tokens.push({
9003
+ type: "punct",
9004
+ punct: two,
9005
+ pos: i
9006
+ });
9007
+ i += 2;
9008
+ continue;
9009
+ }
9010
+ if (isSinglePunct(ch)) {
9011
+ tokens.push({
9012
+ type: "punct",
9013
+ punct: ch,
9014
+ pos: i
9015
+ });
9016
+ i += 1;
9017
+ continue;
9018
+ }
9019
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
9020
+ }
9021
+ tokens.push({
9022
+ type: "eof",
9023
+ pos: n
9024
+ });
9025
+ return tokens;
9026
+ }
9027
+ function keywordOf(text) {
9028
+ if (text === "true") return "true";
9029
+ if (text === "false") return "false";
9030
+ return "null";
9031
+ }
9032
+ function isSinglePunct(ch) {
9033
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
9034
+ }
8918
9035
  /**
8919
- * `addon-widgets` system-scoped singleton aggregator cap. Public-facing
8920
- * surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
9036
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
8921
9037
  *
8922
- * The provider iterates every `addon-widgets-source` (collection)
8923
- * provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
8924
- * `bundleUrl` strings pointing at
8925
- * `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
8926
- * `mtime` cache-buster lets the browser pick up addon rebuilds without
8927
- * manual reload same scheme used by `addon-pages`.
9038
+ * Precedence (low high): ternary `?:` (right-assoc) → `||` → `&&` → equality
9039
+ * relational additive multiplicative → unary `! -` → call / primary.
9040
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
9041
+ * string validated against the builtin table at parse time, so an unknown
9042
+ * function is rejected immediately (author feedback) and a persisted expression
9043
+ * that references a since-removed builtin degrades at read.
8928
9044
  *
8929
- * The hub-local builtin `addon-widgets-aggregator` (see
8930
- * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
8931
- * provider. Splitting the public aggregator from the raw collection
8932
- * keeps both ends in codegen — there's no hand-written wrapper.
9045
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
9046
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) both raise `ExpressionParseError`.
8933
9047
  */
8934
- var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
8935
- addonId: string(),
8936
- bundleUrl: string()
8937
- });
8938
- method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
9048
+ /** Binary/logical operator precedence (higher binds tighter). */
9049
+ var BINARY_PRECEDENCE = {
9050
+ "||": 1,
9051
+ "&&": 2,
9052
+ "==": 3,
9053
+ "!=": 3,
9054
+ "<": 4,
9055
+ "<=": 4,
9056
+ ">": 4,
9057
+ ">=": 4,
9058
+ "+": 5,
9059
+ "-": 5,
9060
+ "*": 6,
9061
+ "/": 6,
9062
+ "%": 6
9063
+ };
9064
+ function isLogicalOp(op) {
9065
+ return op === "&&" || op === "||";
9066
+ }
9067
+ function isBinaryOp(op) {
9068
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
9069
+ }
9070
+ var Parser = class {
9071
+ tokens;
9072
+ pos = 0;
9073
+ nodeCount = 0;
9074
+ identifiers = /* @__PURE__ */ new Set();
9075
+ callees = /* @__PURE__ */ new Set();
9076
+ constructor(tokens) {
9077
+ this.tokens = tokens;
9078
+ }
9079
+ parse() {
9080
+ const ast = this.parseTernary();
9081
+ const tok = this.peek();
9082
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
9083
+ return {
9084
+ ast,
9085
+ identifiers: this.identifiers,
9086
+ callees: this.callees,
9087
+ nodeCount: this.nodeCount
9088
+ };
9089
+ }
9090
+ peek() {
9091
+ return this.tokens[this.pos];
9092
+ }
9093
+ next() {
9094
+ return this.tokens[this.pos++];
9095
+ }
9096
+ /** Consume a punctuator token, erroring if the next token isn't it. */
9097
+ expectPunct(punct) {
9098
+ const tok = this.peek();
9099
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
9100
+ this.pos += 1;
9101
+ }
9102
+ matchPunct(punct) {
9103
+ const tok = this.peek();
9104
+ if (tok.type === "punct" && tok.punct === punct) {
9105
+ this.pos += 1;
9106
+ return true;
9107
+ }
9108
+ return false;
9109
+ }
9110
+ countNode() {
9111
+ this.nodeCount += 1;
9112
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
9113
+ }
9114
+ parseTernary() {
9115
+ const test = this.parseBinary(1);
9116
+ if (this.matchPunct("?")) {
9117
+ const consequent = this.parseTernary();
9118
+ this.expectPunct(":");
9119
+ const alternate = this.parseTernary();
9120
+ this.countNode();
9121
+ return {
9122
+ kind: "conditional",
9123
+ test,
9124
+ consequent,
9125
+ alternate
9126
+ };
9127
+ }
9128
+ return test;
9129
+ }
9130
+ parseBinary(minPrec) {
9131
+ let left = this.parseUnary();
9132
+ for (;;) {
9133
+ const tok = this.peek();
9134
+ if (tok.type !== "punct") break;
9135
+ const prec = BINARY_PRECEDENCE[tok.punct];
9136
+ if (prec === void 0 || prec < minPrec) break;
9137
+ const op = tok.punct;
9138
+ this.pos += 1;
9139
+ const right = this.parseBinary(prec + 1);
9140
+ this.countNode();
9141
+ if (isLogicalOp(op)) left = {
9142
+ kind: "logical",
9143
+ op,
9144
+ left,
9145
+ right
9146
+ };
9147
+ else if (isBinaryOp(op)) left = {
9148
+ kind: "binary",
9149
+ op,
9150
+ left,
9151
+ right
9152
+ };
9153
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
9154
+ }
9155
+ return left;
9156
+ }
9157
+ parseUnary() {
9158
+ const tok = this.peek();
9159
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
9160
+ const op = tok.punct;
9161
+ this.pos += 1;
9162
+ const operand = this.parseUnary();
9163
+ this.countNode();
9164
+ return {
9165
+ kind: "unary",
9166
+ op,
9167
+ operand
9168
+ };
9169
+ }
9170
+ return this.parsePrimary();
9171
+ }
9172
+ parsePrimary() {
9173
+ const tok = this.next();
9174
+ switch (tok.type) {
9175
+ case "number":
9176
+ this.countNode();
9177
+ return {
9178
+ kind: "literal",
9179
+ value: tok.value
9180
+ };
9181
+ case "string":
9182
+ this.countNode();
9183
+ return {
9184
+ kind: "literal",
9185
+ value: tok.value
9186
+ };
9187
+ case "keyword":
9188
+ this.countNode();
9189
+ return {
9190
+ kind: "literal",
9191
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
9192
+ };
9193
+ case "identifier": {
9194
+ const nextTok = this.peek();
9195
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
9196
+ this.identifiers.add(tok.name);
9197
+ this.countNode();
9198
+ return {
9199
+ kind: "identifier",
9200
+ name: tok.name
9201
+ };
9202
+ }
9203
+ case "punct":
9204
+ if (tok.punct === "(") {
9205
+ const inner = this.parseTernary();
9206
+ this.expectPunct(")");
9207
+ return inner;
9208
+ }
9209
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
9210
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
9211
+ }
9212
+ }
9213
+ parseCall(callee, pos) {
9214
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
9215
+ this.expectPunct("(");
9216
+ const args = [];
9217
+ if (!this.matchPunct(")")) for (;;) {
9218
+ args.push(this.parseTernary());
9219
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
9220
+ if (this.matchPunct(",")) continue;
9221
+ this.expectPunct(")");
9222
+ break;
9223
+ }
9224
+ this.callees.add(callee);
9225
+ this.countNode();
9226
+ return {
9227
+ kind: "call",
9228
+ callee,
9229
+ args
9230
+ };
9231
+ }
9232
+ };
9233
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
9234
+ * `ExpressionParseError` on any lexical or grammatical failure. */
9235
+ function parseExpression(source) {
9236
+ return new Parser(tokenize(source)).parse();
9237
+ }
8939
9238
  /**
8940
- * Alerts capability collection-based internal alert system.
9239
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
9240
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
9241
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
9242
+ * one per read on a hot resolve path.
8941
9243
  *
8942
- * Multiple providers can register. Each provider filters by EventBus category
8943
- * and creates/updates alerts. The built-in Alert Center addon persists alerts
8944
- * in the DB and serves them to the admin UI.
9244
+ * The cache is a module-level singleton: entries are pure, content-addressed
9245
+ * ASTs keyed by the raw source string, so sharing one instance across all
9246
+ * callers is safe and maximises hit rate.
8945
9247
  */
8946
- var AlertSeveritySchema = _enum([
8947
- "info",
8948
- "success",
8949
- "warning",
8950
- "error"
9248
+ var cache = /* @__PURE__ */ new Map();
9249
+ function getCached(source) {
9250
+ const hit = cache.get(source);
9251
+ if (hit !== void 0) {
9252
+ cache.delete(source);
9253
+ cache.set(source, hit);
9254
+ return hit;
9255
+ }
9256
+ let result;
9257
+ try {
9258
+ result = {
9259
+ ok: true,
9260
+ parsed: parseExpression(source)
9261
+ };
9262
+ } catch (err) {
9263
+ result = {
9264
+ ok: false,
9265
+ error: err instanceof ExpressionParseError ? err.message : String(err)
9266
+ };
9267
+ }
9268
+ cache.set(source, result);
9269
+ if (cache.size > 256) {
9270
+ const oldest = cache.keys().next().value;
9271
+ if (oldest !== void 0) cache.delete(oldest);
9272
+ }
9273
+ return result;
9274
+ }
9275
+ /** Compile `source`, returning a discriminated result instead of throwing.
9276
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
9277
+ function compileExpressionSafe(source) {
9278
+ return getCached(source);
9279
+ }
9280
+ Object.freeze({});
9281
+ /**
9282
+ * Author-time validation. Returns `null` when the source is valid, else a
9283
+ * human-readable error message. Checks: the expression compiles; binding count
9284
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
9285
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
9286
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
9287
+ */
9288
+ function validateExpressionSource(src) {
9289
+ const names = Object.keys(src.bindings);
9290
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
9291
+ for (const name of names) {
9292
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
9293
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
9294
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
9295
+ }
9296
+ const compiled = compileExpressionSafe(src.expr);
9297
+ if (!compiled.ok) return compiled.error;
9298
+ const bound = new Set(names);
9299
+ for (const id of compiled.parsed.identifiers) {
9300
+ if (id === "now") continue;
9301
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
9302
+ }
9303
+ return null;
9304
+ }
9305
+ var ExpressionBindingSourceSchema = union([
9306
+ object({
9307
+ kind: literal("field").optional(),
9308
+ sourceKey: string(),
9309
+ cap: string(),
9310
+ fieldPath: string()
9311
+ }),
9312
+ object({
9313
+ kind: literal("literal"),
9314
+ value: union([
9315
+ string(),
9316
+ number(),
9317
+ boolean(),
9318
+ _null()
9319
+ ])
9320
+ }),
9321
+ object({
9322
+ kind: literal("global"),
9323
+ sourceStableId: string(),
9324
+ cap: string(),
9325
+ fieldPath: string()
9326
+ })
8951
9327
  ]);
8952
- var AlertStatusSchema = _enum([
8953
- "active",
8954
- "in-progress",
8955
- "completed",
8956
- "failed",
8957
- "dismissed"
9328
+ object({
9329
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
9330
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
9331
+ }).superRefine((src, ctx) => {
9332
+ const err = validateExpressionSource(src);
9333
+ if (err !== null) ctx.addIssue({
9334
+ code: "custom",
9335
+ message: err,
9336
+ path: ["expr"]
9337
+ });
9338
+ });
9339
+ /** How a leaf compares a device field to a value. Derived from the field's
9340
+ * `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
9341
+ var AutomationConditionOperatorSchema = _enum([
9342
+ "eq",
9343
+ "ne",
9344
+ "gt",
9345
+ "gte",
9346
+ "lt",
9347
+ "lte",
9348
+ "contains",
9349
+ "in"
9350
+ ]);
9351
+ var AutomationConditionLeafSchema = object({
9352
+ kind: literal("condition"),
9353
+ deviceId: number().int().nonnegative(),
9354
+ cap: string().min(1),
9355
+ fieldPath: string().min(1),
9356
+ operator: AutomationConditionOperatorSchema,
9357
+ value: union([
9358
+ string(),
9359
+ number(),
9360
+ boolean(),
9361
+ array(union([string(), number()]))
9362
+ ])
9363
+ });
9364
+ /**
9365
+ * The expression leaf, declared as a plain object rather than an intersection
9366
+ * with {@link ExpressionSourceSchema}: a discriminated union has to be able to
9367
+ * read `kind` off each option, and an intersection hides it. The author-time
9368
+ * validation is the SAME function `ExpressionSourceSchema` runs, so the two
9369
+ * cannot drift — an expression that one accepts, the other accepts.
9370
+ */
9371
+ var AutomationConditionExpressionSchema = object({
9372
+ kind: literal("expression"),
9373
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
9374
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
9375
+ }).superRefine((src, ctx) => {
9376
+ const err = validateExpressionSource(src);
9377
+ if (err !== null) ctx.addIssue({
9378
+ code: "custom",
9379
+ message: err,
9380
+ path: ["expr"]
9381
+ });
9382
+ });
9383
+ var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
9384
+ object({
9385
+ kind: literal("all"),
9386
+ children: array(AutomationConditionSchema)
9387
+ }),
9388
+ object({
9389
+ kind: literal("any"),
9390
+ children: array(AutomationConditionSchema)
9391
+ }),
9392
+ object({
9393
+ kind: literal("not"),
9394
+ child: AutomationConditionSchema
9395
+ }),
9396
+ AutomationConditionLeafSchema,
9397
+ AutomationConditionExpressionSchema
9398
+ ]));
9399
+ /**
9400
+ * What starts a run.
9401
+ *
9402
+ * D8 compliance, and it is the reason `device-state` is not merely an event
9403
+ * subscription: the trigger evaluates against the **state mirror**, which is
9404
+ * reconciled, and an event only WAKES the evaluation. A dropped event therefore
9405
+ * DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
9406
+ * already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
9407
+ * not.
9408
+ */
9409
+ var AutomationTriggerSchema = discriminatedUnion("kind", [
9410
+ object({
9411
+ kind: literal("device-state"),
9412
+ deviceId: number().int().nonnegative(),
9413
+ cap: string().min(1),
9414
+ fieldPath: string().min(1),
9415
+ /** Fire when the field takes this value. Omit to fire on any change. */
9416
+ becomes: union([
9417
+ string(),
9418
+ number(),
9419
+ boolean()
9420
+ ]).optional(),
9421
+ /** Only on a CHANGE of value, not on every re-report. */
9422
+ edge: boolean().optional(),
9423
+ /** The condition must hold this long before the run starts. */
9424
+ forMs: number().int().min(0).max(864e5).optional(),
9425
+ /** Collapse a burst into one run. */
9426
+ debounceMs: number().int().min(0).max(6e5).optional()
9427
+ }),
9428
+ object({
9429
+ kind: literal("device-event"),
9430
+ /** An `EventCategory` value. */
9431
+ category: string().min(1),
9432
+ deviceId: number().int().nonnegative().optional()
9433
+ }),
9434
+ object({
9435
+ kind: literal("schedule"),
9436
+ cron: string().min(1).max(120)
9437
+ }),
9438
+ object({ kind: literal("manual") })
9439
+ ]);
9440
+ /**
9441
+ * One action step.
9442
+ *
9443
+ * `wait` and `cap` are `NcRuleActionSchema`'s two members, kept structurally
9444
+ * identical so `NcRuleActionRunner` runs them unchanged — its device-scope
9445
+ * check, stop-at-first-failure and per-sequence throttle are the whole reason
9446
+ * to reuse it, and none of them are re-implemented here.
9447
+ *
9448
+ * **The one divergence, and it is forced.** `NcRuleActionSchema.cap.deviceId` is
9449
+ * a literal `z.number().int()`, and the NC runner's own `RunSequencesInput`
9450
+ * documents its subject device as *"for the log tag, never for routing"*. So an
9451
+ * NC action can never target the device that triggered it — which is fine for
9452
+ * the NC (its rules already scope to a device) and fatal for an automation
9453
+ * ("sound the siren of the camera that saw the person"). `deviceId` therefore
9454
+ * also accepts `{ $var }`, resolved from the run's `vars` bag BEFORE the runner
9455
+ * is called. The runner still receives a number and is untouched; the
9456
+ * resolution is the recipe's job, not the runner's.
9457
+ */
9458
+ var AutomationActionSchema = discriminatedUnion("kind", [
9459
+ object({
9460
+ kind: literal("wait"),
9461
+ seconds: number().min(0).max(300)
9462
+ }),
9463
+ object({
9464
+ kind: literal("cap"),
9465
+ deviceId: union([number().int(), object({ $var: string().min(1) })]),
9466
+ cap: string().min(1),
9467
+ method: string().min(1),
9468
+ /** Values may carry `{{vars.x}}` slots, which SUBSTITUTE and do not
9469
+ * evaluate (§3.2.3). Anything beyond substitution is the expression leaf. */
9470
+ args: record(string(), unknown()).optional()
9471
+ }),
9472
+ object({
9473
+ kind: literal("code"),
9474
+ /** Compiled into the automation's OWN block by esbuild — not a third
9475
+ * runtime, not a `vm`, and not dynamically evaluated. */
9476
+ code: string().min(1).max(2e4)
9477
+ })
9478
+ ]);
9479
+ object({
9480
+ triggers: array(AutomationTriggerSchema),
9481
+ conditions: AutomationConditionSchema.optional(),
9482
+ actions: array(AutomationActionSchema)
9483
+ });
9484
+ /**
9485
+ * `addon-pages` — system-scoped singleton aggregator cap. Public-facing
9486
+ * surface that admin-ui consumes through `useAddonPagesListPages()`.
9487
+ *
9488
+ * The provider iterates every `addon-pages-source` (collection) provider
9489
+ * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
9490
+ * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
9491
+ * filesystem `mtime` cache-buster lets the browser pick up addon
9492
+ * rebuilds without manual reload.
9493
+ *
9494
+ * The hub-local builtin `addon-pages-aggregator` (see
9495
+ * `@camstack/system/builtins/addon-pages-aggregator`) registers the
9496
+ * provider. Splitting the public aggregator from the raw collection
9497
+ * keeps both ends in codegen — there's no hand-written
9498
+ * `addon-pages.router.ts` wrapper anymore.
9499
+ */
9500
+ var AddonPageDeclarationSchema$1 = object({
9501
+ id: string(),
9502
+ label: string(),
9503
+ icon: string(),
9504
+ path: string(),
9505
+ remoteName: string(),
9506
+ bundle: string(),
9507
+ section: string().optional(),
9508
+ sectionLabel: string().optional()
9509
+ });
9510
+ var AddonPageInfoSchema = object({
9511
+ addonId: string(),
9512
+ page: AddonPageDeclarationSchema$1,
9513
+ bundleUrl: string()
9514
+ });
9515
+ method(_void(), array(AddonPageInfoSchema).readonly());
9516
+ /**
9517
+ * `addon-pages-source` — collection cap exposing per-provider raw page
9518
+ * declarations. Every addon that contributes a UI page registers a
9519
+ * provider here. The hub-side singleton aggregator (`addon-pages` cap,
9520
+ * see `addon-pages.cap.ts`) walks this collection, stamps versioned
9521
+ * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
9522
+ * that admin-ui consumes.
9523
+ *
9524
+ * The split exists because the public listing has a different output
9525
+ * shape than the per-provider raw declarations, and we want both ends
9526
+ * to flow through codegen instead of relying on a hand-written wrapper.
9527
+ */
9528
+ var AddonPageDeclarationSchema = object({
9529
+ id: string(),
9530
+ label: string(),
9531
+ icon: string(),
9532
+ path: string(),
9533
+ /**
9534
+ * Module Federation remote name — must match the `name` field on the
9535
+ * page addon's `federation()` plugin config. Used by admin-ui's
9536
+ * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
9537
+ * Conventionally `addon_<id>_page` (snake_case; MF names cannot
9538
+ * contain hyphens).
9539
+ */
9540
+ remoteName: string(),
9541
+ /**
9542
+ * Bundle filename inside the addon's `dist/` dir served at
9543
+ * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
9544
+ * is always `'remoteEntry.js'`; the value is kept on the metadata so
9545
+ * the static-file route can compute an mtime-based cache-buster URL
9546
+ * without a separate filesystem stat.
9547
+ */
9548
+ bundle: string(),
9549
+ /**
9550
+ * Sidebar section this page docks into. Well-known ids: `'detection'`,
9551
+ * `'cluster'`, `'administration'` — the page renders inside that group.
9552
+ * Any OTHER string creates (or joins) a custom section rendered after
9553
+ * the built-in groups; its label comes from `sectionLabel` (first
9554
+ * declaration wins), falling back to the id. Absent → the legacy
9555
+ * "Addon Pages" group.
9556
+ */
9557
+ section: string().optional(),
9558
+ /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
9559
+ sectionLabel: string().optional()
9560
+ });
9561
+ method(_void(), array(AddonPageDeclarationSchema).readonly());
9562
+ var AddonHttpRouteSchema = object({
9563
+ method: _enum([
9564
+ "GET",
9565
+ "POST",
9566
+ "PUT",
9567
+ "DELETE",
9568
+ "PATCH"
9569
+ ]),
9570
+ path: string(),
9571
+ access: _enum([
9572
+ "public",
9573
+ "authenticated",
9574
+ "admin"
9575
+ ]).optional(),
9576
+ description: string().optional()
9577
+ });
9578
+ /**
9579
+ * Cross-process route invocation envelope. The hub captures the
9580
+ * request as plain data, ships it to the worker via Moleculer, and
9581
+ * the worker runs the local handler against a capturing reply. The
9582
+ * envelope returned describes what the handler intended (status,
9583
+ * headers, body, or a redirect) so the hub can translate it back to
9584
+ * the Fastify reply that's actually wired to the socket.
9585
+ */
9586
+ var InvokeRequestSchema = object({
9587
+ method: string(),
9588
+ path: string(),
9589
+ params: record(string(), string()),
9590
+ query: record(string(), string()),
9591
+ body: unknown(),
9592
+ headers: record(string(), string()),
9593
+ user: object({
9594
+ id: string(),
9595
+ username: string(),
9596
+ isAdmin: boolean()
9597
+ }).optional(),
9598
+ scopedToken: unknown().optional()
9599
+ });
9600
+ var InvokeReplyEnvelopeSchema = object({
9601
+ status: number().int(),
9602
+ headers: record(string(), string()),
9603
+ /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
9604
+ * sending `body`. Status defaults to 302 when this is set unless
9605
+ * the handler called `reply.code(...)` explicitly. */
9606
+ redirectUrl: string().nullable(),
9607
+ /** JSON-serializable body. `undefined` is treated as "no body". */
9608
+ body: unknown().optional(),
9609
+ /** Set when the handler called `reply.type(mime)`. */
9610
+ contentType: string().optional()
9611
+ });
9612
+ method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
9613
+ var ConfigTabDeclarationSchema = object({
9614
+ id: string(),
9615
+ label: string(),
9616
+ icon: string(),
9617
+ order: number().optional()
9618
+ });
9619
+ var ConfigSectionWithValuesSchema = object({
9620
+ id: string(),
9621
+ title: string(),
9622
+ description: string().optional(),
9623
+ style: _enum(["card", "accordion"]).optional(),
9624
+ defaultCollapsed: boolean().optional(),
9625
+ columns: union([
9626
+ literal(1),
9627
+ literal(2),
9628
+ literal(3),
9629
+ literal(4)
9630
+ ]).optional(),
9631
+ tab: string().optional(),
9632
+ location: _enum(["settings", "top-tab"]).optional(),
9633
+ order: number().optional(),
9634
+ fields: array(any())
9635
+ });
9636
+ var SettingsSchemaWithValuesSchema = object({
9637
+ tabs: array(ConfigTabDeclarationSchema).optional(),
9638
+ sections: array(ConfigSectionWithValuesSchema)
9639
+ });
9640
+ /** Patch object — keys are field names, values are the new field values. */
9641
+ var SettingsPatchSchema = record(string(), unknown());
9642
+ /** Standard success response for update operations. */
9643
+ var SettingsUpdateResultSchema = object({ success: literal(true) });
9644
+ method(object({
9645
+ addonId: string(),
9646
+ nodeId: string().optional(),
9647
+ overlay: record(string(), unknown()).optional(),
9648
+ cap: string().optional()
9649
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9650
+ addonId: string(),
9651
+ nodeId: string().optional(),
9652
+ patch: SettingsPatchSchema
9653
+ }), SettingsUpdateResultSchema, {
9654
+ kind: "mutation",
9655
+ auth: "admin"
9656
+ }), method(object({
9657
+ addonId: string(),
9658
+ deviceId: number(),
9659
+ nodeId: string().optional()
9660
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9661
+ addonId: string(),
9662
+ deviceId: number(),
9663
+ nodeId: string().optional(),
9664
+ patch: SettingsPatchSchema
9665
+ }), SettingsUpdateResultSchema, {
9666
+ kind: "mutation",
9667
+ auth: "admin"
9668
+ });
9669
+ /**
9670
+ * `addon-widgets-source` — collection cap exposing per-addon raw widget
9671
+ * declarations. Mirrors the addon-pages split: every addon shipping
9672
+ * widgets registers a provider on this collection cap; the hub-local
9673
+ * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
9674
+ * collection, stamps versioned `bundleUrl`s onto each declaration, and
9675
+ * exposes the public listing surface that admin-ui consumes.
9676
+ *
9677
+ * The split exists because the public listing has a different output
9678
+ * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
9679
+ * per-provider raw declarations. Both ends flow through codegen.
9680
+ *
9681
+ * Unified UI-contribution model (Task 10): a widget descriptor IS a
9682
+ * `UiContribution` with `kind:'remote'`. The host renders it through the
9683
+ * same `ContributionRenderer` / Module-Federation path as every other
9684
+ * contributed UI surface — no bespoke widget-rendering path. The widget-
9685
+ * only metadata (sizing hints, `requires`) lives as extra fields on the
9686
+ * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
9687
+ * `kind` / `remote`) carries identity + placement + the MF remote.
9688
+ */
9689
+ /** Where the widget makes sense to render — maps to a contribution `tab`. */
9690
+ var WidgetHostEnum = _enum([
9691
+ "device-tab",
9692
+ "dashboard",
9693
+ "integration-detail"
9694
+ ]);
9695
+ var WidgetSizeEnum = _enum([
9696
+ "xs",
9697
+ "sm",
9698
+ "md",
9699
+ "lg",
9700
+ "xl"
9701
+ ]);
9702
+ /**
9703
+ * MF remote descriptor — mirrors `UiContributionRemote` from
9704
+ * `capability-definition.ts`. Widget remotes expose a single
9705
+ * `'./widgets'` module whose default export is a
9706
+ * `Record<componentKey, Component>` map; `componentKey` (the widget
9707
+ * `stableId`) picks the entry the host mounts.
9708
+ */
9709
+ var WidgetRemoteSchema = object({
9710
+ remoteName: string(),
9711
+ exposedModule: string(),
9712
+ componentKey: string().optional()
9713
+ });
9714
+ /**
9715
+ * One widget declaration — a `UiContribution` (`kind:'remote'`) plus
9716
+ * widget-only metadata. The `UiContribution` core fields:
9717
+ *
9718
+ * - `tab` — where the widget hosts. A widget that runs on the
9719
+ * dashboard declares `tab:'dashboard'`; a device-tab
9720
+ * widget declares the target device-detail tab id.
9721
+ * - `subTab` — optional sub-tab within `tab`.
9722
+ * - `label` — operator-facing label.
9723
+ * - `order` — ordering within `(tab, subTab)`.
9724
+ * - `kind` — always `'remote'` for widgets.
9725
+ * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
9726
+ *
9727
+ * Widget-only fields retained alongside the contribution core:
9728
+ *
9729
+ * - `stableId` — stable identity within the addon (the MF
9730
+ * `componentKey`; kept top-level so consumers have
9731
+ * a stable key without reaching into `remote`).
9732
+ * - `description` / `icon` — picker metadata.
9733
+ * - `bundle` — entry filename inside the addon `dist/` dir; the
9734
+ * aggregator stamps a versioned `bundleUrl` from it.
9735
+ * - `hosts` — every host the widget supports (a widget can run
9736
+ * both on the dashboard and a device tab). `tab`
9737
+ * is the PRIMARY host; `hosts` is the full set the
9738
+ * picker filters on.
9739
+ * - `requires` — host-context requirements validated at mount.
9740
+ * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
9741
+ * — dashboard placement hints.
9742
+ */
9743
+ var WidgetMetadataSchema = object({
9744
+ /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
9745
+ tab: string(),
9746
+ /** Optional sub-tab within `tab`. */
9747
+ subTab: string().optional(),
9748
+ /** Operator-facing label. */
9749
+ label: string(),
9750
+ /** Ordering within `(tab, subTab)`, ascending. */
9751
+ order: number().optional(),
9752
+ /** Always `'remote'` — a widget is a Module Federation remote. */
9753
+ kind: literal("remote"),
9754
+ /** MF remote descriptor. */
9755
+ remote: WidgetRemoteSchema,
9756
+ /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
9757
+ stableId: string(),
9758
+ description: string().optional(),
9759
+ icon: string().optional(),
9760
+ /**
9761
+ * Bundle filename inside the addon's `dist/` dir served at
9762
+ * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
9763
+ * this is always `'remoteEntry.js'` — the value is kept on the
9764
+ * metadata so the static-file route can compute an mtime-based
9765
+ * cache-buster URL without a separate filesystem stat.
9766
+ */
9767
+ bundle: string(),
9768
+ /** Every host the widget supports. The picker filters on this set. */
9769
+ hosts: array(WidgetHostEnum).readonly(),
9770
+ /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
9771
+ requires: object({
9772
+ deviceContext: boolean().default(false),
9773
+ integrationContext: boolean().default(false)
9774
+ }),
9775
+ /**
9776
+ * Loadable BEFORE authentication. The normal widget registry listing
9777
+ * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
9778
+ * (the login page) cannot discover a widget through it. A widget that
9779
+ * declares `preAuth: true` marks itself as safe to mount on a pre-auth
9780
+ * screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
9781
+ * login-method contribution channel (see `login-method.cap.ts`) rather
9782
+ * than the authenticated registry, and its bundle is served by the
9783
+ * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
9784
+ */
9785
+ preAuth: boolean().optional().default(false),
9786
+ /** Dashboard placement HINTS (operator can override per instance). */
9787
+ defaultSize: WidgetSizeEnum.default("md"),
9788
+ allowedSizes: array(WidgetSizeEnum).readonly().default([
9789
+ "sm",
9790
+ "md",
9791
+ "lg"
9792
+ ]),
9793
+ defaultColumns: number().int().min(1).max(12).default(6),
9794
+ defaultRows: number().int().min(1).max(12).default(1)
9795
+ });
9796
+ method(_void(), array(WidgetMetadataSchema).readonly());
9797
+ /**
9798
+ * `addon-widgets` — system-scoped singleton aggregator cap. Public-facing
9799
+ * surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
9800
+ *
9801
+ * The provider iterates every `addon-widgets-source` (collection)
9802
+ * provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
9803
+ * `bundleUrl` strings pointing at
9804
+ * `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
9805
+ * `mtime` cache-buster lets the browser pick up addon rebuilds without
9806
+ * manual reload — same scheme used by `addon-pages`.
9807
+ *
9808
+ * The hub-local builtin `addon-widgets-aggregator` (see
9809
+ * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
9810
+ * provider. Splitting the public aggregator from the raw collection
9811
+ * keeps both ends in codegen — there's no hand-written wrapper.
9812
+ */
9813
+ var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
9814
+ addonId: string(),
9815
+ bundleUrl: string()
9816
+ });
9817
+ method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
9818
+ /**
9819
+ * Alerts capability — collection-based internal alert system.
9820
+ *
9821
+ * Multiple providers can register. Each provider filters by EventBus category
9822
+ * and creates/updates alerts. The built-in Alert Center addon persists alerts
9823
+ * in the DB and serves them to the admin UI.
9824
+ */
9825
+ var AlertSeveritySchema = _enum([
9826
+ "info",
9827
+ "success",
9828
+ "warning",
9829
+ "error"
9830
+ ]);
9831
+ var AlertStatusSchema = _enum([
9832
+ "active",
9833
+ "in-progress",
9834
+ "completed",
9835
+ "failed",
9836
+ "dismissed"
8958
9837
  ]);
8959
9838
  var AlertSourceSchema = object({
8960
9839
  type: string(),
@@ -9630,6 +10509,69 @@ var StreamFormatSchema = _enum([
9630
10509
  "mjpeg",
9631
10510
  "rtsp"
9632
10511
  ]);
10512
+ /** A container `produceEventMedia` can emit. */
10513
+ var EventMediaKindSchema = _enum(["mp4", "gif"]);
10514
+ /**
10515
+ * One produced artifact, referenced by HANDLE.
10516
+ *
10517
+ * Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
10518
+ * method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
10519
+ * [D18](../../../../docs/decisions/adr-0018.md) — cross-process media is fetched
10520
+ * on demand, compressed, by handle). `bytes` is here so a caller can decide
10521
+ * whether it wants the fetch at all.
10522
+ */
10523
+ var EventMediaArtifactSchema = object({
10524
+ kind: EventMediaKindSchema,
10525
+ /** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
10526
+ handle: string(),
10527
+ /**
10528
+ * The node holding the bytes — the ROUTING key for `fetchEventMedia`.
10529
+ *
10530
+ * `stream-broker` is a singleton cap and an unpinned call never leaves the
10531
+ * hub, so a handle produced on an agent's broker would be redeemed against
10532
+ * the hub's store and come back `null`. Same contract, same field name and
10533
+ * the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
10534
+ * lives and the consumer pins to it.
10535
+ */
10536
+ nodeId: string(),
10537
+ mime: string(),
10538
+ bytes: number().int(),
10539
+ width: number().int(),
10540
+ height: number().int()
10541
+ });
10542
+ /**
10543
+ * What a production actually covered — the answer to the only question an
10544
+ * operator asks about a notification clip.
10545
+ *
10546
+ * `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
10547
+ * so a caller can state "this clip starts 4.1 s before the event" instead of
10548
+ * inferring it from a duration. A production whose `fromTs` is later than the
10549
+ * event is a production with no pre-roll, and that is exactly the defect this
10550
+ * method exists to make visible rather than plausible.
10551
+ */
10552
+ var EventMediaCoverageSchema = object({
10553
+ fromTs: number(),
10554
+ toTs: number(),
10555
+ /** Encoded packets in the muxed window. */
10556
+ packets: number().int()
10557
+ });
10558
+ /**
10559
+ * The result of ONE cut, in every container the caller asked for.
10560
+ *
10561
+ * Every artifact in `media` came out of the SAME window of the SAME rendition —
10562
+ * that is the whole reason this is one method rather than one call per format.
10563
+ * A consumer attaching a gif and a video can no longer show two different
10564
+ * moments, because it never chose two sources.
10565
+ */
10566
+ var EventMediaProductionSchema = object({
10567
+ media: array(EventMediaArtifactSchema).readonly(),
10568
+ coverage: EventMediaCoverageSchema,
10569
+ /** The rendition actually cut from — what the default or the fallback chose. */
10570
+ profile: CamProfileSchema,
10571
+ /** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
10572
+ * source, a downscale, or a playback rate other than 1). */
10573
+ video: _enum(["copy", "encode"])
10574
+ });
9633
10575
  var RtspRestreamEntrySchema = object({
9634
10576
  brokerId: string(),
9635
10577
  url: string(),
@@ -10029,6 +10971,56 @@ method(object({
10029
10971
  }), {
10030
10972
  kind: "mutation",
10031
10973
  auth: "admin"
10974
+ }), method(object({
10975
+ deviceId: number(),
10976
+ /** Absent = the largest H.264 rendition at or below 1080p, which is
10977
+ * also the one that can be copied. Falls back to whatever the ring
10978
+ * actually retained, and the answer says which. */
10979
+ profile: CamProfileSchema.optional(),
10980
+ aroundMs: number(),
10981
+ preSeconds: number().min(0).max(20).default(4),
10982
+ postSeconds: number().min(0).max(20).default(6),
10983
+ kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
10984
+ /** GIF geometry. The video keeps the source's own. */
10985
+ gifMaxWidth: number().int().min(120).max(1280).default(640),
10986
+ /**
10987
+ * The gif's own PLAYBACK rate in frames per second — what the finished
10988
+ * gif runs at, not how many source frames feed it. The decimation that
10989
+ * feeds it samples `gifFps / gifSpeed` source frames per second, so at
10990
+ * the defaults a 12 fps gif is built out of 3 source frames a second.
10991
+ */
10992
+ gifFps: number().int().min(1).max(15).default(12),
10993
+ /**
10994
+ * How fast the GIF plays against real time, independent of `speed`.
10995
+ *
10996
+ * 4× by default, by operator request: a notification gif is glanced at
10997
+ * on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
10998
+ * a separate knob from `speed` even though both now default to 4 —
10999
+ * a caller wanting a real-time video and a fast gif must not have to
11000
+ * choose.
11001
+ */
11002
+ gifSpeed: number().min(1).max(8).default(4),
11003
+ /**
11004
+ * Playback rate of the VIDEO. Also 4× by default, by operator decision.
11005
+ *
11006
+ * `1` is real time and is the ONLY value that allows the copy branch —
11007
+ * anything else forces `libx264` over the window. That was priced
11008
+ * before it was chosen: a per-event burst measured at 0.23 s and 254 KB
11009
+ * on a real 615 720p cut, against 922 KB for the copy it replaces. A
11010
+ * re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
11011
+ * once the decode is forced the width stops being free.
11012
+ */
11013
+ speed: number().min(1).max(8).default(4)
11014
+ }), EventMediaProductionSchema, {
11015
+ kind: "mutation",
11016
+ auth: "admin"
11017
+ }), method(object({ handle: string() }), object({
11018
+ base64: string(),
11019
+ mime: string(),
11020
+ bytes: number().int()
11021
+ }).nullable(), {
11022
+ kind: "mutation",
11023
+ auth: "admin"
10032
11024
  }), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
10033
11025
  probed: boolean(),
10034
11026
  summary: string()
@@ -10221,25 +11213,6 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
10221
11213
  }),
10222
11214
  lastChangedAt: number()
10223
11215
  });
10224
- /**
10225
- * core-blocks — user-authored TypeScript, stored in the kernel and executed in
10226
- * its own process.
10227
- *
10228
- * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
10229
- *
10230
- * The first use is **owning devices without being a device provider**: a block
10231
- * declares devices under a system or custom integration and drives their state,
10232
- * with the same `ctx` an addon gets. Automations come later; nothing here
10233
- * models a trigger.
10234
- *
10235
- * **Stated plainly, because it does not change by being true:** a block has an
10236
- * addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
10237
- * with no review step. What makes that survivable is not a sandbox, it is
10238
- * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
10239
- * so a block that throws or never returns is marked `failed` and visible
10240
- * instead of taking the hub with it (D6). Every method here is admin-only, and
10241
- * must stay so.
10242
- */
10243
11216
  /** Where a block runs. The operator chooses — a block driving a device on an
10244
11217
  * agent is the reason placement is not fixed to the hub. */
10245
11218
  var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
@@ -10311,6 +11284,9 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
10311
11284
  }), object({ block: CoreBlockSchema }), {
10312
11285
  kind: "mutation",
10313
11286
  auth: "admin"
11287
+ }), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
11288
+ kind: "mutation",
11289
+ auth: "admin"
10314
11290
  }), method(object({ code: string() }), CoreBlockCompileResultSchema, {
10315
11291
  kind: "mutation",
10316
11292
  auth: "admin"
@@ -10845,826 +11821,231 @@ var AdoptionFilterSchema = object({
10845
11821
  id: string(),
10846
11822
  label: string(),
10847
11823
  isDefault: boolean().optional()
10848
- });
10849
- /**
10850
- * Candidate-list TEXT/query filter. Mirrors the prior `ha-discovery` filter,
10851
- * de-HA-ified — `area` / `manufacturer` stay free-form strings so any
10852
- * integration can populate them from its own metadata. Distinct from the
10853
- * granularity `AdoptionFilter` above: this narrows the candidate set within a
10854
- * chosen granularity, whereas the granularity `filter` selects WHAT a
10855
- * candidate is (a device vs an entity).
10856
- */
10857
- var CandidateQueryFilterSchema = object({
10858
- /** Substring filter on name + manufacturer + model. */
10859
- search: string().optional(),
10860
- /** Area-name exact match. */
10861
- area: string().optional(),
10862
- /** Manufacturer exact match. */
10863
- manufacturer: string().optional(),
10864
- /** When true, only return candidates the operator already adopted. */
10865
- adoptedOnly: boolean().optional(),
10866
- /** When true, only return candidates the operator hasn't adopted yet. */
10867
- unadoptedOnly: boolean().optional()
10868
- });
10869
- var ListCandidatesInputSchema = object({
10870
- integrationId: string(),
10871
- page: number().int().positive().default(1),
10872
- pageSize: number().int().positive().max(2e4).default(50),
10873
- /**
10874
- * Optional provider-declared discovery GRANULARITY id (opaque; see
10875
- * `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
10876
- * exactly the pre-existing behavior (fully back-compatible).
10877
- */
10878
- filter: string().optional(),
10879
- /** Optional candidate-list text/query narrowing within the granularity. */
10880
- filterText: CandidateQueryFilterSchema.optional()
10881
- });
10882
- var ListCandidatesOutputSchema = object({
10883
- candidates: array(DiscoveredChildDeviceSchema).readonly(),
10884
- totalCount: number().int().nonnegative(),
10885
- page: number().int().positive(),
10886
- pageSize: number().int().positive()
10887
- });
10888
- var GetCandidateInputSchema = object({
10889
- integrationId: string(),
10890
- childNativeId: string()
10891
- });
10892
- var AdoptionStatusSchema = object({
10893
- /** Last refresh timestamp (ms epoch) — null when never refreshed. */
10894
- lastDiscoveryAt: number().int().nonnegative().nullable(),
10895
- /** Count of candidates in the discovery cache. */
10896
- candidateCount: number().int().nonnegative(),
10897
- /** Count of candidates the operator has already adopted. */
10898
- adoptedCount: number().int().nonnegative(),
10899
- /** Last error message from a refresh attempt. */
10900
- lastError: string().nullable()
10901
- });
10902
- var PerCandidateSchema = object({
10903
- /** Override the default display name for this candidate's parent. */
10904
- name: string().min(1).optional(),
10905
- /** Pre-hide a subset of entity-children — created (state still flows)
10906
- * but listed in the parent's `accessories.hiddenChildIds`. */
10907
- hiddenChildIds: array(string()).optional()
10908
- });
10909
- var AdoptInputSchema = object({
10910
- integrationId: string(),
10911
- /**
10912
- * Candidate native ids to adopt. Their MEANING is filter-relative: under the
10913
- * default `'devices'` granularity these are device-native ids; under a
10914
- * provider-declared granularity (e.g. HA `'entities'`) they are that
10915
- * granularity's native ids (e.g. entity ids). The field name is kept stable
10916
- * to avoid a breaking rename.
10917
- */
10918
- childNativeIds: array(string()).min(1),
10919
- /**
10920
- * Optional provider-declared discovery GRANULARITY id (opaque; see
10921
- * `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
10922
- * exactly the pre-existing behavior (fully back-compatible).
10923
- */
10924
- filter: string().optional(),
10925
- /** When true, import each adopted device's source-system location (e.g. HA
10926
- * area) into CamStack — fuzzy-match an existing location or create it, then
10927
- * assign. Omitted/false = no location work (back-compat). */
10928
- importLocations: boolean().optional(),
10929
- perCandidate: record(string(), PerCandidateSchema).optional()
10930
- });
10931
- var AdoptResultSchema = object({ adopted: array(object({
10932
- childNativeId: string(),
10933
- parentDeviceId: number().int().nonnegative(),
10934
- accessoryDeviceIds: array(number().int().nonnegative()).readonly()
10935
- })).readonly() });
10936
- var ReleaseInputSchema = object({
10937
- integrationId: string(),
10938
- /** Parent CamStack device id (NOT an accessory child id). Removing
10939
- * the parent cascades into every accessory. */
10940
- camDeviceId: number().int().nonnegative()
10941
- });
10942
- var ResyncInputSchema = object({
10943
- /** Parent CamStack device id of an adopted device. The provider resolves its
10944
- * source (integration/broker + native id) and re-aligns the device's
10945
- * structural spec (type/role/capabilities/units) with the live mapping,
10946
- * rebuilding any child whose class changed while preserving operator edits. */
10947
- camDeviceId: number().int().nonnegative(),
10948
- /** "Resync from zero" (#19). When true, the kernel PURGES every accessory
10949
- * child of `camDeviceId` BEFORE the provider re-derives the device, so the
10950
- * children are rebuilt fresh from source — correct names, coords, and units —
10951
- * instead of being preserved by the incremental reconcile. Use to recover from
10952
- * legacy generic/placeholder names that the normal name-precedence keeps frozen
10953
- * (the operator's explicit reset). Push-driven integrations (no-op resync)
10954
- * rebuild on their next snapshot; pull/command integrations rebuild in `resync`.
10955
- * Operator edits on the PARENT (its name, layout, primary-child pick) survive —
10956
- * only the children are torn down. Omitted/false ⇒ the normal incremental
10957
- * re-sync that preserves children. */
10958
- resetToSource: boolean().optional()
10959
- });
10960
- var ResyncResultSchema = object({
10961
- /** True when the persisted spec actually changed (children may have been rebuilt). */
10962
- changed: boolean(),
10963
- /** Number of child devices rebuilt into a new class by this re-sync. */
10964
- rebuiltChildren: number().int().nonnegative(),
10965
- /** Number of accessory children torn down by a `resetToSource` purge before the
10966
- * provider re-derived the device. 0/absent for a normal incremental re-sync. */
10967
- removedChildren: number().int().nonnegative().optional()
10968
- });
10969
- 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, {
10970
- kind: "mutation",
10971
- auth: "admin"
10972
- }), method(AdoptInputSchema, AdoptResultSchema, {
10973
- kind: "mutation",
10974
- auth: "admin"
10975
- }), method(ReleaseInputSchema, _void(), {
10976
- kind: "mutation",
10977
- auth: "admin"
10978
- }), method(ResyncInputSchema, ResyncResultSchema, {
10979
- kind: "mutation",
10980
- auth: "admin"
10981
- });
10982
- /**
10983
- * `device-export` — collection cap for addons that export camstack
10984
- * devices to external ecosystems (HomeAssistant via MQTT discovery,
10985
- * HomeKit/HAP, Alexa Smart Home, …).
10986
- *
10987
- * No `ecosystem` enum — the addon id identifies the export target.
10988
- * Each addon owns its mapping logic in its own settings UI. The cap
10989
- * exposes only the COMMON surface: link state, supported device
10990
- * kinds (so the UI can filter the device picker), and per-device
10991
- * expose/unexpose.
10992
- */
10993
- var LinkStateSchema = _enum([
10994
- "unlinked",
10995
- "linked",
10996
- "error"
10997
- ]);
10998
- /**
10999
- * A single label/value row surfaced in the export panel's "Setup"
11000
- * section. Rendered with a copy-to-clipboard button. `secret: true`
11001
- * rows are masked by default with a reveal toggle (client ids,
11002
- * secrets, …).
11003
- */
11004
- var ExportSetupFieldSchema = object({
11005
- label: string(),
11006
- value: string(),
11007
- /** Mask the value by default + render a reveal toggle (client id, secrets). */
11008
- secret: boolean().optional()
11009
- });
11010
- /**
11011
- * Generic, addon-agnostic pairing/account block. Any export addon can
11012
- * surface a scannable QR, a set of copyable label/value rows, and a
11013
- * free-form operator note — the `DeviceExportPanel` renders whatever
11014
- * the provider supplies and skips the section entirely when `setup`
11015
- * is unset.
11016
- */
11017
- var ExportSetupSchema = object({
11018
- /** A string to render as a scannable QR — HAP `X-HM://…` URI, a pairing URL, etc. Omitted when there's nothing to scan. */
11019
- qr: string().optional(),
11020
- /** Label/value rows shown with a copy button (HAP setup code, OAuth URLs, client id, linked-account count, …). */
11021
- fields: array(ExportSetupFieldSchema).readonly().optional(),
11022
- /** Free-form operator instructions rendered above the fields. */
11023
- note: string().optional()
11024
- });
11025
- var DeviceExportStatusSchema = object({
11026
- linkState: LinkStateSchema,
11027
- exposedDeviceCount: number(),
11028
- error: string().optional(),
11029
- /**
11030
- * Optional pairing/account info the panel renders in a generic
11031
- * "Setup" section. Addon-agnostic — the addon id identifies the
11032
- * export target, never an `ecosystem` key here.
11033
- */
11034
- setup: ExportSetupSchema.optional()
11035
- });
11036
- var DeviceKindSchema = string();
11037
- var ExposedDeviceSchema = object({
11038
- deviceId: string(),
11039
- exposedAs: string().optional(),
11040
- capabilities: array(string()).optional()
11041
- });
11042
- var ExposeInputSchema = object({
11043
- deviceId: string(),
11044
- capabilities: array(string()).optional()
11045
- });
11046
- var UnexposeInputSchema = object({ deviceId: string() });
11047
- var deviceExportCapability = {
11048
- name: "device-export",
11049
- scope: "system",
11050
- mode: "collection",
11051
- providerKind: "device-export",
11052
- status: {
11053
- schema: DeviceExportStatusSchema,
11054
- kind: "poll"
11055
- },
11056
- /**
11057
- * Each export provider contributes its own per-device "Export" panel
11058
- * (enabled toggle, preferred stream, addon-specific knobs like the
11059
- * HA-MQTT discovery prefix). The three framework methods
11060
- * `getDeviceSettingsContribution` / `getDeviceLiveContribution` /
11061
- * `applyDeviceSettingsPatch` are auto-injected on the provider
11062
- * interface and routed to the device-details aggregator.
11063
- */
11064
- exposesDeviceSettings: true,
11065
- methods: {
11066
- getStatus: method(_void(), DeviceExportStatusSchema),
11067
- listSupportedDeviceKinds: method(_void(), array(DeviceKindSchema)),
11068
- listExposedDevices: method(_void(), array(ExposedDeviceSchema)),
11069
- exposeDevice: method(ExposeInputSchema, _void(), { kind: "mutation" }),
11070
- unexposeDevice: method(UnexposeInputSchema, _void(), { kind: "mutation" })
11071
- }
11072
- };
11073
- /**
11074
- * Resource-bound constants for the safe expression engine.
11075
- *
11076
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
11077
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
11078
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
11079
- * work a single author-supplied expression can request, so a hostile or
11080
- * accidental pathological string can never spend unbounded CPU/memory.
11081
- */
11082
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
11083
- * rejected without allocation. */
11084
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
11085
- /** A legal binding / identifier name. */
11086
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
11087
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
11088
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
11089
- var RESERVED_BINDING_NAMES = new Set([
11090
- "now",
11091
- "true",
11092
- "false",
11093
- "null"
11094
- ]);
11095
- /**
11096
- * Error types for the safe expression engine. Two distinct classes so callers
11097
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
11098
- * failure — both are non-fatal to the host: read paths degrade to "skip link".
11099
- */
11100
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
11101
- * the failure is anchored to a character (author-facing inline feedback). */
11102
- var ExpressionParseError = class extends Error {
11103
- position;
11104
- constructor(message, position) {
11105
- super(message);
11106
- this.name = "ExpressionParseError";
11107
- this.position = position;
11108
- }
11109
- };
11110
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
11111
- * result, unknown builtin, step-budget exceeded). */
11112
- var ExpressionEvalError = class extends Error {
11113
- constructor(message) {
11114
- super(message);
11115
- this.name = "ExpressionEvalError";
11116
- }
11117
- };
11118
- /**
11119
- * Frozen, null-prototype builtin function table for the expression engine
11120
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
11121
- * parser rejects any callee not in it, and the evaluator gates each call on an
11122
- * own-property check against it.
11123
- *
11124
- * Because the object has a NULL prototype AND is `Object.freeze`d:
11125
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
11126
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
11127
- * (there is no `Object.prototype` in the chain), so those names are not
11128
- * callable — they are simply "unknown function" at parse time.
11129
- *
11130
- * Every numeric argument is validated as a finite number and every numeric
11131
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
11132
- * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
11133
- * closed rather than emitting a garbage value.
11134
- */
11135
- function asFiniteNumber(value, name, index) {
11136
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
11137
- return value;
11138
- }
11139
- function asString$1(value, name, index) {
11140
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
11141
- return value;
11142
- }
11143
- function finiteResult(value, name) {
11144
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
11145
- return value;
11146
- }
11147
- function allFiniteNumbers(args, name) {
11148
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
11149
- }
11150
- var INF = Number.POSITIVE_INFINITY;
11151
- var table = {
11152
- min: {
11153
- minArgs: 1,
11154
- maxArgs: INF,
11155
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
11156
- },
11157
- max: {
11158
- minArgs: 1,
11159
- maxArgs: INF,
11160
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
11161
- },
11162
- abs: {
11163
- minArgs: 1,
11164
- maxArgs: 1,
11165
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
11166
- },
11167
- floor: {
11168
- minArgs: 1,
11169
- maxArgs: 1,
11170
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
11171
- },
11172
- ceil: {
11173
- minArgs: 1,
11174
- maxArgs: 1,
11175
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
11176
- },
11177
- sqrt: {
11178
- minArgs: 1,
11179
- maxArgs: 1,
11180
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
11181
- },
11182
- round: {
11183
- minArgs: 1,
11184
- maxArgs: 2,
11185
- apply: (args) => {
11186
- const x = asFiniteNumber(args[0], "round", 0);
11187
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
11188
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
11189
- const factor = 10 ** digits;
11190
- return finiteResult(Math.round(x * factor) / factor, "round");
11191
- }
11192
- },
11193
- pow: {
11194
- minArgs: 2,
11195
- maxArgs: 2,
11196
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
11197
- },
11198
- clamp: {
11199
- minArgs: 3,
11200
- maxArgs: 3,
11201
- apply: (args) => {
11202
- const x = asFiniteNumber(args[0], "clamp", 0);
11203
- const lo = asFiniteNumber(args[1], "clamp", 1);
11204
- const hi = asFiniteNumber(args[2], "clamp", 2);
11205
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
11206
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
11207
- }
11208
- },
11209
- avg: {
11210
- minArgs: 1,
11211
- maxArgs: INF,
11212
- apply: (args) => {
11213
- const nums = allFiniteNumbers(args, "avg");
11214
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
11215
- }
11216
- },
11217
- sum: {
11218
- minArgs: 1,
11219
- maxArgs: INF,
11220
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
11221
- },
11222
- coalesce: {
11223
- minArgs: 1,
11224
- maxArgs: INF,
11225
- apply: (args) => {
11226
- for (const a of args) if (a !== null) return a;
11227
- return null;
11228
- }
11229
- },
11230
- age: {
11231
- minArgs: 2,
11232
- maxArgs: 2,
11233
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
11234
- },
11235
- convert: {
11236
- minArgs: 3,
11237
- maxArgs: 3,
11238
- apply: (args, hooks) => {
11239
- const x = asFiniteNumber(args[0], "convert", 0);
11240
- const from = asString$1(args[1], "convert", 1).trim();
11241
- const to = asString$1(args[2], "convert", 2).trim();
11242
- if (hooks.convert) {
11243
- const out = hooks.convert(x, from, to);
11244
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
11245
- return finiteResult(out, "convert");
11246
- }
11247
- if (from === to) return x;
11248
- throw new ExpressionEvalError("convert: unit conversion table not installed");
11249
- }
11250
- }
11251
- };
11252
- Object.freeze(Object.assign(Object.create(null), table));
11253
- /** The set of valid builtin names — used by the parser to reject unknown
11254
- * callees at parse time (immediate author feedback). */
11255
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
11256
- /**
11257
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
11258
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
11259
- * single/double-quoted strings with a tiny escape set, identifiers, the three
11260
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
11261
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
11262
- * is a parse error with a source position, so member access / assignment /
11263
- * template literals are lexically impossible.
11264
- */
11265
- var KEYWORDS = new Set([
11266
- "true",
11267
- "false",
11268
- "null"
11269
- ]);
11270
- function isDigit(ch) {
11271
- return ch >= "0" && ch <= "9";
11272
- }
11273
- function isIdentStart(ch) {
11274
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
11275
- }
11276
- function isIdentPart(ch) {
11277
- return isIdentStart(ch) || isDigit(ch);
11278
- }
11279
- function isWhitespace(ch) {
11280
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
11281
- }
11282
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
11283
- * Throws `ExpressionParseError` on any illegal character or unterminated
11284
- * string. */
11285
- function tokenize(source) {
11286
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
11287
- const tokens = [];
11288
- let i = 0;
11289
- const n = source.length;
11290
- while (i < n) {
11291
- const ch = source[i];
11292
- if (isWhitespace(ch)) {
11293
- i += 1;
11294
- continue;
11295
- }
11296
- if (isDigit(ch)) {
11297
- const start = i;
11298
- while (i < n && isDigit(source[i])) i += 1;
11299
- if (i < n && source[i] === ".") {
11300
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
11301
- i += 1;
11302
- while (i < n && isDigit(source[i])) i += 1;
11303
- }
11304
- const text = source.slice(start, i);
11305
- const value = Number(text);
11306
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
11307
- tokens.push({
11308
- type: "number",
11309
- value,
11310
- pos: start
11311
- });
11312
- continue;
11313
- }
11314
- if (ch === "'" || ch === "\"") {
11315
- const quote = ch;
11316
- const start = i;
11317
- i += 1;
11318
- let out = "";
11319
- let closed = false;
11320
- while (i < n) {
11321
- const c = source[i];
11322
- if (c === "\\") {
11323
- const next = i + 1 < n ? source[i + 1] : "";
11324
- if (next === "\\" || next === "'" || next === "\"") {
11325
- out += next;
11326
- i += 2;
11327
- continue;
11328
- }
11329
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
11330
- }
11331
- if (c === quote) {
11332
- closed = true;
11333
- i += 1;
11334
- break;
11335
- }
11336
- out += c;
11337
- i += 1;
11338
- }
11339
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
11340
- tokens.push({
11341
- type: "string",
11342
- value: out,
11343
- pos: start
11344
- });
11345
- continue;
11346
- }
11347
- if (isIdentStart(ch)) {
11348
- const start = i;
11349
- while (i < n && isIdentPart(source[i])) i += 1;
11350
- const text = source.slice(start, i);
11351
- if (KEYWORDS.has(text)) tokens.push({
11352
- type: "keyword",
11353
- keyword: keywordOf(text),
11354
- pos: start
11355
- });
11356
- else tokens.push({
11357
- type: "identifier",
11358
- name: text,
11359
- pos: start
11360
- });
11361
- continue;
11362
- }
11363
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
11364
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
11365
- tokens.push({
11366
- type: "punct",
11367
- punct: two,
11368
- pos: i
11369
- });
11370
- i += 2;
11371
- continue;
11372
- }
11373
- if (isSinglePunct(ch)) {
11374
- tokens.push({
11375
- type: "punct",
11376
- punct: ch,
11377
- pos: i
11378
- });
11379
- i += 1;
11380
- continue;
11381
- }
11382
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
11383
- }
11384
- tokens.push({
11385
- type: "eof",
11386
- pos: n
11387
- });
11388
- return tokens;
11389
- }
11390
- function keywordOf(text) {
11391
- if (text === "true") return "true";
11392
- if (text === "false") return "false";
11393
- return "null";
11394
- }
11395
- function isSinglePunct(ch) {
11396
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
11397
- }
11398
- /**
11399
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
11400
- *
11401
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
11402
- * → relational → additive → multiplicative → unary `! -` → call / primary.
11403
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
11404
- * string validated against the builtin table at parse time, so an unknown
11405
- * function is rejected immediately (author feedback) and a persisted expression
11406
- * that references a since-removed builtin degrades at read.
11407
- *
11408
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
11409
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
11410
- */
11411
- /** Binary/logical operator precedence (higher binds tighter). */
11412
- var BINARY_PRECEDENCE = {
11413
- "||": 1,
11414
- "&&": 2,
11415
- "==": 3,
11416
- "!=": 3,
11417
- "<": 4,
11418
- "<=": 4,
11419
- ">": 4,
11420
- ">=": 4,
11421
- "+": 5,
11422
- "-": 5,
11423
- "*": 6,
11424
- "/": 6,
11425
- "%": 6
11426
- };
11427
- function isLogicalOp(op) {
11428
- return op === "&&" || op === "||";
11429
- }
11430
- function isBinaryOp(op) {
11431
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
11432
- }
11433
- var Parser = class {
11434
- tokens;
11435
- pos = 0;
11436
- nodeCount = 0;
11437
- identifiers = /* @__PURE__ */ new Set();
11438
- callees = /* @__PURE__ */ new Set();
11439
- constructor(tokens) {
11440
- this.tokens = tokens;
11441
- }
11442
- parse() {
11443
- const ast = this.parseTernary();
11444
- const tok = this.peek();
11445
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
11446
- return {
11447
- ast,
11448
- identifiers: this.identifiers,
11449
- callees: this.callees,
11450
- nodeCount: this.nodeCount
11451
- };
11452
- }
11453
- peek() {
11454
- return this.tokens[this.pos];
11455
- }
11456
- next() {
11457
- return this.tokens[this.pos++];
11458
- }
11459
- /** Consume a punctuator token, erroring if the next token isn't it. */
11460
- expectPunct(punct) {
11461
- const tok = this.peek();
11462
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
11463
- this.pos += 1;
11464
- }
11465
- matchPunct(punct) {
11466
- const tok = this.peek();
11467
- if (tok.type === "punct" && tok.punct === punct) {
11468
- this.pos += 1;
11469
- return true;
11470
- }
11471
- return false;
11472
- }
11473
- countNode() {
11474
- this.nodeCount += 1;
11475
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
11476
- }
11477
- parseTernary() {
11478
- const test = this.parseBinary(1);
11479
- if (this.matchPunct("?")) {
11480
- const consequent = this.parseTernary();
11481
- this.expectPunct(":");
11482
- const alternate = this.parseTernary();
11483
- this.countNode();
11484
- return {
11485
- kind: "conditional",
11486
- test,
11487
- consequent,
11488
- alternate
11489
- };
11490
- }
11491
- return test;
11492
- }
11493
- parseBinary(minPrec) {
11494
- let left = this.parseUnary();
11495
- for (;;) {
11496
- const tok = this.peek();
11497
- if (tok.type !== "punct") break;
11498
- const prec = BINARY_PRECEDENCE[tok.punct];
11499
- if (prec === void 0 || prec < minPrec) break;
11500
- const op = tok.punct;
11501
- this.pos += 1;
11502
- const right = this.parseBinary(prec + 1);
11503
- this.countNode();
11504
- if (isLogicalOp(op)) left = {
11505
- kind: "logical",
11506
- op,
11507
- left,
11508
- right
11509
- };
11510
- else if (isBinaryOp(op)) left = {
11511
- kind: "binary",
11512
- op,
11513
- left,
11514
- right
11515
- };
11516
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
11517
- }
11518
- return left;
11519
- }
11520
- parseUnary() {
11521
- const tok = this.peek();
11522
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
11523
- const op = tok.punct;
11524
- this.pos += 1;
11525
- const operand = this.parseUnary();
11526
- this.countNode();
11527
- return {
11528
- kind: "unary",
11529
- op,
11530
- operand
11531
- };
11532
- }
11533
- return this.parsePrimary();
11534
- }
11535
- parsePrimary() {
11536
- const tok = this.next();
11537
- switch (tok.type) {
11538
- case "number":
11539
- this.countNode();
11540
- return {
11541
- kind: "literal",
11542
- value: tok.value
11543
- };
11544
- case "string":
11545
- this.countNode();
11546
- return {
11547
- kind: "literal",
11548
- value: tok.value
11549
- };
11550
- case "keyword":
11551
- this.countNode();
11552
- return {
11553
- kind: "literal",
11554
- value: tok.keyword === "null" ? null : tok.keyword === "true"
11555
- };
11556
- case "identifier": {
11557
- const nextTok = this.peek();
11558
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
11559
- this.identifiers.add(tok.name);
11560
- this.countNode();
11561
- return {
11562
- kind: "identifier",
11563
- name: tok.name
11564
- };
11565
- }
11566
- case "punct":
11567
- if (tok.punct === "(") {
11568
- const inner = this.parseTernary();
11569
- this.expectPunct(")");
11570
- return inner;
11571
- }
11572
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
11573
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
11574
- }
11575
- }
11576
- parseCall(callee, pos) {
11577
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
11578
- this.expectPunct("(");
11579
- const args = [];
11580
- if (!this.matchPunct(")")) for (;;) {
11581
- args.push(this.parseTernary());
11582
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
11583
- if (this.matchPunct(",")) continue;
11584
- this.expectPunct(")");
11585
- break;
11586
- }
11587
- this.callees.add(callee);
11588
- this.countNode();
11589
- return {
11590
- kind: "call",
11591
- callee,
11592
- args
11593
- };
11594
- }
11595
- };
11596
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
11597
- * `ExpressionParseError` on any lexical or grammatical failure. */
11598
- function parseExpression(source) {
11599
- return new Parser(tokenize(source)).parse();
11600
- }
11824
+ });
11601
11825
  /**
11602
- * LRU compile cache for parsed expressions (spec §2.4 "parse once LRU keyed
11603
- * by expr"). The cache stores BOTH successes and failures (negative caching),
11604
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
11605
- * one per read on a hot resolve path.
11826
+ * Candidate-list TEXT/query filter. Mirrors the prior `ha-discovery` filter,
11827
+ * de-HA-ified `area` / `manufacturer` stay free-form strings so any
11828
+ * integration can populate them from its own metadata. Distinct from the
11829
+ * granularity `AdoptionFilter` above: this narrows the candidate set within a
11830
+ * chosen granularity, whereas the granularity `filter` selects WHAT a
11831
+ * candidate is (a device vs an entity).
11832
+ */
11833
+ var CandidateQueryFilterSchema = object({
11834
+ /** Substring filter on name + manufacturer + model. */
11835
+ search: string().optional(),
11836
+ /** Area-name exact match. */
11837
+ area: string().optional(),
11838
+ /** Manufacturer exact match. */
11839
+ manufacturer: string().optional(),
11840
+ /** When true, only return candidates the operator already adopted. */
11841
+ adoptedOnly: boolean().optional(),
11842
+ /** When true, only return candidates the operator hasn't adopted yet. */
11843
+ unadoptedOnly: boolean().optional()
11844
+ });
11845
+ var ListCandidatesInputSchema = object({
11846
+ integrationId: string(),
11847
+ page: number().int().positive().default(1),
11848
+ pageSize: number().int().positive().max(2e4).default(50),
11849
+ /**
11850
+ * Optional provider-declared discovery GRANULARITY id (opaque; see
11851
+ * `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
11852
+ * exactly the pre-existing behavior (fully back-compatible).
11853
+ */
11854
+ filter: string().optional(),
11855
+ /** Optional candidate-list text/query narrowing within the granularity. */
11856
+ filterText: CandidateQueryFilterSchema.optional()
11857
+ });
11858
+ var ListCandidatesOutputSchema = object({
11859
+ candidates: array(DiscoveredChildDeviceSchema).readonly(),
11860
+ totalCount: number().int().nonnegative(),
11861
+ page: number().int().positive(),
11862
+ pageSize: number().int().positive()
11863
+ });
11864
+ var GetCandidateInputSchema = object({
11865
+ integrationId: string(),
11866
+ childNativeId: string()
11867
+ });
11868
+ var AdoptionStatusSchema = object({
11869
+ /** Last refresh timestamp (ms epoch) — null when never refreshed. */
11870
+ lastDiscoveryAt: number().int().nonnegative().nullable(),
11871
+ /** Count of candidates in the discovery cache. */
11872
+ candidateCount: number().int().nonnegative(),
11873
+ /** Count of candidates the operator has already adopted. */
11874
+ adoptedCount: number().int().nonnegative(),
11875
+ /** Last error message from a refresh attempt. */
11876
+ lastError: string().nullable()
11877
+ });
11878
+ var PerCandidateSchema = object({
11879
+ /** Override the default display name for this candidate's parent. */
11880
+ name: string().min(1).optional(),
11881
+ /** Pre-hide a subset of entity-children — created (state still flows)
11882
+ * but listed in the parent's `accessories.hiddenChildIds`. */
11883
+ hiddenChildIds: array(string()).optional()
11884
+ });
11885
+ var AdoptInputSchema = object({
11886
+ integrationId: string(),
11887
+ /**
11888
+ * Candidate native ids to adopt. Their MEANING is filter-relative: under the
11889
+ * default `'devices'` granularity these are device-native ids; under a
11890
+ * provider-declared granularity (e.g. HA `'entities'`) they are that
11891
+ * granularity's native ids (e.g. entity ids). The field name is kept stable
11892
+ * to avoid a breaking rename.
11893
+ */
11894
+ childNativeIds: array(string()).min(1),
11895
+ /**
11896
+ * Optional provider-declared discovery GRANULARITY id (opaque; see
11897
+ * `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
11898
+ * exactly the pre-existing behavior (fully back-compatible).
11899
+ */
11900
+ filter: string().optional(),
11901
+ /** When true, import each adopted device's source-system location (e.g. HA
11902
+ * area) into CamStack — fuzzy-match an existing location or create it, then
11903
+ * assign. Omitted/false = no location work (back-compat). */
11904
+ importLocations: boolean().optional(),
11905
+ perCandidate: record(string(), PerCandidateSchema).optional()
11906
+ });
11907
+ var AdoptResultSchema = object({ adopted: array(object({
11908
+ childNativeId: string(),
11909
+ parentDeviceId: number().int().nonnegative(),
11910
+ accessoryDeviceIds: array(number().int().nonnegative()).readonly()
11911
+ })).readonly() });
11912
+ var ReleaseInputSchema = object({
11913
+ integrationId: string(),
11914
+ /** Parent CamStack device id (NOT an accessory child id). Removing
11915
+ * the parent cascades into every accessory. */
11916
+ camDeviceId: number().int().nonnegative()
11917
+ });
11918
+ var ResyncInputSchema = object({
11919
+ /** Parent CamStack device id of an adopted device. The provider resolves its
11920
+ * source (integration/broker + native id) and re-aligns the device's
11921
+ * structural spec (type/role/capabilities/units) with the live mapping,
11922
+ * rebuilding any child whose class changed while preserving operator edits. */
11923
+ camDeviceId: number().int().nonnegative(),
11924
+ /** "Resync from zero" (#19). When true, the kernel PURGES every accessory
11925
+ * child of `camDeviceId` BEFORE the provider re-derives the device, so the
11926
+ * children are rebuilt fresh from source — correct names, coords, and units —
11927
+ * instead of being preserved by the incremental reconcile. Use to recover from
11928
+ * legacy generic/placeholder names that the normal name-precedence keeps frozen
11929
+ * (the operator's explicit reset). Push-driven integrations (no-op resync)
11930
+ * rebuild on their next snapshot; pull/command integrations rebuild in `resync`.
11931
+ * Operator edits on the PARENT (its name, layout, primary-child pick) survive —
11932
+ * only the children are torn down. Omitted/false ⇒ the normal incremental
11933
+ * re-sync that preserves children. */
11934
+ resetToSource: boolean().optional()
11935
+ });
11936
+ var ResyncResultSchema = object({
11937
+ /** True when the persisted spec actually changed (children may have been rebuilt). */
11938
+ changed: boolean(),
11939
+ /** Number of child devices rebuilt into a new class by this re-sync. */
11940
+ rebuiltChildren: number().int().nonnegative(),
11941
+ /** Number of accessory children torn down by a `resetToSource` purge before the
11942
+ * provider re-derived the device. 0/absent for a normal incremental re-sync. */
11943
+ removedChildren: number().int().nonnegative().optional()
11944
+ });
11945
+ 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, {
11946
+ kind: "mutation",
11947
+ auth: "admin"
11948
+ }), method(AdoptInputSchema, AdoptResultSchema, {
11949
+ kind: "mutation",
11950
+ auth: "admin"
11951
+ }), method(ReleaseInputSchema, _void(), {
11952
+ kind: "mutation",
11953
+ auth: "admin"
11954
+ }), method(ResyncInputSchema, ResyncResultSchema, {
11955
+ kind: "mutation",
11956
+ auth: "admin"
11957
+ });
11958
+ /**
11959
+ * `device-export` — collection cap for addons that export camstack
11960
+ * devices to external ecosystems (HomeAssistant via MQTT discovery,
11961
+ * HomeKit/HAP, Alexa Smart Home, …).
11606
11962
  *
11607
- * The cache is a module-level singleton: entries are pure, content-addressed
11608
- * ASTs keyed by the raw source string, so sharing one instance across all
11609
- * callers is safe and maximises hit rate.
11963
+ * No `ecosystem` enum the addon id identifies the export target.
11964
+ * Each addon owns its mapping logic in its own settings UI. The cap
11965
+ * exposes only the COMMON surface: link state, supported device
11966
+ * kinds (so the UI can filter the device picker), and per-device
11967
+ * expose/unexpose.
11610
11968
  */
11611
- var cache = /* @__PURE__ */ new Map();
11612
- function getCached(source) {
11613
- const hit = cache.get(source);
11614
- if (hit !== void 0) {
11615
- cache.delete(source);
11616
- cache.set(source, hit);
11617
- return hit;
11618
- }
11619
- let result;
11620
- try {
11621
- result = {
11622
- ok: true,
11623
- parsed: parseExpression(source)
11624
- };
11625
- } catch (err) {
11626
- result = {
11627
- ok: false,
11628
- error: err instanceof ExpressionParseError ? err.message : String(err)
11629
- };
11630
- }
11631
- cache.set(source, result);
11632
- if (cache.size > 256) {
11633
- const oldest = cache.keys().next().value;
11634
- if (oldest !== void 0) cache.delete(oldest);
11635
- }
11636
- return result;
11637
- }
11638
- /** Compile `source`, returning a discriminated result instead of throwing.
11639
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
11640
- function compileExpressionSafe(source) {
11641
- return getCached(source);
11642
- }
11643
- Object.freeze({});
11969
+ var LinkStateSchema = _enum([
11970
+ "unlinked",
11971
+ "linked",
11972
+ "error"
11973
+ ]);
11644
11974
  /**
11645
- * Author-time validation. Returns `null` when the source is valid, else a
11646
- * human-readable error message. Checks: the expression compiles; binding count
11647
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
11648
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
11649
- * FREE identifier of the AST is covered by a binding or the injected `now`.
11975
+ * A single label/value row surfaced in the export panel's "Setup"
11976
+ * section. Rendered with a copy-to-clipboard button. `secret: true`
11977
+ * rows are masked by default with a reveal toggle (client ids,
11978
+ * secrets, ).
11650
11979
  */
11651
- function validateExpressionSource(src) {
11652
- const names = Object.keys(src.bindings);
11653
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
11654
- for (const name of names) {
11655
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
11656
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
11657
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
11658
- }
11659
- const compiled = compileExpressionSafe(src.expr);
11660
- if (!compiled.ok) return compiled.error;
11661
- const bound = new Set(names);
11662
- for (const id of compiled.parsed.identifiers) {
11663
- if (id === "now") continue;
11664
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
11980
+ var ExportSetupFieldSchema = object({
11981
+ label: string(),
11982
+ value: string(),
11983
+ /** Mask the value by default + render a reveal toggle (client id, secrets). */
11984
+ secret: boolean().optional()
11985
+ });
11986
+ /**
11987
+ * Generic, addon-agnostic pairing/account block. Any export addon can
11988
+ * surface a scannable QR, a set of copyable label/value rows, and a
11989
+ * free-form operator note — the `DeviceExportPanel` renders whatever
11990
+ * the provider supplies and skips the section entirely when `setup`
11991
+ * is unset.
11992
+ */
11993
+ var ExportSetupSchema = object({
11994
+ /** A string to render as a scannable QR — HAP `X-HM://…` URI, a pairing URL, etc. Omitted when there's nothing to scan. */
11995
+ qr: string().optional(),
11996
+ /** Label/value rows shown with a copy button (HAP setup code, OAuth URLs, client id, linked-account count, …). */
11997
+ fields: array(ExportSetupFieldSchema).readonly().optional(),
11998
+ /** Free-form operator instructions rendered above the fields. */
11999
+ note: string().optional()
12000
+ });
12001
+ var DeviceExportStatusSchema = object({
12002
+ linkState: LinkStateSchema,
12003
+ exposedDeviceCount: number(),
12004
+ error: string().optional(),
12005
+ /**
12006
+ * Optional pairing/account info the panel renders in a generic
12007
+ * "Setup" section. Addon-agnostic — the addon id identifies the
12008
+ * export target, never an `ecosystem` key here.
12009
+ */
12010
+ setup: ExportSetupSchema.optional()
12011
+ });
12012
+ var DeviceKindSchema = string();
12013
+ var ExposedDeviceSchema = object({
12014
+ deviceId: string(),
12015
+ exposedAs: string().optional(),
12016
+ capabilities: array(string()).optional()
12017
+ });
12018
+ var ExposeInputSchema = object({
12019
+ deviceId: string(),
12020
+ capabilities: array(string()).optional()
12021
+ });
12022
+ var UnexposeInputSchema = object({ deviceId: string() });
12023
+ var deviceExportCapability = {
12024
+ name: "device-export",
12025
+ scope: "system",
12026
+ mode: "collection",
12027
+ providerKind: "device-export",
12028
+ status: {
12029
+ schema: DeviceExportStatusSchema,
12030
+ kind: "poll"
12031
+ },
12032
+ /**
12033
+ * Each export provider contributes its own per-device "Export" panel
12034
+ * (enabled toggle, preferred stream, addon-specific knobs like the
12035
+ * HA-MQTT discovery prefix). The three framework methods
12036
+ * `getDeviceSettingsContribution` / `getDeviceLiveContribution` /
12037
+ * `applyDeviceSettingsPatch` are auto-injected on the provider
12038
+ * interface and routed to the device-details aggregator.
12039
+ */
12040
+ exposesDeviceSettings: true,
12041
+ methods: {
12042
+ getStatus: method(_void(), DeviceExportStatusSchema),
12043
+ listSupportedDeviceKinds: method(_void(), array(DeviceKindSchema)),
12044
+ listExposedDevices: method(_void(), array(ExposedDeviceSchema)),
12045
+ exposeDevice: method(ExposeInputSchema, _void(), { kind: "mutation" }),
12046
+ unexposeDevice: method(UnexposeInputSchema, _void(), { kind: "mutation" })
11665
12047
  }
11666
- return null;
11667
- }
12048
+ };
11668
12049
  var ProviderStatusSchema = object({
11669
12050
  connected: boolean(),
11670
12051
  deviceCount: number(),
@@ -11789,92 +12170,6 @@ var ChildLayoutEntrySchema = object({
11789
12170
  order: number().optional(),
11790
12171
  collapsed: boolean().optional()
11791
12172
  });
11792
- /** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
11793
- * `device-management.ts`. Source is a union: a FIELD source copies a sibling
11794
- * accessory's status field (`kind` optional/absent for wire compat); a
11795
- * LITERAL source carries a per-device constant (no sibling is read); a
11796
- * GLOBAL source (P2e) copies ANY device's status field, addressed by the
11797
- * source device's full re-sync-stable `stableId`. */
11798
- var DeviceLinkFieldSourceSchema = object({
11799
- kind: literal("field").optional(),
11800
- sourceKey: string(),
11801
- cap: string(),
11802
- fieldPath: string()
11803
- });
11804
- var DeviceLinkLiteralSourceSchema = object({
11805
- kind: literal("literal"),
11806
- value: union([
11807
- string(),
11808
- number(),
11809
- boolean(),
11810
- _null()
11811
- ])
11812
- });
11813
- var DeviceLinkGlobalSourceSchema = object({
11814
- kind: literal("global"),
11815
- sourceStableId: string(),
11816
- cap: string(),
11817
- fieldPath: string()
11818
- });
11819
- /** Expression source (Stage X): compute the target field from N named bindings
11820
- * via the safe expression engine. Bindings are field | literal | global — never
11821
- * another expression (no nesting). The `superRefine` runs the SAME author-time
11822
- * validation as `validateExpressionSource` (compiles the expr, checks binding
11823
- * names + identifier coverage) so every wire boundary that parses a DeviceLink
11824
- * (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
11825
- * Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
11826
- var DeviceLinkExpressionSourceSchema = object({
11827
- kind: literal("expression"),
11828
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
11829
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), union([
11830
- DeviceLinkFieldSourceSchema,
11831
- DeviceLinkLiteralSourceSchema,
11832
- DeviceLinkGlobalSourceSchema
11833
- ]))
11834
- }).superRefine((src, ctx) => {
11835
- const err = validateExpressionSource(src);
11836
- if (err !== null) ctx.addIssue({
11837
- code: "custom",
11838
- message: err,
11839
- path: ["expr"]
11840
- });
11841
- });
11842
- var DeviceLinkSchema = object({
11843
- id: string(),
11844
- source: union([
11845
- DeviceLinkFieldSourceSchema,
11846
- DeviceLinkLiteralSourceSchema,
11847
- DeviceLinkGlobalSourceSchema,
11848
- DeviceLinkExpressionSourceSchema
11849
- ]),
11850
- target: object({
11851
- cap: string(),
11852
- fieldPath: string(),
11853
- itemKey: string().optional()
11854
- }),
11855
- transform: discriminatedUnion("kind", [
11856
- object({ kind: literal("identity") }),
11857
- object({
11858
- kind: literal("enum-map"),
11859
- mapping: record(string(), union([
11860
- string(),
11861
- number(),
11862
- boolean()
11863
- ])),
11864
- fallback: union([
11865
- string(),
11866
- number(),
11867
- boolean()
11868
- ]).optional()
11869
- }),
11870
- object({
11871
- kind: literal("linear"),
11872
- scale: number(),
11873
- offset: number(),
11874
- clamp: tuple([number(), number()]).readonly().optional()
11875
- })
11876
- ]).optional()
11877
- });
11878
12173
  /** Cap-wire shape of a per-cap display refinement — mirrors
11879
12174
  * `DeviceCapDisplayOverride` in `device-management.ts`. */
11880
12175
  var DeviceCapDisplayOverrideSchema = object({
@@ -11954,8 +12249,6 @@ var DeviceInfoSchema = object({
11954
12249
  * named accordion sections (with optional intra-section order). See
11955
12250
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
11956
12251
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
11957
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
11958
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
11959
12252
  /** Operator-authored per-device display override. See `DeviceMeta.display`. */
11960
12253
  display: DeviceDisplayOverrideSchema.optional()
11961
12254
  });
@@ -11964,7 +12257,7 @@ var ConfigEntrySchema = object({
11964
12257
  value: unknown(),
11965
12258
  description: string().optional()
11966
12259
  });
11967
- var DeviceLinkModeSchema = _enum(["auto", "manual"]);
12260
+ var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
11968
12261
  /** One resolved linked device — the compact projection consumers need. */
11969
12262
  var LinkedDeviceSchema = object({
11970
12263
  deviceId: number(),
@@ -12027,8 +12320,6 @@ var DeviceMetaSchema = object({
12027
12320
  * accordion sections (with optional intra-section order). See
12028
12321
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
12029
12322
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
12030
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
12031
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
12032
12323
  /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
12033
12324
  * Optional: only present for accessory children that carry a known role. */
12034
12325
  role: string().nullable().optional(),
@@ -12121,12 +12412,6 @@ method(object({
12121
12412
  }), _void(), {
12122
12413
  kind: "mutation",
12123
12414
  auth: "admin"
12124
- }), method(object({
12125
- deviceId: number(),
12126
- deviceLinks: array(DeviceLinkSchema).readonly()
12127
- }), _void(), {
12128
- kind: "mutation",
12129
- auth: "admin"
12130
12415
  }), method(object({
12131
12416
  deviceId: number(),
12132
12417
  display: DeviceDisplayOverrideSchema.nullable()
@@ -12208,7 +12493,7 @@ method(object({
12208
12493
  * shipping 293 rows to find 12. */
12209
12494
  isCamera: boolean().optional()
12210
12495
  }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
12211
- mode: DeviceLinkModeSchema,
12496
+ mode: LinkedDevicesModeSchema,
12212
12497
  devices: array(LinkedDeviceSchema)
12213
12498
  })), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
12214
12499
  deviceId: number(),
@@ -12241,11 +12526,7 @@ method(object({
12241
12526
  deviceId: number(),
12242
12527
  entries: array(object({
12243
12528
  capName: string(),
12244
- kind: _enum([
12245
- "native",
12246
- "wrapped",
12247
- "linked"
12248
- ]),
12529
+ kind: _enum(["native", "wrapped"]),
12249
12530
  providerAddonId: string(),
12250
12531
  providerNodeId: string(),
12251
12532
  nativeAddonId: string()
@@ -12254,11 +12535,7 @@ method(object({
12254
12535
  deviceId: number(),
12255
12536
  entries: array(object({
12256
12537
  capName: string(),
12257
- kind: _enum([
12258
- "native",
12259
- "wrapped",
12260
- "linked"
12261
- ]),
12538
+ kind: _enum(["native", "wrapped"]),
12262
12539
  providerAddonId: string(),
12263
12540
  providerNodeId: string(),
12264
12541
  nativeAddonId: string()
@@ -12359,6 +12636,15 @@ method(object({
12359
12636
  }), method(ReleaseInputSchema.extend({ addonId: string() }), _void(), {
12360
12637
  kind: "mutation",
12361
12638
  auth: "admin"
12639
+ }), method(AdoptInputSchema.extend({ addonId: string() }), object({ jobId: string() }), {
12640
+ kind: "mutation",
12641
+ auth: "admin"
12642
+ }), method(object({
12643
+ addonId: string(),
12644
+ integrationId: string().optional()
12645
+ }), array(AdoptionJobSchema).readonly(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
12646
+ kind: "mutation",
12647
+ auth: "admin"
12362
12648
  }), method(ResyncInputSchema, ResyncResultSchema, {
12363
12649
  kind: "mutation",
12364
12650
  auth: "admin"
@@ -13061,7 +13347,7 @@ var MotionAnalysisResultSchema = object({
13061
13347
  frameHeight: number(),
13062
13348
  analysisMs: number()
13063
13349
  });
13064
- method(object({
13350
+ DeviceType.Camera, method(object({
13065
13351
  deviceId: number(),
13066
13352
  frame: FrameInputSchema.optional(),
13067
13353
  frameHandle: FrameHandleSchema.optional()
@@ -13081,7 +13367,7 @@ method(object({
13081
13367
  * Why: pub/sub routing over the system event-bus loses fidelity
13082
13368
  * (callback shape, QoS guarantees, will/retain semantics) and adds
13083
13369
  * refcount bookkeeping that addons would rather own themselves. The
13084
- * canonical consumer (`addon-export-ha-mqtt`) needs raw `mqtt.js`
13370
+ * canonical consumer needs raw `mqtt.js`
13085
13371
  * features anyway — give it the connection config, get out of the way.
13086
13372
  *
13087
13373
  * Consumer flow:
@@ -14246,8 +14532,35 @@ var NcDeliverySchema = _enum([
14246
14532
  "immediate",
14247
14533
  "track-end",
14248
14534
  "device-event",
14249
- "package-event"
14535
+ "package-event",
14536
+ "system-event"
14250
14537
  ]);
14538
+ /**
14539
+ * Stable Notification Center vocabulary over infrastructure/liveness events.
14540
+ * Bus categories are normalized into these intent-level kinds so rules do not
14541
+ * depend on a provider's raw event name or payload shape.
14542
+ */
14543
+ var NcSystemEventKindSchema = _enum([
14544
+ "camera-online",
14545
+ "camera-offline",
14546
+ "stream-online",
14547
+ "stream-offline",
14548
+ "node-online",
14549
+ "node-offline",
14550
+ "addon-update-available",
14551
+ "server-update-available"
14552
+ ]);
14553
+ /**
14554
+ * One coherent system-event condition. `kinds` is the required opt-in safety
14555
+ * gate; the remaining lists are optional narrowing filters relevant to the
14556
+ * selected kinds.
14557
+ */
14558
+ var NcSystemEventConditionSchema = object({
14559
+ kinds: array(NcSystemEventKindSchema).min(1),
14560
+ deviceIds: array(number().int()).min(1).optional(),
14561
+ nodeIds: array(string().min(1)).min(1).optional(),
14562
+ packageNames: array(string().min(1)).min(1).optional()
14563
+ });
14251
14564
  /** Weekly schedule — OR of windows; absence on the rule = always active. */
14252
14565
  var NcScheduleSchema = object({
14253
14566
  windows: array(object({
@@ -14552,6 +14865,8 @@ var NcConditionsSchema = object({
14552
14865
  "picked-up",
14553
14866
  "both"
14554
14867
  ]).optional(),
14868
+ /** Infrastructure/liveness/update event matcher (`system-event` delivery). */
14869
+ systemEvent: NcSystemEventConditionSchema.optional(),
14555
14870
  /**
14556
14871
  * PERSONAL-RULE custom zones (viewer-drawn). Inline normalized polygons
14557
14872
  * (MaskShape vocabulary). A record passes when its bbox overlaps ANY
@@ -14771,7 +15086,8 @@ var NcTestResultSchema = object({
14771
15086
  "object-event",
14772
15087
  "track",
14773
15088
  "device-event",
14774
- "package-event"
15089
+ "package-event",
15090
+ "system-event"
14775
15091
  ]),
14776
15092
  deviceId: number(),
14777
15093
  timestamp: number(),
@@ -14793,7 +15109,8 @@ var NcConditionDescriptorSchema = object({
14793
15109
  "schedule",
14794
15110
  "device",
14795
15111
  "package",
14796
- "occupancy"
15112
+ "occupancy",
15113
+ "system"
14797
15114
  ]),
14798
15115
  label: string(),
14799
15116
  /** Editor widget the UI renders — never hardcode per-condition forms. */
@@ -14811,7 +15128,8 @@ var NcConditionDescriptorSchema = object({
14811
15128
  "crossingSelect",
14812
15129
  "polygonDraw",
14813
15130
  "occupancy",
14814
- "deviceState"
15131
+ "deviceState",
15132
+ "systemEvent"
14815
15133
  ]),
14816
15134
  operator: _enum([
14817
15135
  "in",
@@ -14870,7 +15188,8 @@ var NcHistoryRecordKindSchema = _enum([
14870
15188
  "object-event",
14871
15189
  "track-end",
14872
15190
  "device-event",
14873
- "package-event"
15191
+ "package-event",
15192
+ "system-event"
14874
15193
  ]);
14875
15194
  /** Subject summary frozen on the row at fire time (survives rule/record edits). */
14876
15195
  var NcHistorySubjectSchema = object({
@@ -14878,7 +15197,14 @@ var NcHistorySubjectSchema = object({
14878
15197
  label: string().optional(),
14879
15198
  confidence: number().optional(),
14880
15199
  zones: array(string()),
14881
- timestamp: number()
15200
+ timestamp: number(),
15201
+ systemEvent: object({
15202
+ kind: NcSystemEventKindSchema,
15203
+ subject: string(),
15204
+ title: string(),
15205
+ body: string(),
15206
+ data: record(string(), unknown())
15207
+ }).optional()
14882
15208
  });
14883
15209
  /**
14884
15210
  * One delivery-history row. This is a read-only VIEW over the durable
@@ -15238,6 +15564,76 @@ object({
15238
15564
  * Each provider returns a static descriptor; the core enumerates them
15239
15565
  * to validate the `integration=` query param and resolve the consent
15240
15566
  * label + the scopes baked into the issued token.
15567
+ *
15568
+ * ## Declaring one
15569
+ *
15570
+ * An OAuth client is integration-specific knowledge — who the client is, what
15571
+ * it may ask for, where it may be sent — so it is declared by the ADDON that
15572
+ * owns the integration, never by the kernel and never as a branch inside
15573
+ * `oauth2-routes.ts` ([D101](../../../../docs/decisions/adr-0101.md)). Three
15574
+ * steps, no others:
15575
+ *
15576
+ * 1. Add `{ "name": "oauth-integration" }` to the addon's `camstack.addons[]`
15577
+ * manifest entry. This is also what tells the hub, at addon-LOAD time, that
15578
+ * a descriptor is owed — see "the boot window" below.
15579
+ * 2. Return a provider from `onInitialize()`:
15580
+ *
15581
+ * ```ts
15582
+ * const provider: IOauthIntegrationProvider = {
15583
+ * getDescriptor: async () => ({
15584
+ * integrationId: 'my-thing', // the `integration=` query param
15585
+ * displayName: 'My Thing',
15586
+ * requestedScopes: [ … ], // see below
15587
+ * allowedRedirectPrefixes: ['https://callback.example/'],
15588
+ * }),
15589
+ * }
15590
+ * return [{ capability: oauthIntegrationCapability, provider }]
15591
+ * ```
15592
+ *
15593
+ * The descriptor must be **static** — it is read on the authorize path, so
15594
+ * never put an await on network or disk behind it, and never register it
15595
+ * behind one either (a provider is registered only once `onInitialize`
15596
+ * RETURNS, so anything awaited before the return delays linking).
15597
+ * 3. Nothing else. There is no allow-list to join, no id to register with the
15598
+ * core, and no per-integration branch anywhere: `/api/oauth2/authorize` and
15599
+ * `/api/oauth2/integrations` are built from this collection alone.
15600
+ *
15601
+ * **Scopes. `requestedScopes` has exactly ONE meaning: what the integration
15602
+ * NEEDS to function.** Not a blast radius, not a conservative
15603
+ * under-declaration, not a description of some other path the addon happens to
15604
+ * have. Derive it from what the client actually calls **with this token** —
15605
+ * every tRPC path against `METHOD_ACCESS_MAP`, plus an `addon:` grant for every
15606
+ * addon HTTP route it posts to — and write the call that justifies each entry
15607
+ * next to it. Two integrations once used this field to mean two different
15608
+ * things; the operator ruled there is one meaning, and any third integration
15609
+ * inherits it (2026-08-09).
15610
+ *
15611
+ * This is not documentation, it is the ENFORCEMENT INPUT. Since
15612
+ * [D103](../../../../docs/decisions/adr-0103.md) the `/addon/:addonId/*` gate
15613
+ * checks an integration token's grant before letting it reach an
15614
+ * `access: 'authenticated'` route, so an **under-declaration is an integration
15615
+ * that stops working** — a missing `addon:` entry means `403 Token scope
15616
+ * mismatch` on every control the client tries to actuate. Widen the descriptor
15617
+ * honestly rather than weakening a check to make a route pass.
15618
+ *
15619
+ * Prefer a narrow `capability:` scope to a `category:` one unless the client
15620
+ * genuinely needs a whole family; a category scope grants every future member
15621
+ * of that category too. `category:system [create]` has been rejected once and
15622
+ * should stay rejected: it hands `addons.installPackage` to an integration.
15623
+ *
15624
+ * Calls the ADDON itself makes over `ctx.api` run as the addon and are not
15625
+ * scope-checked, so they are not what this field describes — but reaching the
15626
+ * addon's route in the first place IS, and that is the entry to declare.
15627
+ *
15628
+ * **The boot window.** An addon registers its provider after its runner forks
15629
+ * and initialises, so between hub start and that moment this collection is
15630
+ * incomplete and an `integrationId` can be legitimately absent. The core does
15631
+ * not wait, poll or cache around this ([D3](../../../../docs/decisions/adr-0003.md)):
15632
+ * it compares the manifest declarers against the registered providers and
15633
+ * answers `503 temporarily_unavailable` (with `Retry-After` and the pending
15634
+ * addon ids) instead of `400 unknown integration`, and reports
15635
+ * `complete: false` on `GET /api/oauth2/integrations`. A client should retry
15636
+ * while the list is incomplete rather than conclude the hub cannot do OAuth.
15241
15637
  */
15242
15638
  var OauthIntegrationDescriptorSchema = object({
15243
15639
  /** Stable id used as the `integration=` query param, e.g. 'export-alexa'. */
@@ -15250,13 +15646,48 @@ var OauthIntegrationDescriptorSchema = object({
15250
15646
  * redirect_uri that does not start with one of these. Required —
15251
15647
  * an empty list means the integration can never complete linking. */
15252
15648
  allowedRedirectPrefixes: array(string()).min(1),
15649
+ /** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
15650
+ * RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
15651
+ * `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
15652
+ * whose address the hub cannot know in advance (a Home Assistant at
15653
+ * `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
15654
+ * exactly; a public host never satisfies this branch, so it is not a
15655
+ * wildcard prefix by another name. */
15656
+ allowedPrivateHostPaths: array(string()).optional(),
15657
+ /** When true this is a PUBLIC client (source is published, no secret can be
15658
+ * protected) and PKCE is mandatory: `/authorize` refuses without an S256
15659
+ * `code_challenge`, `/token` refuses without the matching `code_verifier`. */
15660
+ requiresPkce: boolean().optional(),
15253
15661
  /** Optional public origin (no trailing slash) that this integration's
15254
15662
  * issued codes/tokens should carry as the `hubUrl` claim — typically the
15255
15663
  * operator-selected external-access endpoint resolved by the addon. When
15256
15664
  * present, /api/oauth2/authorize bakes THIS into the code instead of the
15257
15665
  * hub-global `publicHubUrl()`, so a forked exporter addon (which can't set
15258
15666
  * the hub's env) drives the claim that its cloud Lambda routes back on. */
15259
- hubUrl: string().optional()
15667
+ hubUrl: string().optional(),
15668
+ /**
15669
+ * How long a REFRESH token issued for this integration lives — seconds, or
15670
+ * `'never'` for a token minted with no `exp` claim at all. Omit to keep the
15671
+ * 30-day default, which is what every link used before this field existed.
15672
+ *
15673
+ * Declared here for the same reason `requestedScopes` is: the integration
15674
+ * knows what it needs. Amazon's account linking and a Home Assistant config
15675
+ * entry are both meant to survive indefinitely, and re-linking is a manual
15676
+ * user action, so a 30-day expiry silently unlinks a working integration.
15677
+ *
15678
+ * **The security posture, stated so it is owned deliberately.** A refresh
15679
+ * token that never expires is permanent access if it leaks. What bounds it is
15680
+ * revocation, not time: `oauthRefresh` re-reads the session on every use and
15681
+ * returns `null` once `revokedAt` is set, as does `oauthVerifyAccessToken`.
15682
+ * The one gap is the ACCESS token — it is a plain signed JWT that nothing
15683
+ * re-checks against the session on the `/trpc` and `/addon/*` paths, so
15684
+ * revoking a link takes effect there only after its remaining hour. That hour
15685
+ * is why the access TTL is not configurable.
15686
+ *
15687
+ * The value is baked into the authorization code at `/authorize` and travels
15688
+ * on the tokens, so editing this field changes FUTURE links only.
15689
+ */
15690
+ refreshTokenTtlSec: union([number().int().positive(), literal("never")]).optional()
15260
15691
  });
15261
15692
  method(_void(), OauthIntegrationDescriptorSchema);
15262
15693
  /**
@@ -15400,7 +15831,7 @@ var TrackEnvelopeSchema = object({
15400
15831
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
15401
15832
  * keeps every scalar the list surfaces actually render (ids, class(es),
15402
15833
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
15403
- * zonesVisited, bestEventId, envelope) and returns `positions` /
15834
+ * zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
15404
15835
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
15405
15836
  * `getTrack`. Mirrors the event-store `projection` convention
15406
15837
  * (`getObjectEvents` et al.).
@@ -15609,8 +16040,26 @@ var TrackSchema = object({
15609
16040
  /** Periodic snapshots at snapshotIntervalMs cadence (subject to
15610
16041
  * saveThumbnails policy). */
15611
16042
  snapshots: array(TrackSnapshotSchema).readonly(),
15612
- /** Deduplicated zones the track has entered at least once. */
16043
+ /** Deduplicated zones the track has entered at least once. Zone IDS. */
15613
16044
  zonesVisited: array(string()).readonly(),
16045
+ /**
16046
+ * Human NAMES for {@link zonesVisited}, resolved at READ time against the
16047
+ * `zones` capability.
16048
+ *
16049
+ * `zonesVisited` persists ids (`cfeec78c-8d69-…`), which no operator can type
16050
+ * and no card can render — so every free-text search surface was structurally
16051
+ * unable to answer "show me the tracks in Uscio", and did not fail loudly, it
16052
+ * just returned nothing. Resolving here rather than in each client keeps ONE
16053
+ * derivation and costs the clients no extra call (the `zones` cap is
16054
+ * per-device, so a client-side resolve would be a per-camera fan-out on a
16055
+ * surface built to avoid exactly that).
16056
+ *
16057
+ * Resolved, never invented: a zone deleted since the track was written has no
16058
+ * name and is DROPPED, so this array can be shorter than `zonesVisited` — the
16059
+ * two are not positionally aligned. Absent when the track visited no zone, or
16060
+ * when the zone catalogue could not be read.
16061
+ */
16062
+ zoneNames: array(string()).readonly().optional(),
15614
16063
  /** Deduplicated set of detector classes observed for this track over its
15615
16064
  * life (a track may be reclassified, e.g. person→vehicle). Absent on
15616
16065
  * legacy rows written before class accumulation shipped. */
@@ -15637,6 +16086,24 @@ var TrackSchema = object({
15637
16086
  * Populated from the persisted envelope columns on historical reads;
15638
16087
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
15639
16088
  envelope: TrackEnvelopeSchema.optional(),
16089
+ /**
16090
+ * A face DETECTOR found a face on this track — nothing more. It says the
16091
+ * detail plane produced a `face` detail; it does NOT say the face was
16092
+ * embedded, matched, above `minFacePx`, or that the recognizer was even
16093
+ * enabled. Set once and never cleared.
16094
+ *
16095
+ * **This exists so "face present but not recognised" is expressible.** A
16096
+ * recognised identity lands in `subLabel` (attributed to the face chain via
16097
+ * `subLabelMeta.stepId`), so before this field a track with an unmatched face
16098
+ * and a track with no face at all were byte-identical on the wire and no
16099
+ * surface could tell them apart. The read is `hasFace === true && subLabel
16100
+ * === undefined`.
16101
+ *
16102
+ * **Absent ≠ false.** Every row written before the column existed omits it,
16103
+ * and so does every server that predates the field — a consumer must test
16104
+ * `=== true` and render nothing otherwise, never infer "no face".
16105
+ */
16106
+ hasFace: boolean().optional(),
15640
16107
  ...TrackFlagFields,
15641
16108
  ...TrackRetrainFields
15642
16109
  });
@@ -17662,6 +18129,23 @@ var CameraRecordingStatusSchema = object({
17662
18129
  active: boolean(),
17663
18130
  storageBytes: number()
17664
18131
  });
18132
+ /** One stage of the fan-out that could NOT be read, and how long it cost. */
18133
+ var CameraStatusDegradationSchema = object({
18134
+ stage: _enum([
18135
+ "source",
18136
+ "broker",
18137
+ "detection",
18138
+ "recording",
18139
+ "switches"
18140
+ ]),
18141
+ reason: _enum([
18142
+ "timeout",
18143
+ "error",
18144
+ "partial"
18145
+ ]),
18146
+ /** Wall-clock ms spent on the stage before it was abandoned. */
18147
+ elapsedMs: number()
18148
+ });
17665
18149
  /**
17666
18150
  * Aggregated per-camera pipeline status — server-composed, single call.
17667
18151
  *
@@ -17692,9 +18176,28 @@ var CameraStatusSchema = object({
17692
18176
  * differently — a quiet camera that looks identical to a dead one is the
17693
18177
  * silence-reads-as-never-happened trap this repo keeps paying for.
17694
18178
  *
17695
- * Empty when nothing is off. Never contains a switch no provider offers.
18179
+ * Empty when nothing is off, and never contains a switch no provider offers
18180
+ * — but an empty list is only a POSITIVE claim when `degraded` does not name
18181
+ * `'switches'`. When it does, the switch set could not be read and nothing
18182
+ * here may be rendered as "the operator turned nothing off": that is the
18183
+ * D62 failure (a camera we could not read painted as broken) in the very
18184
+ * field that exists to prevent it.
17696
18185
  */
17697
18186
  switchedOff: array(CameraSwitchIdSchema).readonly(),
18187
+ /**
18188
+ * Stages of the bounded fan-out that were CUT SHORT — a timeout or a
18189
+ * rejection — and whose block is therefore `null` because we could not
18190
+ * READ it, not because there is nothing there.
18191
+ *
18192
+ * Without this, three different facts arrive as the same `null`: "the stage
18193
+ * timed out", "the stage failed", and "this camera legitimately has no
18194
+ * decoder / no recording". Every surface that draws a conclusion from a null
18195
+ * block (or from an empty `switchedOff`) must consult this first; a stage
18196
+ * named here supports no conclusion at all, only "unknown".
18197
+ *
18198
+ * Empty on a clean read — the overwhelmingly common case.
18199
+ */
18200
+ degraded: array(CameraStatusDegradationSchema).readonly(),
17698
18201
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
17699
18202
  fetchedAt: number()
17700
18203
  });
@@ -18212,6 +18715,37 @@ DeviceType.Camera, method(object({
18212
18715
  lastCapturedAt: number().nullable(),
18213
18716
  cacheAgeMs: number().nullable(),
18214
18717
  etag: string().nullable()
18718
+ }))), systemMethod(object({
18719
+ /** The tiles a surface is actually rendering. One entry per (device,
18720
+ * width) the caller will paint — the width is snapped to the server's
18721
+ * ladder and becomes part of the link's SIGNED identity. */
18722
+ targets: array(object({
18723
+ deviceId: number(),
18724
+ /** Target width in px. Omit for the frame as captured — correct
18725
+ * for a full-bleed surface, wrong (and expensive) for a grid. */
18726
+ width: number().int().positive().optional()
18727
+ })).min(1).max(200) }), array(object({
18728
+ deviceId: number(),
18729
+ /** Root-relative signed path, or null when the link plane is not
18730
+ * served (no data-plane facility). Present even for a device that has
18731
+ * never captured — the request is what triggers the first one (D94). */
18732
+ url: string().nullable(),
18733
+ /** Epoch ms of the frame this link serves. Null = never captured.
18734
+ * THE honest age: the tRPC path carried none before this. */
18735
+ capturedAt: number().nullable(),
18736
+ /** Age of that frame at the moment the answer was built. */
18737
+ ageMs: number().nullable(),
18738
+ /** Epoch ms after which `url` stops verifying. */
18739
+ expiresAt: number().nullable(),
18740
+ /** Ladder rung the bytes are at; null = the frame as captured. */
18741
+ width: number().nullable(),
18742
+ /** The device has never produced a frame. An empty state, not a
18743
+ * failure — and never a reason to withhold the link (D94). */
18744
+ neverCaptured: boolean(),
18745
+ /** A sleeping battery camera: the frame is deliberately stale and will
18746
+ * NOT refresh in the background. A surface should say so rather than
18747
+ * present it as current. */
18748
+ sleeping: boolean()
18215
18749
  })));
18216
18750
  /**
18217
18751
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
@@ -18251,13 +18785,35 @@ var SsoBridgeClaimsSchema = object({
18251
18785
  integrationId: string().optional(),
18252
18786
  /** JWT ID — unique per issued code; consumed-set enforces single-use. */
18253
18787
  jti: string().optional(),
18788
+ /** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
18789
+ * client. Its PRESENCE is what makes the verifier mandatory at exchange,
18790
+ * so the requirement travels with the code and not with mutable config. */
18791
+ codeChallenge: string().optional(),
18254
18792
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
18255
18793
  * tokens so the verify path can check the session is not revoked. */
18256
- sessionId: string().optional()
18794
+ sessionId: string().optional(),
18795
+ /**
18796
+ * The refresh lifetime this LINK was created with, in seconds, or `'never'`.
18797
+ * Baked into the code at `/authorize` from the integration's descriptor and
18798
+ * carried forward so `oauthRefresh` re-mints with the same lifetime. It rides
18799
+ * on the token rather than being re-read from the descriptor on purpose:
18800
+ * editing a descriptor must not retroactively extend or shorten a link the
18801
+ * operator already consented to.
18802
+ */
18803
+ refreshTtl: union([number().int().positive(), literal("never")]).optional()
18257
18804
  });
18258
18805
  method(object({
18259
18806
  claims: SsoBridgeClaimsSchema,
18260
- ttlSec: number().int().positive().optional()
18807
+ /**
18808
+ * Seconds, or `'never'` for a token minted with NO `exp` claim.
18809
+ *
18810
+ * `'never'` is a literal rather than `undefined`/`0` because omitting
18811
+ * this field already means "the 5-minute SSO hand-off default", and
18812
+ * `jwt.sign` THROWS on `{ expiresIn: undefined }` — a "no expiry" that
18813
+ * went through the numeric path would fail at mint time and break
18814
+ * linking rather than produce an eternal token.
18815
+ */
18816
+ ttlSec: union([number().int().positive(), literal("never")]).optional()
18261
18817
  }), object({ token: string() })), method(object({ token: string() }), SsoBridgeClaimsSchema.nullable());
18262
18818
  var ProviderListEntrySchema = discriminatedUnion("shouldSaveDiskSpace", [object({
18263
18819
  providerId: string().min(1),
@@ -18803,7 +19359,7 @@ var ClipPlaybackSchema = object({
18803
19359
  playbackEndpoints: array(string()).optional(),
18804
19360
  token: string().optional()
18805
19361
  });
18806
- method(object({
19362
+ DeviceType.Camera, method(object({
18807
19363
  deviceId: number(),
18808
19364
  since: number(),
18809
19365
  until: number(),
@@ -19958,6 +20514,37 @@ DeviceType.Light, method(object({
19958
20514
  mireds: number().int().optional(),
19959
20515
  lastChangedAt: number()
19960
20516
  });
20517
+ var ConnectionTestOutcomeSchema = discriminatedUnion("outcome", [
20518
+ object({
20519
+ outcome: literal("validated"),
20520
+ /** Round-trip of the sign-in, when the provider measured it. */
20521
+ latencyMs: number().nonnegative().optional(),
20522
+ /** Optional human detail worth showing next to the tick
20523
+ * ("3 devices visible on this account"). */
20524
+ detail: string().optional()
20525
+ }).strict(),
20526
+ object({
20527
+ outcome: literal("rejected"),
20528
+ error: string()
20529
+ }).strict(),
20530
+ object({
20531
+ outcome: literal("inconclusive"),
20532
+ error: string()
20533
+ }).strict()
20534
+ ]);
20535
+ var ConnectionTestInputSchema = object({
20536
+ /** Candidate integration settings, exactly as the create form collected them. */
20537
+ settings: record(string(), unknown()) });
20538
+ /**
20539
+ * What the provider's test actually DOES, so the UI can say it in words before
20540
+ * the operator presses the button ("Signs in to the Dreo cloud"). Purely
20541
+ * descriptive — it never changes routing.
20542
+ */
20543
+ var ConnectionTestDescriptorSchema = object({ label: string() });
20544
+ method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
20545
+ kind: "mutation",
20546
+ auth: "admin"
20547
+ }), method(_void(), ConnectionTestDescriptorSchema, { auth: "admin" });
19961
20548
  object({
19962
20549
  /** True when the upstream system considers the entity connected. */
19963
20550
  connected: boolean(),
@@ -20315,7 +20902,29 @@ var FaceInfoSchema = object({
20315
20902
  recognizedIdentityId: string().optional(),
20316
20903
  identityName: string().optional(),
20317
20904
  assigned: boolean(),
20905
+ /**
20906
+ * The crop, inline, base64.
20907
+ *
20908
+ * **Prefer {@link cropUrl}.** At the 500 rows the Faces view asks for this
20909
+ * field alone is ~2.87 MiB, re-sent in full on every operator assign and
20910
+ * every 30 s poll, base64-inflated over the msgpack socket and held in the
20911
+ * query heap. It stays for callers that have not migrated; `includeCrops:
20912
+ * false` turns it off once they have.
20913
+ */
20318
20914
  base64: string().optional(),
20915
+ /**
20916
+ * Same crop, as a data-plane URL for `<img src>` — the move the admin
20917
+ * snapshot surfaces made on 2026-08-08.
20918
+ *
20919
+ * Served by the `event-media` plane, which resolves a raw MediaStore key and
20920
+ * is `access: 'authenticated'`: a bare `<img>` carries the `camstack_session`
20921
+ * cookie, so no header plumbing is needed. The bytes then ride the browser's
20922
+ * HTTP cache with an ETag and `immutable`, instead of the WebSocket.
20923
+ *
20924
+ * Absent when the face has no stored crop, or when the addon has no data
20925
+ * plane — callers fall back to {@link base64}.
20926
+ */
20927
+ cropUrl: string().optional(),
20319
20928
  /** Design B: the face bbox (pixel space) on the key frame — lets a detail
20320
20929
  * view draw the box over the native `keyFrameMediaKey` frame. Absent on
20321
20930
  * legacy rows written before design B. */
@@ -20380,7 +20989,23 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
20380
20989
  auth: "admin"
20381
20990
  }), method(object({
20382
20991
  limit: number().int().positive().optional(),
20383
- filter: FaceFilterEnum.optional()
20992
+ filter: FaceFilterEnum.optional(),
20993
+ /**
20994
+ * Inline the base64 crop on every row. Default `true` — the existing
20995
+ * behaviour, kept so no caller breaks.
20996
+ *
20997
+ * Set `false` once the caller renders {@link FaceInfo.cropUrl}: that
20998
+ * drops ~2.87 MiB per 500-row page to a few KiB of metadata and lets
20999
+ * the browser cache the images.
21000
+ *
21001
+ * **This is an INPUT field, so it does not reach the addon until the
21002
+ * next train.** The hub router validates cap inputs against its own
21003
+ * compiled Zod, which strips a key it does not know — verified today
21004
+ * on the OUTPUT side, where an additive field DOES arrive immediately
21005
+ * (`Track.hasFace`). Until the train ships, sending `false` is
21006
+ * harmless and simply keeps the crops inline.
21007
+ */
21008
+ includeCrops: boolean().optional()
20384
21009
  }).optional(), array(FaceInfoSchema).readonly()), method(object({
20385
21010
  deviceId: number().int(),
20386
21011
  trackId: string()
@@ -20821,15 +21446,57 @@ var AvailableIntegrationTypeSchema = object({
20821
21446
  * flow can import (e.g. HA areas). Drives the adopt modal's "import
20822
21447
  * locations" checkbox. Provider-declared in the addon manifest. */
20823
21448
  supportsLocationImport: boolean(),
21449
+ /**
21450
+ * True when this integration DECLARES a pre-creation test (the
21451
+ * `connection-test` cap, or a broker whose settings it stores). Drives the
21452
+ * Test button: an integration that cannot be tested must say so up front
21453
+ * rather than offering a button that always answers the same nonsense.
21454
+ */
21455
+ canTest: boolean(),
20824
21456
  existingInstances: array(object({
20825
21457
  id: string(),
20826
21458
  name: string()
20827
21459
  })),
20828
21460
  canAdd: boolean()
20829
21461
  });
21462
+ /**
21463
+ * Why a test could not be answered as a plain boolean.
21464
+ *
21465
+ * `success` alone collapsed four different situations into one red box, and the
21466
+ * one that mattered most — "nobody ever asked the remote anything" — looked
21467
+ * exactly like "the remote said no". The status is the discriminator:
21468
+ *
21469
+ * - `validated` — a provider-declared test ran and the remote ACCEPTED.
21470
+ * - `rejected` — a provider-declared test ran and the remote REFUSED.
21471
+ * The only status that blocks `integrations.create`.
21472
+ * - `inconclusive` — a test IS declared but could not complete (timeout,
21473
+ * DNS, 5xx). Nothing was observed; not a failure.
21474
+ * - `unsupported` — this integration declares NO test. Nothing was
21475
+ * observed either; not a failure, and not a pass.
21476
+ *
21477
+ * `unsupported` and `inconclusive` both carry `success: false` so an older
21478
+ * client can never read them as a green tick, and both carry an `error` string
21479
+ * that SAYS the test did not run rather than inventing a failure.
21480
+ */
21481
+ var TestConnectionStatusEnum = _enum([
21482
+ "validated",
21483
+ "rejected",
21484
+ "inconclusive",
21485
+ "unsupported"
21486
+ ]);
20830
21487
  var TestConnectionResultSchema$1 = object({
21488
+ /** True ONLY for `validated`. Never true for a test that did not run. */
20831
21489
  success: boolean(),
20832
- error: string().optional()
21490
+ error: string().optional(),
21491
+ /** Optional for wire back-compat with clients built before the tri-state;
21492
+ * the server always sets it. */
21493
+ status: TestConnectionStatusEnum.optional(),
21494
+ /** Addon id whose declared test answered — `null` when none did. Lets the UI
21495
+ * attribute a result instead of blaming "the integration". */
21496
+ testedBy: string().nullable().optional(),
21497
+ latencyMs: number().nonnegative().optional(),
21498
+ /** Human detail from a `validated` result ("3 devices on this account"). */
21499
+ detail: string().optional()
20833
21500
  });
20834
21501
  var CreateIntegrationInputSchema = object({
20835
21502
  addonId: string(),
@@ -23879,13 +24546,23 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
23879
24546
  username: string(),
23880
24547
  scopes: array(TokenScopeSchema),
23881
24548
  redirectUri: string(),
23882
- hubUrl: string()
24549
+ hubUrl: string(),
24550
+ /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
24551
+ * that carries one can ONLY be exchanged with the matching verifier. */
24552
+ codeChallenge: string().optional(),
24553
+ /** The integration's declared refresh lifetime — seconds, or `'never'`.
24554
+ * From `OauthIntegrationDescriptor.refreshTokenTtlSec`. Baked into the
24555
+ * code so the link carries its own lifetime; omit for the 30-day
24556
+ * default. */
24557
+ refreshTtlSec: union([number().int().positive(), literal("never")]).optional()
23883
24558
  }), object({ code: string() }), {
23884
24559
  kind: "mutation",
23885
24560
  access: "create"
23886
24561
  }), method(object({
23887
24562
  code: string(),
23888
- redirectUri: string()
24563
+ redirectUri: string(),
24564
+ /** PKCE verifier. REQUIRED when the code carries a challenge. */
24565
+ codeVerifier: string().optional()
23889
24566
  }), object({
23890
24567
  accessToken: string(),
23891
24568
  refreshToken: string(),
@@ -25070,6 +25747,18 @@ Object.freeze({
25070
25747
  addonId: null,
25071
25748
  access: "create"
25072
25749
  },
25750
+ "connectionTest.describeTest": {
25751
+ capName: "connection-test",
25752
+ capScope: "system",
25753
+ addonId: null,
25754
+ access: "view"
25755
+ },
25756
+ "connectionTest.testSettings": {
25757
+ capName: "connection-test",
25758
+ capScope: "system",
25759
+ addonId: null,
25760
+ access: "create"
25761
+ },
25073
25762
  "consumables.reset": {
25074
25763
  capName: "consumables",
25075
25764
  capScope: "device",
@@ -25118,6 +25807,12 @@ Object.freeze({
25118
25807
  addonId: null,
25119
25808
  access: "view"
25120
25809
  },
25810
+ "coreBlocks.restart": {
25811
+ capName: "core-blocks",
25812
+ capScope: "system",
25813
+ addonId: null,
25814
+ access: "create"
25815
+ },
25121
25816
  "coreBlocks.setEnabled": {
25122
25817
  capName: "core-blocks",
25123
25818
  capScope: "system",
@@ -25454,6 +26149,12 @@ Object.freeze({
25454
26149
  addonId: null,
25455
26150
  access: "create"
25456
26151
  },
26152
+ "deviceManager.adoptionCancelJob": {
26153
+ capName: "device-manager",
26154
+ capScope: "system",
26155
+ addonId: null,
26156
+ access: "create"
26157
+ },
25457
26158
  "deviceManager.adoptionListCandidateFilters": {
25458
26159
  capName: "device-manager",
25459
26160
  capScope: "system",
@@ -25466,6 +26167,12 @@ Object.freeze({
25466
26167
  addonId: null,
25467
26168
  access: "view"
25468
26169
  },
26170
+ "deviceManager.adoptionListJobs": {
26171
+ capName: "device-manager",
26172
+ capScope: "system",
26173
+ addonId: null,
26174
+ access: "view"
26175
+ },
25469
26176
  "deviceManager.adoptionRefresh": {
25470
26177
  capName: "device-manager",
25471
26178
  capScope: "system",
@@ -25484,6 +26191,12 @@ Object.freeze({
25484
26191
  addonId: null,
25485
26192
  access: "create"
25486
26193
  },
26194
+ "deviceManager.adoptionStartJob": {
26195
+ capName: "device-manager",
26196
+ capScope: "system",
26197
+ addonId: null,
26198
+ access: "create"
26199
+ },
25487
26200
  "deviceManager.allocateDeviceId": {
25488
26201
  capName: "device-manager",
25489
26202
  capScope: "system",
@@ -25754,12 +26467,6 @@ Object.freeze({
25754
26467
  addonId: null,
25755
26468
  access: "create"
25756
26469
  },
25757
- "deviceManager.setDeviceLinks": {
25758
- capName: "device-manager",
25759
- capScope: "system",
25760
- addonId: null,
25761
- access: "create"
25762
- },
25763
26470
  "deviceManager.setDisabled": {
25764
26471
  capName: "device-manager",
25765
26472
  capScope: "system",
@@ -28628,6 +29335,12 @@ Object.freeze({
28628
29335
  addonId: null,
28629
29336
  access: "view"
28630
29337
  },
29338
+ "snapshot.getSnapshotLinks": {
29339
+ capName: "snapshot",
29340
+ capScope: "device",
29341
+ addonId: null,
29342
+ access: "view"
29343
+ },
28631
29344
  "snapshot.getSnapshotOverview": {
28632
29345
  capName: "snapshot",
28633
29346
  capScope: "device",
@@ -28904,6 +29617,12 @@ Object.freeze({
28904
29617
  addonId: null,
28905
29618
  access: "create"
28906
29619
  },
29620
+ "streamBroker.fetchEventMedia": {
29621
+ capName: "stream-broker",
29622
+ capScope: "system",
29623
+ addonId: null,
29624
+ access: "create"
29625
+ },
28907
29626
  "streamBroker.getAllRtspEntries": {
28908
29627
  capName: "stream-broker",
28909
29628
  capScope: "system",
@@ -28988,6 +29707,12 @@ Object.freeze({
28988
29707
  addonId: null,
28989
29708
  access: "create"
28990
29709
  },
29710
+ "streamBroker.produceEventMedia": {
29711
+ capName: "stream-broker",
29712
+ capScope: "system",
29713
+ addonId: null,
29714
+ access: "create"
29715
+ },
28991
29716
  "streamBroker.publishCameraStream": {
28992
29717
  capName: "stream-broker",
28993
29718
  capScope: "system",
@@ -43957,37 +44682,37 @@ function errMsg$8(err) {
43957
44682
  }
43958
44683
  //#endregion
43959
44684
  //#region src/mappers/builders/doorbell-delivery.ts
43960
- function isRecord(value) {
44685
+ function isRecord$1(value) {
43961
44686
  return typeof value === "object" && value !== null;
43962
44687
  }
43963
44688
  function numberOrNull(value) {
43964
44689
  return typeof value === "number" ? value : null;
43965
44690
  }
43966
44691
  function isConnectionLike(value) {
43967
- return isRecord(value) && typeof value["hasEventNotifications"] === "function";
44692
+ return isRecord$1(value) && typeof value["hasEventNotifications"] === "function";
43968
44693
  }
43969
- function isIterable(value) {
43970
- return isRecord(value) && typeof value[Symbol.iterator] === "function";
44694
+ function isIterable$1(value) {
44695
+ return isRecord$1(value) && typeof value[Symbol.iterator] === "function";
43971
44696
  }
43972
44697
  /** `accessory._server.httpServer.connections`, or null at any missing hop. */
43973
- function readConnections(accessory) {
43974
- if (!isRecord(accessory)) return null;
44698
+ function readConnections$1(accessory) {
44699
+ if (!isRecord$1(accessory)) return null;
43975
44700
  const server = accessory["_server"];
43976
- if (!isRecord(server)) return null;
44701
+ if (!isRecord$1(server)) return null;
43977
44702
  const httpServer = server["httpServer"];
43978
- if (!isRecord(httpServer)) return null;
44703
+ if (!isRecord$1(httpServer)) return null;
43979
44704
  const connections = httpServer["connections"];
43980
- return isIterable(connections) ? connections : null;
44705
+ return isIterable$1(connections) ? connections : null;
43981
44706
  }
43982
44707
  /**
43983
44708
  * Probe how far a ring on `characteristic` of `accessory` can travel RIGHT NOW.
43984
44709
  * Pure with respect to HAP state — it only reads. Never throws.
43985
44710
  */
43986
44711
  function describeDoorbellDelivery(accessory, characteristic) {
43987
- const aid = isRecord(accessory) ? numberOrNull(accessory["aid"]) : null;
43988
- const iid = isRecord(characteristic) ? numberOrNull(characteristic["iid"]) : null;
43989
- const serverPublished = isRecord(accessory) && isRecord(accessory["_server"]);
43990
- const connections = readConnections(accessory);
44712
+ const aid = isRecord$1(accessory) ? numberOrNull(accessory["aid"]) : null;
44713
+ const iid = isRecord$1(characteristic) ? numberOrNull(characteristic["iid"]) : null;
44714
+ const serverPublished = isRecord$1(accessory) && isRecord$1(accessory["_server"]);
44715
+ const connections = readConnections$1(accessory);
43991
44716
  if (connections === null) return {
43992
44717
  aid,
43993
44718
  iid,
@@ -44099,10 +44824,16 @@ async function buildMotionSensor(bctx, existing = null) {
44099
44824
  resetTimer = null;
44100
44825
  }, RESET_DEBOUNCE_MS);
44101
44826
  };
44827
+ const motionLog = ctx.logger.withTags({ deviceId: numericDeviceId });
44828
+ const hksvTrigger = existing !== null;
44102
44829
  const unsubscribe = ctx.eventBus.subscribe({ category: EventCategory.MotionOnMotionChanged }, (event) => {
44103
44830
  if (event.data.deviceId !== numericDeviceId) return;
44104
44831
  const detected = event.data.detected === true;
44105
44832
  motionService.updateCharacteristic(Characteristic.MotionDetected, detected);
44833
+ motionLog.debug("export-hap: motion pushed to HomeKit", { meta: {
44834
+ detected,
44835
+ hksvTrigger
44836
+ } });
44106
44837
  if (detected) armReset();
44107
44838
  else if (resetTimer) {
44108
44839
  clearTimeout(resetTimer);
@@ -44479,36 +45210,84 @@ async function probe(call, label, log) {
44479
45210
  }
44480
45211
  }
44481
45212
  //#endregion
44482
- //#region src/hksv/recording-options.ts
45213
+ //#region src/hksv/controller-census.ts
45214
+ var EMPTY_CENSUS = {
45215
+ serverPublished: false,
45216
+ connections: 0,
45217
+ adminConnections: 0,
45218
+ nonAdminConnections: 0,
45219
+ unverifiedConnections: 0,
45220
+ pairedControllers: 0,
45221
+ pairedAdmins: 0
45222
+ };
45223
+ function isRecord(value) {
45224
+ return typeof value === "object" && value !== null;
45225
+ }
45226
+ function isIterable(value) {
45227
+ return isRecord(value) && typeof value[Symbol.iterator] === "function";
45228
+ }
45229
+ function isAccessoryInfoLike(value) {
45230
+ return isRecord(value) && isRecord(value["pairedClients"]) && typeof value["hasAdminPermissions"] === "function";
45231
+ }
45232
+ /** `accessory._server.httpServer.connections`, or null at any missing hop. */
45233
+ function readConnections(accessory) {
45234
+ const server = accessory["_server"];
45235
+ if (!isRecord(server)) return null;
45236
+ const httpServer = server["httpServer"];
45237
+ if (!isRecord(httpServer)) return null;
45238
+ const connections = httpServer["connections"];
45239
+ return isIterable(connections) ? connections : null;
45240
+ }
44483
45241
  /**
44484
- * The HomeKit Secure Video ADVERTISEMENT`CameraRecordingOptions`, derived
44485
- * from what the fMP4 sink will actually produce for THIS camera.
44486
- *
44487
- * ## The rule this file exists to enforce
44488
- *
44489
- * Never advertise something we cannot serve. That is not a slogan here: it is
44490
- * the diagnosis of [D50](../../../../docs/decisions/adr-0050.md) an
44491
- * advertised `recording` whose delegate yielded nothing put every motion-capable
44492
- * camera into a ~12 s timeout loop every 20-60 s, all day. So every number below
44493
- * is derived from the picked source (`recording-source.ts`) or from a measured
44494
- * property of the sink, and none of them is a plausible-looking constant.
44495
- *
44496
- * ## The fragment length is the subtle one
44497
- *
44498
- * HKSV requires every media fragment to be **no longer** than the length the
44499
- * controller selected. On the copy branch the fragment length is the SOURCE's
44500
- * key-frame cadence ([D80](../../../../docs/decisions/adr-0080.md)) — we do not
44501
- * get to choose it, we can only be honest about it. So:
44502
- *
44503
- * - when the camera reports its GOP (`stream-params`), the advertised length is
44504
- * the smallest offered value that COVERS it;
44505
- * - when it does not, we advertise the 4000 ms every HKSV camera uses and the
44506
- * delegate warns at `warn` with `tags: { deviceId }` if the fragments that
44507
- * actually arrive are longer.
44508
- *
44509
- * A camera whose GOP exceeds the longest value we offer does not advertise
44510
- * recording at all. See {@link deriveFragmentLengthMs}.
45242
+ * Census the accessory's HAP connections. Pure with respect to HAP state it
45243
+ * only reads and never throws.
45244
+ */
45245
+ function describeHapControllers(accessory) {
45246
+ if (!isRecord(accessory)) return EMPTY_CENSUS;
45247
+ const info = accessory["_accessoryInfo"];
45248
+ const paired = isAccessoryInfoLike(info) ? Object.keys(info.pairedClients) : [];
45249
+ const pairedAdmins = isAccessoryInfoLike(info) ? paired.filter((username) => info.hasAdminPermissions(username)).length : 0;
45250
+ const connections = readConnections(accessory);
45251
+ if (connections === null) return {
45252
+ ...EMPTY_CENSUS,
45253
+ pairedControllers: paired.length,
45254
+ pairedAdmins
45255
+ };
45256
+ let open = 0;
45257
+ let admins = 0;
45258
+ let nonAdmins = 0;
45259
+ let unverified = 0;
45260
+ for (const connection of connections) {
45261
+ open += 1;
45262
+ const username = isRecord(connection) ? connection["username"] : void 0;
45263
+ if (typeof username !== "string" || !isAccessoryInfoLike(info)) {
45264
+ unverified += 1;
45265
+ continue;
45266
+ }
45267
+ if (info.hasAdminPermissions(username)) admins += 1;
45268
+ else nonAdmins += 1;
45269
+ }
45270
+ return {
45271
+ serverPublished: true,
45272
+ connections: open,
45273
+ adminConnections: admins,
45274
+ nonAdminConnections: nonAdmins,
45275
+ unverifiedConnections: unverified,
45276
+ pairedControllers: paired.length,
45277
+ pairedAdmins
45278
+ };
45279
+ }
45280
+ /**
45281
+ * True when NO connected controller may write HKSV state. Every
45282
+ * `SelectedCameraRecordingConfiguration` write such a controller sends is
45283
+ * refused before it reaches us, so the recording configuration can never
45284
+ * arrive and the caller must say so out loud.
44511
45285
  */
45286
+ function noAdminControllerConnected(census) {
45287
+ return census.serverPublished && census.connections > 0 && census.adminConnections === 0;
45288
+ }
45289
+ //#endregion
45290
+ //#region src/hksv/recording-options.ts
44512
45291
  /**
44513
45292
  * The prebuffer we promise. HAP's floor is 4000 ms and its documented sensible
44514
45293
  * range is [4000, 8000]; the plane's ring is sized from this, so the two cannot
@@ -44531,6 +45310,16 @@ var HKSV_FRAGMENT_LENGTHS_MS = [4e3, 8e3];
44531
45310
  */
44532
45311
  var HKSV_AUDIO_SAMPLE_RATE_HZ = 24e3;
44533
45312
  /**
45313
+ * The frame rates the advertised {@link Resolution} may carry, and the only
45314
+ * ones. See {@link normaliseAdvertisedFps} for why the measured rate does not
45315
+ * go in raw.
45316
+ */
45317
+ var HKSV_ADVERTISED_FRAME_RATES = [
45318
+ 15,
45319
+ 24,
45320
+ 30
45321
+ ];
45322
+ /**
44534
45323
  * The advertised fragment length for a camera whose key-frame cadence is
44535
45324
  * `sourceGopMs`, or `null` when no offered length covers it.
44536
45325
  *
@@ -44546,6 +45335,46 @@ function deriveFragmentLengthMs(sourceGopMs) {
44546
45335
  return HKSV_FRAGMENT_LENGTHS_MS.find((ms) => ms >= sourceGopMs) ?? null;
44547
45336
  }
44548
45337
  /**
45338
+ * The frame rate to ADVERTISE for a slot that was measured at `measuredFps` —
45339
+ * the nearest member of {@link HKSV_ADVERTISED_FRAME_RATES}, ties going to the
45340
+ * lower rate.
45341
+ *
45342
+ * The measured rate does not go into the advertisement raw, for two reasons,
45343
+ * and the second one is the serious one.
45344
+ *
45345
+ * **It is the list the controller chooses from.** `[1280, 720, 10]` — 615's
45346
+ * measured 720p slot — is a frame rate no shipping HKSV camera offers, and the
45347
+ * controller has to find an acceptable configuration in what we advertise
45348
+ * before it will write one back.
45349
+ *
45350
+ * **A measurement makes the advertisement UNSTABLE, and hap-nodejs punishes
45351
+ * that by discarding the controller's selection.** `RecordingManagement`
45352
+ * hashes the supported-configuration TLVs and, on restore, keeps the persisted
45353
+ * `selectedConfiguration` only while the hash still matches — otherwise
45354
+ * `deserialize: discarding saved selectedConfiguration`, after which the
45355
+ * accessory answers every HDS `DATA_SEND OPEN` with `INVALID_CONFIGURATION`
45356
+ * and records nothing until the controller happens to re-select. The
45357
+ * advertised resolution is the one hashed input that came from a probe:
45358
+ * camera 590 measured 9 fps on one restart and 10 on the next, on
45359
+ * 2026-08-07/08, so this was a self-inflicted outage waiting on a reboot.
45360
+ * Quantising gives the probe a wide band to move inside without the
45361
+ * advertisement changing at all.
45362
+ */
45363
+ function normaliseAdvertisedFps(measuredFps) {
45364
+ const fallback = HKSV_ADVERTISED_FRAME_RATES[0] ?? 15;
45365
+ if (!Number.isFinite(measuredFps) || measuredFps <= 0) return fallback;
45366
+ let best = fallback;
45367
+ let bestDistance = Number.POSITIVE_INFINITY;
45368
+ for (const candidate of HKSV_ADVERTISED_FRAME_RATES) {
45369
+ const distance = Math.abs(candidate - measuredFps);
45370
+ if (distance < bestDistance) {
45371
+ best = candidate;
45372
+ bestDistance = distance;
45373
+ }
45374
+ }
45375
+ return best;
45376
+ }
45377
+ /**
44549
45378
  * Build the advertisement.
44550
45379
  *
44551
45380
  * ONE resolution is advertised — the one slot the recording child pulls. HAP's
@@ -44558,7 +45387,7 @@ function buildRecordingOptions(input) {
44558
45387
  const resolution = [
44559
45388
  input.width,
44560
45389
  input.height,
44561
- Math.max(1, Math.round(input.fps))
45390
+ normaliseAdvertisedFps(input.fps)
44562
45391
  ];
44563
45392
  return {
44564
45393
  prebufferLength: HKSV_PREBUFFER_MS,
@@ -44881,13 +45710,19 @@ var HksvRecordingDelegate = class {
44881
45710
  updateRecordingActive(active) {
44882
45711
  if (active === this.active) return;
44883
45712
  this.active = active;
45713
+ const census = this.input.describeControllers();
44884
45714
  this.log.info("hksv: recording active changed", {
44885
45715
  tags: { deviceId: this.input.deviceId },
44886
45716
  meta: {
44887
45717
  active,
44888
- hasConfiguration: this.configuration !== void 0
45718
+ hasConfiguration: this.configuration !== void 0,
45719
+ ...census
44889
45720
  }
44890
45721
  });
45722
+ if (active && this.configuration === void 0 && noAdminControllerConnected(census)) this.log.warn("hksv: recording is ON but NO connected controller holds admin — the recording configuration can never arrive", {
45723
+ tags: { deviceId: this.input.deviceId },
45724
+ meta: { ...census }
45725
+ });
44891
45726
  this.reconcile("recording-active");
44892
45727
  }
44893
45728
  updateRecordingConfiguration(configuration) {
@@ -44942,13 +45777,14 @@ var HksvRecordingDelegate = class {
44942
45777
  streamId,
44943
45778
  subscription
44944
45779
  };
44945
- const startedAt = Date.now();
45780
+ const startedAt = this.input.now();
44946
45781
  const prebufferSpanMs = source.prebufferSpanMs();
44947
45782
  let packets = 0;
44948
45783
  let bytes = 0;
44949
45784
  let markedLast = false;
44950
45785
  let longestFragmentGapMs = 0;
44951
- let lastPacketAt = startedAt;
45786
+ let firstFragmentAt = null;
45787
+ let lastFragmentAt = null;
44952
45788
  try {
44953
45789
  for await (const packet of subscription.packets()) {
44954
45790
  if (signal?.aborted === true) {
@@ -44964,9 +45800,10 @@ var HksvRecordingDelegate = class {
44964
45800
  packets += 1;
44965
45801
  bytes += packet.data.length;
44966
45802
  if (packet.kind === "fragment") {
44967
- const now = Date.now();
44968
- longestFragmentGapMs = Math.max(longestFragmentGapMs, now - lastPacketAt);
44969
- lastPacketAt = now;
45803
+ const now = this.input.now();
45804
+ if (lastFragmentAt === null) firstFragmentAt = now;
45805
+ else longestFragmentGapMs = Math.max(longestFragmentGapMs, now - lastFragmentAt);
45806
+ lastFragmentAt = now;
44970
45807
  }
44971
45808
  markedLast = markedLast || packet.isLast;
44972
45809
  yield {
@@ -45001,9 +45838,10 @@ var HksvRecordingDelegate = class {
45001
45838
  streamId,
45002
45839
  packets,
45003
45840
  bytes,
45004
- durationMs: Date.now() - startedAt,
45841
+ durationMs: this.input.now() - startedAt,
45005
45842
  prebufferSpanMs,
45006
45843
  longestFragmentGapMs,
45844
+ msToFirstFragmentMs: firstFragmentAt === null ? null : firstFragmentAt - startedAt,
45007
45845
  closedReason: subscription.closedReason,
45008
45846
  markedLast
45009
45847
  }
@@ -45220,6 +46058,8 @@ async function buildHksvRecording(input) {
45220
46058
  deviceId: numericDeviceId,
45221
46059
  isAudioActive: input.isAudioActive,
45222
46060
  advertisedFragmentMs: fragmentLengthMs,
46061
+ now: () => Date.now(),
46062
+ describeControllers: () => describeHapControllers(bctx.accessory),
45223
46063
  createSource: ({ fragmentMs, audioActive }) => new HksvFragmentSource({
45224
46064
  logger: log,
45225
46065
  deviceId: numericDeviceId,
@@ -45230,11 +46070,13 @@ async function buildHksvRecording(input) {
45230
46070
  audioActive
45231
46071
  })
45232
46072
  });
46073
+ const advertisedResolution = options.video.resolutions[0];
45233
46074
  log.info("export-hap: HKSV ADVERTISED — recording is offered for this camera", { meta: {
45234
46075
  brokerId: source.brokerId,
45235
46076
  profile: source.profile,
45236
46077
  resolution: `${source.width}x${source.height}`,
45237
- fps,
46078
+ measuredFps: fps,
46079
+ advertisedFps: advertisedResolution?.[2] ?? null,
45238
46080
  fragmentLengthMs,
45239
46081
  sourceGopMs: gopMs ?? "unknown"
45240
46082
  } });