@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.
@@ -74,7 +74,7 @@ function carryForward(base, existing, keys) {
74
74
  return out;
75
75
  }
76
76
  //#endregion
77
- //#region ../types/dist/event-category-41fKf-q9.mjs
77
+ //#region ../types/dist/event-category-Cv9dO26A.mjs
78
78
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
79
79
  EventCategory["SystemBoot"] = "system.boot";
80
80
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -90,6 +90,13 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
90
90
  */
91
91
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
92
92
  /**
93
+ * A newer addon or server-root package version was found by the
94
+ * authoritative registry check. Emitted once per
95
+ * `(target, packageName, currentVersion, latestVersion)` transition; repeated
96
+ * polling of the same result is deduplicated by the checker.
97
+ */
98
+ EventCategory["UpdateAvailable"] = "update.available";
99
+ /**
93
100
  * Readiness transition for a capability provider. Every producer emits
94
101
  * this event on `onInitialize` completion, `onDestroy`, and
95
102
  * `$node.reconnect`; every consumer that needs to gate on a cross-process
@@ -7080,7 +7087,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
7080
7087
  input: unknown()
7081
7088
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
7082
7089
  //#endregion
7083
- //#region ../types/dist/fmp4-box-splitter-B53u9-Nu.mjs
7090
+ //#region ../types/dist/canonical-hash-rO1sRmEK.mjs
7084
7091
  var AUDIO_ENCODER_BY_CODEC = {
7085
7092
  opus: "libopus",
7086
7093
  aac: "aac",
@@ -7374,37 +7381,6 @@ function buildFfmpegArgs(inv) {
7374
7381
  ...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
7375
7382
  ];
7376
7383
  }
7377
- /**
7378
- * Deterministic SHA-256 hash of an arbitrary serialisable value. The
7379
- * canonical form sorts object keys alphabetically at every depth so two
7380
- * structurally-equal inputs with different key insertion orders produce
7381
- * the same hash. Returns a 64-char lowercase hex digest.
7382
- *
7383
- * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
7384
- * accessory-rebuild work when the upstream shape is byte-identical to
7385
- * the last applied state — preventing user-visible "re-discovery"
7386
- * notifications on every addon-runner respawn. Each respawn re-fires
7387
- * `DeviceBindingsChanged` for every cap registration, which without
7388
- * this guard would propagate redundant pushes.
7389
- *
7390
- * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
7391
- * subscription. The proper fix is a single "device ready" lifecycle
7392
- * barrier so exports react only when the full cap set has landed —
7393
- * tracked separately for post-HA-integration work.
7394
- */
7395
- function canonicalHash(value) {
7396
- const canonical = JSON.stringify(value, replaceWithSortedKeys);
7397
- return (0, node_crypto.createHash)("sha256").update(canonical ?? "").digest("hex");
7398
- }
7399
- function replaceWithSortedKeys(_key, value) {
7400
- if (value && typeof value === "object" && !Array.isArray(value)) {
7401
- const obj = value;
7402
- const out = {};
7403
- for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7404
- return out;
7405
- }
7406
- return value;
7407
- }
7408
7384
  var DEFAULT_MAX_UNIT_BYTES = 16 * 1024 * 1024;
7409
7385
  /** Header size for a normal box, and for one carrying a 64-bit `largesize`. */
7410
7386
  var BOX_HEADER_BYTES = 8;
@@ -7577,6 +7553,37 @@ var Fmp4BoxSplitter = class {
7577
7553
  return [];
7578
7554
  }
7579
7555
  };
7556
+ /**
7557
+ * Deterministic SHA-256 hash of an arbitrary serialisable value. The
7558
+ * canonical form sorts object keys alphabetically at every depth so two
7559
+ * structurally-equal inputs with different key insertion orders produce
7560
+ * the same hash. Returns a 64-char lowercase hex digest.
7561
+ *
7562
+ * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
7563
+ * accessory-rebuild work when the upstream shape is byte-identical to
7564
+ * the last applied state — preventing user-visible "re-discovery"
7565
+ * notifications on every addon-runner respawn. Each respawn re-fires
7566
+ * `DeviceBindingsChanged` for every cap registration, which without
7567
+ * this guard would propagate redundant pushes.
7568
+ *
7569
+ * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
7570
+ * subscription. The proper fix is a single "device ready" lifecycle
7571
+ * barrier so exports react only when the full cap set has landed —
7572
+ * tracked separately for post-HA-integration work.
7573
+ */
7574
+ function canonicalHash(value) {
7575
+ const canonical = JSON.stringify(value, replaceWithSortedKeys);
7576
+ return (0, node_crypto.createHash)("sha256").update(canonical ?? "").digest("hex");
7577
+ }
7578
+ function replaceWithSortedKeys(_key, value) {
7579
+ if (value && typeof value === "object" && !Array.isArray(value)) {
7580
+ const obj = value;
7581
+ const out = {};
7582
+ for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7583
+ return out;
7584
+ }
7585
+ return value;
7586
+ }
7580
7587
  //#endregion
7581
7588
  //#region ../types/dist/err-msg-IQTHeDzc.mjs
7582
7589
  /**
@@ -7756,6 +7763,104 @@ object({
7756
7763
  })
7757
7764
  });
7758
7765
  /**
7766
+ * Adoption job — the background form of `device-adoption.adopt`.
7767
+ *
7768
+ * ## Why this exists
7769
+ *
7770
+ * `adopt({childNativeIds: [...]})` materialises one CamStack device per
7771
+ * candidate PLUS every accessory child, and the whole array shares ONE UDS
7772
+ * request deadline (60s). Measured on the live hub against Home Assistant:
7773
+ * each device the kernel creates costs ~450 ms — `devices.create` pre-seeds
7774
+ * meta with up to eleven SEQUENTIAL round trips (`setName`, `setType`,
7775
+ * `setRole`, … `persistConfig`) before the class is constructed — and an
7776
+ * accessory child costs the same as its parent. So the real unit of work is
7777
+ * the CHILD, not the candidate:
7778
+ *
7779
+ * - 25 candidates averaging 6 children → ~150 devices → **>60s, times out**
7780
+ * - ONE candidate with 217 children → ~217 devices → **>60s, times out**
7781
+ *
7782
+ * That second line is why this is a job and not a smaller batch. No chunking,
7783
+ * no bounded concurrency over candidates and no per-call tuning can fix a
7784
+ * shape where **N=1 already exceeds the deadline** — the count that blows the
7785
+ * budget is the source system's accessory fan-out, which the operator does not
7786
+ * choose and cannot see. A design that only works below some N is the same bug
7787
+ * deferred.
7788
+ *
7789
+ * ## What the timeout did NOT do
7790
+ *
7791
+ * It did not stop the work. The UDS deadline ends the CALLER's wait; the
7792
+ * provider's loop runs to completion. Measured: a 25-candidate adopt that
7793
+ * "failed" at 60s had adopted 17 by 87s and all 25 by ~130s. The operator saw
7794
+ * an error and had no way to learn that. Every field below exists so that
7795
+ * question has an answer.
7796
+ *
7797
+ * ## Idempotency
7798
+ *
7799
+ * Jobs are in-RAM; a restart forgets them. That is safe here because adoption
7800
+ * is keyed by a stable id (`ha:<broker>:dev:<nativeId>` and equivalents), so
7801
+ * re-running a job re-adopts nothing: an already-adopted candidate is SKIPPED
7802
+ * by the engine before any provider call and lands in `alreadyAdopted`. It is
7803
+ * never a duplicate device, and never an error the operator has to interpret.
7804
+ */
7805
+ var AdoptionJobStateSchema = _enum([
7806
+ "running",
7807
+ "done",
7808
+ "failed",
7809
+ "cancelled"
7810
+ ]);
7811
+ /**
7812
+ * Per-candidate result. Every candidate the job was asked to adopt ends in
7813
+ * exactly one of these buckets — there is no silent drop, and the operator can
7814
+ * always answer "which of my 25 landed?".
7815
+ *
7816
+ * - `adopted` — created now by this job.
7817
+ * - `already-adopted` — a device for this candidate existed before the job
7818
+ * reached it (a re-run, or a retry after a timeout). Not an error.
7819
+ * - `failed` — the provider threw; `error` carries the message.
7820
+ * - `cancelled` — the operator cancelled before this candidate was reached.
7821
+ */
7822
+ var AdoptionOutcomeSchema = _enum([
7823
+ "adopted",
7824
+ "already-adopted",
7825
+ "failed",
7826
+ "cancelled"
7827
+ ]);
7828
+ var AdoptionCandidateResultSchema = object({
7829
+ childNativeId: string(),
7830
+ outcome: AdoptionOutcomeSchema,
7831
+ /** The materialised parent device id — null for `failed` / `cancelled`. */
7832
+ parentDeviceId: number().int().nonnegative().nullable(),
7833
+ /** Accessory children created for this candidate. */
7834
+ accessoryCount: number().int().nonnegative(),
7835
+ /** Failure message; null unless `outcome === 'failed'`. */
7836
+ error: string().nullable()
7837
+ });
7838
+ var AdoptionJobSchema = object({
7839
+ jobId: string(),
7840
+ /** The integration provider this job adopts through (the `addonId` pin). */
7841
+ addonId: string(),
7842
+ integrationId: string(),
7843
+ state: AdoptionJobStateSchema,
7844
+ /** Candidates the job was asked to adopt. Known up front, so never null. */
7845
+ total: number().int().nonnegative(),
7846
+ /** Candidates that have reached a terminal bucket. */
7847
+ processed: number().int().nonnegative(),
7848
+ adopted: number().int().nonnegative(),
7849
+ alreadyAdopted: number().int().nonnegative(),
7850
+ failed: number().int().nonnegative(),
7851
+ /** Accessory child devices created across every candidate — the real unit
7852
+ * of work, surfaced so a slow job is legible rather than mysterious. */
7853
+ accessoriesCreated: number().int().nonnegative(),
7854
+ /** The candidate currently being adopted; null when idle or finished. */
7855
+ currentChildNativeId: string().nullable(),
7856
+ /** One entry per candidate, in the order they were processed. */
7857
+ results: array(AdoptionCandidateResultSchema).readonly(),
7858
+ startedAt: number(),
7859
+ finishedAt: number().nullable(),
7860
+ /** Set only when the job itself broke (not a per-candidate failure). */
7861
+ error: string().nullable()
7862
+ });
7863
+ /**
7759
7864
  * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7760
7865
  * pipeline functions an operator thinks in terms of.
7761
7866
  *
@@ -8629,358 +8734,1132 @@ var ConvertResultSchema = object({
8629
8734
  })).readonly()
8630
8735
  });
8631
8736
  /**
8632
- * `addon-pages` system-scoped singleton aggregator cap. Public-facing
8633
- * surface that admin-ui consumes through `useAddonPagesListPages()`.
8634
- *
8635
- * The provider iterates every `addon-pages-source` (collection) provider
8636
- * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
8637
- * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
8638
- * filesystem `mtime` cache-buster lets the browser pick up addon
8639
- * rebuilds without manual reload.
8640
- *
8641
- * The hub-local builtin `addon-pages-aggregator` (see
8642
- * `@camstack/system/builtins/addon-pages-aggregator`) registers the
8643
- * provider. Splitting the public aggregator from the raw collection
8644
- * keeps both ends in codegen — there's no hand-written
8645
- * `addon-pages.router.ts` wrapper anymore.
8737
+ * Error types for the safe expression engine. Two distinct classes so callers
8738
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
8739
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
8646
8740
  */
8647
- var AddonPageDeclarationSchema$1 = object({
8648
- id: string(),
8649
- label: string(),
8650
- icon: string(),
8651
- path: string(),
8652
- remoteName: string(),
8653
- bundle: string(),
8654
- section: string().optional(),
8655
- sectionLabel: string().optional()
8656
- });
8657
- var AddonPageInfoSchema = object({
8658
- addonId: string(),
8659
- page: AddonPageDeclarationSchema$1,
8660
- bundleUrl: string()
8661
- });
8662
- method(_void(), array(AddonPageInfoSchema).readonly());
8741
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
8742
+ * the failure is anchored to a character (author-facing inline feedback). */
8743
+ var ExpressionParseError = class extends Error {
8744
+ position;
8745
+ constructor(message, position) {
8746
+ super(message);
8747
+ this.name = "ExpressionParseError";
8748
+ this.position = position;
8749
+ }
8750
+ };
8751
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
8752
+ * result, unknown builtin, step-budget exceeded). */
8753
+ var ExpressionEvalError = class extends Error {
8754
+ constructor(message) {
8755
+ super(message);
8756
+ this.name = "ExpressionEvalError";
8757
+ }
8758
+ };
8663
8759
  /**
8664
- * `addon-pages-source` collection cap exposing per-provider raw page
8665
- * declarations. Every addon that contributes a UI page registers a
8666
- * provider here. The hub-side singleton aggregator (`addon-pages` cap,
8667
- * see `addon-pages.cap.ts`) walks this collection, stamps versioned
8668
- * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
8669
- * that admin-ui consumes.
8760
+ * Frozen, null-prototype builtin function table for the expression engine
8761
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
8762
+ * parser rejects any callee not in it, and the evaluator gates each call on an
8763
+ * own-property check against it.
8670
8764
  *
8671
- * The split exists because the public listing has a different output
8672
- * shape than the per-provider raw declarations, and we want both ends
8673
- * to flow through codegen instead of relying on a hand-written wrapper.
8674
- */
8675
- var AddonPageDeclarationSchema = object({
8676
- id: string(),
8677
- label: string(),
8678
- icon: string(),
8679
- path: string(),
8680
- /**
8681
- * Module Federation remote name — must match the `name` field on the
8682
- * page addon's `federation()` plugin config. Used by admin-ui's
8683
- * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
8684
- * Conventionally `addon_<id>_page` (snake_case; MF names cannot
8685
- * contain hyphens).
8686
- */
8687
- remoteName: string(),
8688
- /**
8689
- * Bundle filename inside the addon's `dist/` dir served at
8690
- * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
8691
- * is always `'remoteEntry.js'`; the value is kept on the metadata so
8692
- * the static-file route can compute an mtime-based cache-buster URL
8693
- * without a separate filesystem stat.
8694
- */
8695
- bundle: string(),
8696
- /**
8697
- * Sidebar section this page docks into. Well-known ids: `'detection'`,
8698
- * `'cluster'`, `'administration'` — the page renders inside that group.
8699
- * Any OTHER string creates (or joins) a custom section rendered after
8700
- * the built-in groups; its label comes from `sectionLabel` (first
8701
- * declaration wins), falling back to the id. Absent → the legacy
8702
- * "Addon Pages" group.
8703
- */
8704
- section: string().optional(),
8705
- /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
8706
- sectionLabel: string().optional()
8707
- });
8708
- method(_void(), array(AddonPageDeclarationSchema).readonly());
8709
- var AddonHttpRouteSchema = object({
8710
- method: _enum([
8711
- "GET",
8712
- "POST",
8713
- "PUT",
8714
- "DELETE",
8715
- "PATCH"
8716
- ]),
8717
- path: string(),
8718
- access: _enum([
8719
- "public",
8720
- "authenticated",
8721
- "admin"
8722
- ]).optional(),
8723
- description: string().optional()
8724
- });
8725
- /**
8726
- * Cross-process route invocation envelope. The hub captures the
8727
- * request as plain data, ships it to the worker via Moleculer, and
8728
- * the worker runs the local handler against a capturing reply. The
8729
- * envelope returned describes what the handler intended (status,
8730
- * headers, body, or a redirect) so the hub can translate it back to
8731
- * the Fastify reply that's actually wired to the socket.
8765
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
8766
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
8767
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
8768
+ * (there is no `Object.prototype` in the chain), so those names are not
8769
+ * callable they are simply "unknown function" at parse time.
8770
+ *
8771
+ * Every numeric argument is validated as a finite number and every numeric
8772
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
8773
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
8774
+ * closed rather than emitting a garbage value.
8732
8775
  */
8733
- var InvokeRequestSchema = object({
8734
- method: string(),
8735
- path: string(),
8736
- params: record(string(), string()),
8737
- query: record(string(), string()),
8738
- body: unknown(),
8739
- headers: record(string(), string()),
8740
- user: object({
8741
- id: string(),
8742
- username: string(),
8743
- isAdmin: boolean()
8744
- }).optional(),
8745
- scopedToken: unknown().optional()
8746
- });
8747
- var InvokeReplyEnvelopeSchema = object({
8748
- status: number().int(),
8749
- headers: record(string(), string()),
8750
- /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
8751
- * sending `body`. Status defaults to 302 when this is set unless
8752
- * the handler called `reply.code(...)` explicitly. */
8753
- redirectUrl: string().nullable(),
8754
- /** JSON-serializable body. `undefined` is treated as "no body". */
8755
- body: unknown().optional(),
8756
- /** Set when the handler called `reply.type(mime)`. */
8757
- contentType: string().optional()
8758
- });
8759
- method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
8760
- var ConfigTabDeclarationSchema = object({
8761
- id: string(),
8762
- label: string(),
8763
- icon: string(),
8764
- order: number().optional()
8765
- });
8766
- var ConfigSectionWithValuesSchema = object({
8767
- id: string(),
8768
- title: string(),
8769
- description: string().optional(),
8770
- style: _enum(["card", "accordion"]).optional(),
8771
- defaultCollapsed: boolean().optional(),
8772
- columns: union([
8773
- literal(1),
8774
- literal(2),
8775
- literal(3),
8776
- literal(4)
8777
- ]).optional(),
8778
- tab: string().optional(),
8779
- location: _enum(["settings", "top-tab"]).optional(),
8780
- order: number().optional(),
8781
- fields: array(any())
8782
- });
8783
- var SettingsSchemaWithValuesSchema = object({
8784
- tabs: array(ConfigTabDeclarationSchema).optional(),
8785
- sections: array(ConfigSectionWithValuesSchema)
8786
- });
8787
- /** Patch object keys are field names, values are the new field values. */
8788
- var SettingsPatchSchema = record(string(), unknown());
8789
- /** Standard success response for update operations. */
8790
- var SettingsUpdateResultSchema = object({ success: literal(true) });
8791
- method(object({
8792
- addonId: string(),
8793
- nodeId: string().optional(),
8794
- overlay: record(string(), unknown()).optional(),
8795
- cap: string().optional()
8796
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8797
- addonId: string(),
8798
- nodeId: string().optional(),
8799
- patch: SettingsPatchSchema
8800
- }), SettingsUpdateResultSchema, {
8801
- kind: "mutation",
8802
- auth: "admin"
8803
- }), method(object({
8804
- addonId: string(),
8805
- deviceId: number(),
8806
- nodeId: string().optional()
8807
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8808
- addonId: string(),
8809
- deviceId: number(),
8810
- nodeId: string().optional(),
8811
- patch: SettingsPatchSchema
8812
- }), SettingsUpdateResultSchema, {
8813
- kind: "mutation",
8814
- auth: "admin"
8815
- });
8776
+ function asFiniteNumber(value, name, index) {
8777
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
8778
+ return value;
8779
+ }
8780
+ function asString$1(value, name, index) {
8781
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
8782
+ return value;
8783
+ }
8784
+ function finiteResult(value, name) {
8785
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
8786
+ return value;
8787
+ }
8788
+ function allFiniteNumbers(args, name) {
8789
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
8790
+ }
8791
+ var INF = Number.POSITIVE_INFINITY;
8792
+ var table = {
8793
+ min: {
8794
+ minArgs: 1,
8795
+ maxArgs: INF,
8796
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
8797
+ },
8798
+ max: {
8799
+ minArgs: 1,
8800
+ maxArgs: INF,
8801
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
8802
+ },
8803
+ abs: {
8804
+ minArgs: 1,
8805
+ maxArgs: 1,
8806
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
8807
+ },
8808
+ floor: {
8809
+ minArgs: 1,
8810
+ maxArgs: 1,
8811
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
8812
+ },
8813
+ ceil: {
8814
+ minArgs: 1,
8815
+ maxArgs: 1,
8816
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
8817
+ },
8818
+ sqrt: {
8819
+ minArgs: 1,
8820
+ maxArgs: 1,
8821
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
8822
+ },
8823
+ round: {
8824
+ minArgs: 1,
8825
+ maxArgs: 2,
8826
+ apply: (args) => {
8827
+ const x = asFiniteNumber(args[0], "round", 0);
8828
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
8829
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
8830
+ const factor = 10 ** digits;
8831
+ return finiteResult(Math.round(x * factor) / factor, "round");
8832
+ }
8833
+ },
8834
+ pow: {
8835
+ minArgs: 2,
8836
+ maxArgs: 2,
8837
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
8838
+ },
8839
+ clamp: {
8840
+ minArgs: 3,
8841
+ maxArgs: 3,
8842
+ apply: (args) => {
8843
+ const x = asFiniteNumber(args[0], "clamp", 0);
8844
+ const lo = asFiniteNumber(args[1], "clamp", 1);
8845
+ const hi = asFiniteNumber(args[2], "clamp", 2);
8846
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
8847
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
8848
+ }
8849
+ },
8850
+ avg: {
8851
+ minArgs: 1,
8852
+ maxArgs: INF,
8853
+ apply: (args) => {
8854
+ const nums = allFiniteNumbers(args, "avg");
8855
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
8856
+ }
8857
+ },
8858
+ sum: {
8859
+ minArgs: 1,
8860
+ maxArgs: INF,
8861
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
8862
+ },
8863
+ coalesce: {
8864
+ minArgs: 1,
8865
+ maxArgs: INF,
8866
+ apply: (args) => {
8867
+ for (const a of args) if (a !== null) return a;
8868
+ return null;
8869
+ }
8870
+ },
8871
+ age: {
8872
+ minArgs: 2,
8873
+ maxArgs: 2,
8874
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
8875
+ },
8876
+ convert: {
8877
+ minArgs: 3,
8878
+ maxArgs: 3,
8879
+ apply: (args, hooks) => {
8880
+ const x = asFiniteNumber(args[0], "convert", 0);
8881
+ const from = asString$1(args[1], "convert", 1).trim();
8882
+ const to = asString$1(args[2], "convert", 2).trim();
8883
+ if (hooks.convert) {
8884
+ const out = hooks.convert(x, from, to);
8885
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
8886
+ return finiteResult(out, "convert");
8887
+ }
8888
+ if (from === to) return x;
8889
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
8890
+ }
8891
+ }
8892
+ };
8893
+ Object.freeze(Object.assign(Object.create(null), table));
8894
+ /** The set of valid builtin names — used by the parser to reject unknown
8895
+ * callees at parse time (immediate author feedback). */
8896
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
8816
8897
  /**
8817
- * `addon-widgets-source` collection cap exposing per-addon raw widget
8818
- * declarations. Mirrors the addon-pages split: every addon shipping
8819
- * widgets registers a provider on this collection cap; the hub-local
8820
- * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
8821
- * collection, stamps versioned `bundleUrl`s onto each declaration, and
8822
- * exposes the public listing surface that admin-ui consumes.
8823
- *
8824
- * The split exists because the public listing has a different output
8825
- * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
8826
- * per-provider raw declarations. Both ends flow through codegen.
8898
+ * Resource-bound constants for the safe expression engine.
8827
8899
  *
8828
- * Unified UI-contribution model (Task 10): a widget descriptor IS a
8829
- * `UiContribution` with `kind:'remote'`. The host renders it through the
8830
- * same `ContributionRenderer` / Module-Federation path as every other
8831
- * contributed UI surface no bespoke widget-rendering path. The widget-
8832
- * only metadata (sizing hints, `requires`) lives as extra fields on the
8833
- * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
8834
- * `kind` / `remote`) carries identity + placement + the MF remote.
8900
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
8901
+ * loops, recursion, lambdas or member access see `ast.ts`), so evaluation is
8902
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
8903
+ * work a single author-supplied expression can request, so a hostile or
8904
+ * accidental pathological string can never spend unbounded CPU/memory.
8835
8905
  */
8836
- /** Where the widget makes sense to render maps to a contribution `tab`. */
8837
- var WidgetHostEnum = _enum([
8838
- "device-tab",
8839
- "dashboard",
8840
- "integration-detail"
8841
- ]);
8842
- var WidgetSizeEnum = _enum([
8843
- "xs",
8844
- "sm",
8845
- "md",
8846
- "lg",
8847
- "xl"
8906
+ /** Max source length (chars) checked BEFORE tokenizing so a huge string is
8907
+ * rejected without allocation. */
8908
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
8909
+ /** A legal binding / identifier name. */
8910
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
8911
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
8912
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
8913
+ var RESERVED_BINDING_NAMES = new Set([
8914
+ "now",
8915
+ "true",
8916
+ "false",
8917
+ "null"
8848
8918
  ]);
8849
8919
  /**
8850
- * MF remote descriptor mirrors `UiContributionRemote` from
8851
- * `capability-definition.ts`. Widget remotes expose a single
8852
- * `'./widgets'` module whose default export is a
8853
- * `Record<componentKey, Component>` map; `componentKey` (the widget
8854
- * `stableId`) picks the entry the host mounts.
8920
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
8921
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
8922
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
8923
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
8924
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
8925
+ * is a parse error with a source position, so member access / assignment /
8926
+ * template literals are lexically impossible.
8855
8927
  */
8856
- var WidgetRemoteSchema = object({
8857
- remoteName: string(),
8858
- exposedModule: string(),
8859
- componentKey: string().optional()
8860
- });
8861
- /**
8862
- * One widget declaration a `UiContribution` (`kind:'remote'`) plus
8863
- * widget-only metadata. The `UiContribution` core fields:
8864
- *
8865
- * - `tab` — where the widget hosts. A widget that runs on the
8866
- * dashboard declares `tab:'dashboard'`; a device-tab
8867
- * widget declares the target device-detail tab id.
8868
- * - `subTab` — optional sub-tab within `tab`.
8869
- * - `label` — operator-facing label.
8870
- * - `order` — ordering within `(tab, subTab)`.
8871
- * - `kind` — always `'remote'` for widgets.
8872
- * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
8873
- *
8874
- * Widget-only fields retained alongside the contribution core:
8875
- *
8876
- * - `stableId` — stable identity within the addon (the MF
8877
- * `componentKey`; kept top-level so consumers have
8878
- * a stable key without reaching into `remote`).
8879
- * - `description` / `icon` — picker metadata.
8880
- * - `bundle` — entry filename inside the addon `dist/` dir; the
8881
- * aggregator stamps a versioned `bundleUrl` from it.
8882
- * - `hosts` — every host the widget supports (a widget can run
8883
- * both on the dashboard and a device tab). `tab`
8884
- * is the PRIMARY host; `hosts` is the full set the
8885
- * picker filters on.
8886
- * - `requires` — host-context requirements validated at mount.
8887
- * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
8888
- * — dashboard placement hints.
8889
- */
8890
- var WidgetMetadataSchema = object({
8891
- /** Primary host tab `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
8892
- tab: string(),
8893
- /** Optional sub-tab within `tab`. */
8894
- subTab: string().optional(),
8895
- /** Operator-facing label. */
8896
- label: string(),
8897
- /** Ordering within `(tab, subTab)`, ascending. */
8898
- order: number().optional(),
8899
- /** Always `'remote'` — a widget is a Module Federation remote. */
8900
- kind: literal("remote"),
8901
- /** MF remote descriptor. */
8902
- remote: WidgetRemoteSchema,
8903
- /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
8904
- stableId: string(),
8905
- description: string().optional(),
8906
- icon: string().optional(),
8907
- /**
8908
- * Bundle filename inside the addon's `dist/` dir served at
8909
- * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
8910
- * this is always `'remoteEntry.js'` — the value is kept on the
8911
- * metadata so the static-file route can compute an mtime-based
8912
- * cache-buster URL without a separate filesystem stat.
8913
- */
8914
- bundle: string(),
8915
- /** Every host the widget supports. The picker filters on this set. */
8916
- hosts: array(WidgetHostEnum).readonly(),
8917
- /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
8918
- requires: object({
8919
- deviceContext: boolean().default(false),
8920
- integrationContext: boolean().default(false)
8921
- }),
8922
- /**
8923
- * Loadable BEFORE authentication. The normal widget registry listing
8924
- * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
8925
- * (the login page) cannot discover a widget through it. A widget that
8926
- * declares `preAuth: true` marks itself as safe to mount on a pre-auth
8927
- * screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
8928
- * login-method contribution channel (see `login-method.cap.ts`) rather
8929
- * than the authenticated registry, and its bundle is served by the
8930
- * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
8931
- */
8932
- preAuth: boolean().optional().default(false),
8933
- /** Dashboard placement HINTS (operator can override per instance). */
8934
- defaultSize: WidgetSizeEnum.default("md"),
8935
- allowedSizes: array(WidgetSizeEnum).readonly().default([
8936
- "sm",
8937
- "md",
8938
- "lg"
8939
- ]),
8940
- defaultColumns: number().int().min(1).max(12).default(6),
8941
- defaultRows: number().int().min(1).max(12).default(1)
8942
- });
8943
- method(_void(), array(WidgetMetadataSchema).readonly());
8928
+ var KEYWORDS = new Set([
8929
+ "true",
8930
+ "false",
8931
+ "null"
8932
+ ]);
8933
+ function isDigit(ch) {
8934
+ return ch >= "0" && ch <= "9";
8935
+ }
8936
+ function isIdentStart(ch) {
8937
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
8938
+ }
8939
+ function isIdentPart(ch) {
8940
+ return isIdentStart(ch) || isDigit(ch);
8941
+ }
8942
+ function isWhitespace(ch) {
8943
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
8944
+ }
8945
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
8946
+ * Throws `ExpressionParseError` on any illegal character or unterminated
8947
+ * string. */
8948
+ function tokenize(source) {
8949
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
8950
+ const tokens = [];
8951
+ let i = 0;
8952
+ const n = source.length;
8953
+ while (i < n) {
8954
+ const ch = source[i];
8955
+ if (isWhitespace(ch)) {
8956
+ i += 1;
8957
+ continue;
8958
+ }
8959
+ if (isDigit(ch)) {
8960
+ const start = i;
8961
+ while (i < n && isDigit(source[i])) i += 1;
8962
+ if (i < n && source[i] === ".") {
8963
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
8964
+ i += 1;
8965
+ while (i < n && isDigit(source[i])) i += 1;
8966
+ }
8967
+ const text = source.slice(start, i);
8968
+ const value = Number(text);
8969
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
8970
+ tokens.push({
8971
+ type: "number",
8972
+ value,
8973
+ pos: start
8974
+ });
8975
+ continue;
8976
+ }
8977
+ if (ch === "'" || ch === "\"") {
8978
+ const quote = ch;
8979
+ const start = i;
8980
+ i += 1;
8981
+ let out = "";
8982
+ let closed = false;
8983
+ while (i < n) {
8984
+ const c = source[i];
8985
+ if (c === "\\") {
8986
+ const next = i + 1 < n ? source[i + 1] : "";
8987
+ if (next === "\\" || next === "'" || next === "\"") {
8988
+ out += next;
8989
+ i += 2;
8990
+ continue;
8991
+ }
8992
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
8993
+ }
8994
+ if (c === quote) {
8995
+ closed = true;
8996
+ i += 1;
8997
+ break;
8998
+ }
8999
+ out += c;
9000
+ i += 1;
9001
+ }
9002
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
9003
+ tokens.push({
9004
+ type: "string",
9005
+ value: out,
9006
+ pos: start
9007
+ });
9008
+ continue;
9009
+ }
9010
+ if (isIdentStart(ch)) {
9011
+ const start = i;
9012
+ while (i < n && isIdentPart(source[i])) i += 1;
9013
+ const text = source.slice(start, i);
9014
+ if (KEYWORDS.has(text)) tokens.push({
9015
+ type: "keyword",
9016
+ keyword: keywordOf(text),
9017
+ pos: start
9018
+ });
9019
+ else tokens.push({
9020
+ type: "identifier",
9021
+ name: text,
9022
+ pos: start
9023
+ });
9024
+ continue;
9025
+ }
9026
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
9027
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
9028
+ tokens.push({
9029
+ type: "punct",
9030
+ punct: two,
9031
+ pos: i
9032
+ });
9033
+ i += 2;
9034
+ continue;
9035
+ }
9036
+ if (isSinglePunct(ch)) {
9037
+ tokens.push({
9038
+ type: "punct",
9039
+ punct: ch,
9040
+ pos: i
9041
+ });
9042
+ i += 1;
9043
+ continue;
9044
+ }
9045
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
9046
+ }
9047
+ tokens.push({
9048
+ type: "eof",
9049
+ pos: n
9050
+ });
9051
+ return tokens;
9052
+ }
9053
+ function keywordOf(text) {
9054
+ if (text === "true") return "true";
9055
+ if (text === "false") return "false";
9056
+ return "null";
9057
+ }
9058
+ function isSinglePunct(ch) {
9059
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
9060
+ }
8944
9061
  /**
8945
- * `addon-widgets` system-scoped singleton aggregator cap. Public-facing
8946
- * surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
9062
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
8947
9063
  *
8948
- * The provider iterates every `addon-widgets-source` (collection)
8949
- * provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
8950
- * `bundleUrl` strings pointing at
8951
- * `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
8952
- * `mtime` cache-buster lets the browser pick up addon rebuilds without
8953
- * manual reload same scheme used by `addon-pages`.
9064
+ * Precedence (low high): ternary `?:` (right-assoc) → `||` → `&&` → equality
9065
+ * relational additive multiplicative → unary `! -` → call / primary.
9066
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
9067
+ * string validated against the builtin table at parse time, so an unknown
9068
+ * function is rejected immediately (author feedback) and a persisted expression
9069
+ * that references a since-removed builtin degrades at read.
8954
9070
  *
8955
- * The hub-local builtin `addon-widgets-aggregator` (see
8956
- * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
8957
- * provider. Splitting the public aggregator from the raw collection
8958
- * keeps both ends in codegen — there's no hand-written wrapper.
9071
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
9072
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) both raise `ExpressionParseError`.
8959
9073
  */
8960
- var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
8961
- addonId: string(),
8962
- bundleUrl: string()
8963
- });
8964
- method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
9074
+ /** Binary/logical operator precedence (higher binds tighter). */
9075
+ var BINARY_PRECEDENCE = {
9076
+ "||": 1,
9077
+ "&&": 2,
9078
+ "==": 3,
9079
+ "!=": 3,
9080
+ "<": 4,
9081
+ "<=": 4,
9082
+ ">": 4,
9083
+ ">=": 4,
9084
+ "+": 5,
9085
+ "-": 5,
9086
+ "*": 6,
9087
+ "/": 6,
9088
+ "%": 6
9089
+ };
9090
+ function isLogicalOp(op) {
9091
+ return op === "&&" || op === "||";
9092
+ }
9093
+ function isBinaryOp(op) {
9094
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
9095
+ }
9096
+ var Parser = class {
9097
+ tokens;
9098
+ pos = 0;
9099
+ nodeCount = 0;
9100
+ identifiers = /* @__PURE__ */ new Set();
9101
+ callees = /* @__PURE__ */ new Set();
9102
+ constructor(tokens) {
9103
+ this.tokens = tokens;
9104
+ }
9105
+ parse() {
9106
+ const ast = this.parseTernary();
9107
+ const tok = this.peek();
9108
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
9109
+ return {
9110
+ ast,
9111
+ identifiers: this.identifiers,
9112
+ callees: this.callees,
9113
+ nodeCount: this.nodeCount
9114
+ };
9115
+ }
9116
+ peek() {
9117
+ return this.tokens[this.pos];
9118
+ }
9119
+ next() {
9120
+ return this.tokens[this.pos++];
9121
+ }
9122
+ /** Consume a punctuator token, erroring if the next token isn't it. */
9123
+ expectPunct(punct) {
9124
+ const tok = this.peek();
9125
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
9126
+ this.pos += 1;
9127
+ }
9128
+ matchPunct(punct) {
9129
+ const tok = this.peek();
9130
+ if (tok.type === "punct" && tok.punct === punct) {
9131
+ this.pos += 1;
9132
+ return true;
9133
+ }
9134
+ return false;
9135
+ }
9136
+ countNode() {
9137
+ this.nodeCount += 1;
9138
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
9139
+ }
9140
+ parseTernary() {
9141
+ const test = this.parseBinary(1);
9142
+ if (this.matchPunct("?")) {
9143
+ const consequent = this.parseTernary();
9144
+ this.expectPunct(":");
9145
+ const alternate = this.parseTernary();
9146
+ this.countNode();
9147
+ return {
9148
+ kind: "conditional",
9149
+ test,
9150
+ consequent,
9151
+ alternate
9152
+ };
9153
+ }
9154
+ return test;
9155
+ }
9156
+ parseBinary(minPrec) {
9157
+ let left = this.parseUnary();
9158
+ for (;;) {
9159
+ const tok = this.peek();
9160
+ if (tok.type !== "punct") break;
9161
+ const prec = BINARY_PRECEDENCE[tok.punct];
9162
+ if (prec === void 0 || prec < minPrec) break;
9163
+ const op = tok.punct;
9164
+ this.pos += 1;
9165
+ const right = this.parseBinary(prec + 1);
9166
+ this.countNode();
9167
+ if (isLogicalOp(op)) left = {
9168
+ kind: "logical",
9169
+ op,
9170
+ left,
9171
+ right
9172
+ };
9173
+ else if (isBinaryOp(op)) left = {
9174
+ kind: "binary",
9175
+ op,
9176
+ left,
9177
+ right
9178
+ };
9179
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
9180
+ }
9181
+ return left;
9182
+ }
9183
+ parseUnary() {
9184
+ const tok = this.peek();
9185
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
9186
+ const op = tok.punct;
9187
+ this.pos += 1;
9188
+ const operand = this.parseUnary();
9189
+ this.countNode();
9190
+ return {
9191
+ kind: "unary",
9192
+ op,
9193
+ operand
9194
+ };
9195
+ }
9196
+ return this.parsePrimary();
9197
+ }
9198
+ parsePrimary() {
9199
+ const tok = this.next();
9200
+ switch (tok.type) {
9201
+ case "number":
9202
+ this.countNode();
9203
+ return {
9204
+ kind: "literal",
9205
+ value: tok.value
9206
+ };
9207
+ case "string":
9208
+ this.countNode();
9209
+ return {
9210
+ kind: "literal",
9211
+ value: tok.value
9212
+ };
9213
+ case "keyword":
9214
+ this.countNode();
9215
+ return {
9216
+ kind: "literal",
9217
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
9218
+ };
9219
+ case "identifier": {
9220
+ const nextTok = this.peek();
9221
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
9222
+ this.identifiers.add(tok.name);
9223
+ this.countNode();
9224
+ return {
9225
+ kind: "identifier",
9226
+ name: tok.name
9227
+ };
9228
+ }
9229
+ case "punct":
9230
+ if (tok.punct === "(") {
9231
+ const inner = this.parseTernary();
9232
+ this.expectPunct(")");
9233
+ return inner;
9234
+ }
9235
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
9236
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
9237
+ }
9238
+ }
9239
+ parseCall(callee, pos) {
9240
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
9241
+ this.expectPunct("(");
9242
+ const args = [];
9243
+ if (!this.matchPunct(")")) for (;;) {
9244
+ args.push(this.parseTernary());
9245
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
9246
+ if (this.matchPunct(",")) continue;
9247
+ this.expectPunct(")");
9248
+ break;
9249
+ }
9250
+ this.callees.add(callee);
9251
+ this.countNode();
9252
+ return {
9253
+ kind: "call",
9254
+ callee,
9255
+ args
9256
+ };
9257
+ }
9258
+ };
9259
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
9260
+ * `ExpressionParseError` on any lexical or grammatical failure. */
9261
+ function parseExpression(source) {
9262
+ return new Parser(tokenize(source)).parse();
9263
+ }
8965
9264
  /**
8966
- * Alerts capability collection-based internal alert system.
9265
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
9266
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
9267
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
9268
+ * one per read on a hot resolve path.
8967
9269
  *
8968
- * Multiple providers can register. Each provider filters by EventBus category
8969
- * and creates/updates alerts. The built-in Alert Center addon persists alerts
8970
- * in the DB and serves them to the admin UI.
9270
+ * The cache is a module-level singleton: entries are pure, content-addressed
9271
+ * ASTs keyed by the raw source string, so sharing one instance across all
9272
+ * callers is safe and maximises hit rate.
8971
9273
  */
8972
- var AlertSeveritySchema = _enum([
8973
- "info",
8974
- "success",
8975
- "warning",
8976
- "error"
9274
+ var cache = /* @__PURE__ */ new Map();
9275
+ function getCached(source) {
9276
+ const hit = cache.get(source);
9277
+ if (hit !== void 0) {
9278
+ cache.delete(source);
9279
+ cache.set(source, hit);
9280
+ return hit;
9281
+ }
9282
+ let result;
9283
+ try {
9284
+ result = {
9285
+ ok: true,
9286
+ parsed: parseExpression(source)
9287
+ };
9288
+ } catch (err) {
9289
+ result = {
9290
+ ok: false,
9291
+ error: err instanceof ExpressionParseError ? err.message : String(err)
9292
+ };
9293
+ }
9294
+ cache.set(source, result);
9295
+ if (cache.size > 256) {
9296
+ const oldest = cache.keys().next().value;
9297
+ if (oldest !== void 0) cache.delete(oldest);
9298
+ }
9299
+ return result;
9300
+ }
9301
+ /** Compile `source`, returning a discriminated result instead of throwing.
9302
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
9303
+ function compileExpressionSafe(source) {
9304
+ return getCached(source);
9305
+ }
9306
+ Object.freeze({});
9307
+ /**
9308
+ * Author-time validation. Returns `null` when the source is valid, else a
9309
+ * human-readable error message. Checks: the expression compiles; binding count
9310
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
9311
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
9312
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
9313
+ */
9314
+ function validateExpressionSource(src) {
9315
+ const names = Object.keys(src.bindings);
9316
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
9317
+ for (const name of names) {
9318
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
9319
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
9320
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
9321
+ }
9322
+ const compiled = compileExpressionSafe(src.expr);
9323
+ if (!compiled.ok) return compiled.error;
9324
+ const bound = new Set(names);
9325
+ for (const id of compiled.parsed.identifiers) {
9326
+ if (id === "now") continue;
9327
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
9328
+ }
9329
+ return null;
9330
+ }
9331
+ var ExpressionBindingSourceSchema = union([
9332
+ object({
9333
+ kind: literal("field").optional(),
9334
+ sourceKey: string(),
9335
+ cap: string(),
9336
+ fieldPath: string()
9337
+ }),
9338
+ object({
9339
+ kind: literal("literal"),
9340
+ value: union([
9341
+ string(),
9342
+ number(),
9343
+ boolean(),
9344
+ _null()
9345
+ ])
9346
+ }),
9347
+ object({
9348
+ kind: literal("global"),
9349
+ sourceStableId: string(),
9350
+ cap: string(),
9351
+ fieldPath: string()
9352
+ })
8977
9353
  ]);
8978
- var AlertStatusSchema = _enum([
8979
- "active",
8980
- "in-progress",
8981
- "completed",
8982
- "failed",
8983
- "dismissed"
9354
+ object({
9355
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
9356
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
9357
+ }).superRefine((src, ctx) => {
9358
+ const err = validateExpressionSource(src);
9359
+ if (err !== null) ctx.addIssue({
9360
+ code: "custom",
9361
+ message: err,
9362
+ path: ["expr"]
9363
+ });
9364
+ });
9365
+ /** How a leaf compares a device field to a value. Derived from the field's
9366
+ * `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
9367
+ var AutomationConditionOperatorSchema = _enum([
9368
+ "eq",
9369
+ "ne",
9370
+ "gt",
9371
+ "gte",
9372
+ "lt",
9373
+ "lte",
9374
+ "contains",
9375
+ "in"
9376
+ ]);
9377
+ var AutomationConditionLeafSchema = object({
9378
+ kind: literal("condition"),
9379
+ deviceId: number().int().nonnegative(),
9380
+ cap: string().min(1),
9381
+ fieldPath: string().min(1),
9382
+ operator: AutomationConditionOperatorSchema,
9383
+ value: union([
9384
+ string(),
9385
+ number(),
9386
+ boolean(),
9387
+ array(union([string(), number()]))
9388
+ ])
9389
+ });
9390
+ /**
9391
+ * The expression leaf, declared as a plain object rather than an intersection
9392
+ * with {@link ExpressionSourceSchema}: a discriminated union has to be able to
9393
+ * read `kind` off each option, and an intersection hides it. The author-time
9394
+ * validation is the SAME function `ExpressionSourceSchema` runs, so the two
9395
+ * cannot drift — an expression that one accepts, the other accepts.
9396
+ */
9397
+ var AutomationConditionExpressionSchema = object({
9398
+ kind: literal("expression"),
9399
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
9400
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
9401
+ }).superRefine((src, ctx) => {
9402
+ const err = validateExpressionSource(src);
9403
+ if (err !== null) ctx.addIssue({
9404
+ code: "custom",
9405
+ message: err,
9406
+ path: ["expr"]
9407
+ });
9408
+ });
9409
+ var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
9410
+ object({
9411
+ kind: literal("all"),
9412
+ children: array(AutomationConditionSchema)
9413
+ }),
9414
+ object({
9415
+ kind: literal("any"),
9416
+ children: array(AutomationConditionSchema)
9417
+ }),
9418
+ object({
9419
+ kind: literal("not"),
9420
+ child: AutomationConditionSchema
9421
+ }),
9422
+ AutomationConditionLeafSchema,
9423
+ AutomationConditionExpressionSchema
9424
+ ]));
9425
+ /**
9426
+ * What starts a run.
9427
+ *
9428
+ * D8 compliance, and it is the reason `device-state` is not merely an event
9429
+ * subscription: the trigger evaluates against the **state mirror**, which is
9430
+ * reconciled, and an event only WAKES the evaluation. A dropped event therefore
9431
+ * DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
9432
+ * already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
9433
+ * not.
9434
+ */
9435
+ var AutomationTriggerSchema = discriminatedUnion("kind", [
9436
+ object({
9437
+ kind: literal("device-state"),
9438
+ deviceId: number().int().nonnegative(),
9439
+ cap: string().min(1),
9440
+ fieldPath: string().min(1),
9441
+ /** Fire when the field takes this value. Omit to fire on any change. */
9442
+ becomes: union([
9443
+ string(),
9444
+ number(),
9445
+ boolean()
9446
+ ]).optional(),
9447
+ /** Only on a CHANGE of value, not on every re-report. */
9448
+ edge: boolean().optional(),
9449
+ /** The condition must hold this long before the run starts. */
9450
+ forMs: number().int().min(0).max(864e5).optional(),
9451
+ /** Collapse a burst into one run. */
9452
+ debounceMs: number().int().min(0).max(6e5).optional()
9453
+ }),
9454
+ object({
9455
+ kind: literal("device-event"),
9456
+ /** An `EventCategory` value. */
9457
+ category: string().min(1),
9458
+ deviceId: number().int().nonnegative().optional()
9459
+ }),
9460
+ object({
9461
+ kind: literal("schedule"),
9462
+ cron: string().min(1).max(120)
9463
+ }),
9464
+ object({ kind: literal("manual") })
9465
+ ]);
9466
+ /**
9467
+ * One action step.
9468
+ *
9469
+ * `wait` and `cap` are `NcRuleActionSchema`'s two members, kept structurally
9470
+ * identical so `NcRuleActionRunner` runs them unchanged — its device-scope
9471
+ * check, stop-at-first-failure and per-sequence throttle are the whole reason
9472
+ * to reuse it, and none of them are re-implemented here.
9473
+ *
9474
+ * **The one divergence, and it is forced.** `NcRuleActionSchema.cap.deviceId` is
9475
+ * a literal `z.number().int()`, and the NC runner's own `RunSequencesInput`
9476
+ * documents its subject device as *"for the log tag, never for routing"*. So an
9477
+ * NC action can never target the device that triggered it — which is fine for
9478
+ * the NC (its rules already scope to a device) and fatal for an automation
9479
+ * ("sound the siren of the camera that saw the person"). `deviceId` therefore
9480
+ * also accepts `{ $var }`, resolved from the run's `vars` bag BEFORE the runner
9481
+ * is called. The runner still receives a number and is untouched; the
9482
+ * resolution is the recipe's job, not the runner's.
9483
+ */
9484
+ var AutomationActionSchema = discriminatedUnion("kind", [
9485
+ object({
9486
+ kind: literal("wait"),
9487
+ seconds: number().min(0).max(300)
9488
+ }),
9489
+ object({
9490
+ kind: literal("cap"),
9491
+ deviceId: union([number().int(), object({ $var: string().min(1) })]),
9492
+ cap: string().min(1),
9493
+ method: string().min(1),
9494
+ /** Values may carry `{{vars.x}}` slots, which SUBSTITUTE and do not
9495
+ * evaluate (§3.2.3). Anything beyond substitution is the expression leaf. */
9496
+ args: record(string(), unknown()).optional()
9497
+ }),
9498
+ object({
9499
+ kind: literal("code"),
9500
+ /** Compiled into the automation's OWN block by esbuild — not a third
9501
+ * runtime, not a `vm`, and not dynamically evaluated. */
9502
+ code: string().min(1).max(2e4)
9503
+ })
9504
+ ]);
9505
+ object({
9506
+ triggers: array(AutomationTriggerSchema),
9507
+ conditions: AutomationConditionSchema.optional(),
9508
+ actions: array(AutomationActionSchema)
9509
+ });
9510
+ /**
9511
+ * `addon-pages` — system-scoped singleton aggregator cap. Public-facing
9512
+ * surface that admin-ui consumes through `useAddonPagesListPages()`.
9513
+ *
9514
+ * The provider iterates every `addon-pages-source` (collection) provider
9515
+ * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
9516
+ * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
9517
+ * filesystem `mtime` cache-buster lets the browser pick up addon
9518
+ * rebuilds without manual reload.
9519
+ *
9520
+ * The hub-local builtin `addon-pages-aggregator` (see
9521
+ * `@camstack/system/builtins/addon-pages-aggregator`) registers the
9522
+ * provider. Splitting the public aggregator from the raw collection
9523
+ * keeps both ends in codegen — there's no hand-written
9524
+ * `addon-pages.router.ts` wrapper anymore.
9525
+ */
9526
+ var AddonPageDeclarationSchema$1 = object({
9527
+ id: string(),
9528
+ label: string(),
9529
+ icon: string(),
9530
+ path: string(),
9531
+ remoteName: string(),
9532
+ bundle: string(),
9533
+ section: string().optional(),
9534
+ sectionLabel: string().optional()
9535
+ });
9536
+ var AddonPageInfoSchema = object({
9537
+ addonId: string(),
9538
+ page: AddonPageDeclarationSchema$1,
9539
+ bundleUrl: string()
9540
+ });
9541
+ method(_void(), array(AddonPageInfoSchema).readonly());
9542
+ /**
9543
+ * `addon-pages-source` — collection cap exposing per-provider raw page
9544
+ * declarations. Every addon that contributes a UI page registers a
9545
+ * provider here. The hub-side singleton aggregator (`addon-pages` cap,
9546
+ * see `addon-pages.cap.ts`) walks this collection, stamps versioned
9547
+ * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
9548
+ * that admin-ui consumes.
9549
+ *
9550
+ * The split exists because the public listing has a different output
9551
+ * shape than the per-provider raw declarations, and we want both ends
9552
+ * to flow through codegen instead of relying on a hand-written wrapper.
9553
+ */
9554
+ var AddonPageDeclarationSchema = object({
9555
+ id: string(),
9556
+ label: string(),
9557
+ icon: string(),
9558
+ path: string(),
9559
+ /**
9560
+ * Module Federation remote name — must match the `name` field on the
9561
+ * page addon's `federation()` plugin config. Used by admin-ui's
9562
+ * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
9563
+ * Conventionally `addon_<id>_page` (snake_case; MF names cannot
9564
+ * contain hyphens).
9565
+ */
9566
+ remoteName: string(),
9567
+ /**
9568
+ * Bundle filename inside the addon's `dist/` dir served at
9569
+ * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
9570
+ * is always `'remoteEntry.js'`; the value is kept on the metadata so
9571
+ * the static-file route can compute an mtime-based cache-buster URL
9572
+ * without a separate filesystem stat.
9573
+ */
9574
+ bundle: string(),
9575
+ /**
9576
+ * Sidebar section this page docks into. Well-known ids: `'detection'`,
9577
+ * `'cluster'`, `'administration'` — the page renders inside that group.
9578
+ * Any OTHER string creates (or joins) a custom section rendered after
9579
+ * the built-in groups; its label comes from `sectionLabel` (first
9580
+ * declaration wins), falling back to the id. Absent → the legacy
9581
+ * "Addon Pages" group.
9582
+ */
9583
+ section: string().optional(),
9584
+ /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
9585
+ sectionLabel: string().optional()
9586
+ });
9587
+ method(_void(), array(AddonPageDeclarationSchema).readonly());
9588
+ var AddonHttpRouteSchema = object({
9589
+ method: _enum([
9590
+ "GET",
9591
+ "POST",
9592
+ "PUT",
9593
+ "DELETE",
9594
+ "PATCH"
9595
+ ]),
9596
+ path: string(),
9597
+ access: _enum([
9598
+ "public",
9599
+ "authenticated",
9600
+ "admin"
9601
+ ]).optional(),
9602
+ description: string().optional()
9603
+ });
9604
+ /**
9605
+ * Cross-process route invocation envelope. The hub captures the
9606
+ * request as plain data, ships it to the worker via Moleculer, and
9607
+ * the worker runs the local handler against a capturing reply. The
9608
+ * envelope returned describes what the handler intended (status,
9609
+ * headers, body, or a redirect) so the hub can translate it back to
9610
+ * the Fastify reply that's actually wired to the socket.
9611
+ */
9612
+ var InvokeRequestSchema = object({
9613
+ method: string(),
9614
+ path: string(),
9615
+ params: record(string(), string()),
9616
+ query: record(string(), string()),
9617
+ body: unknown(),
9618
+ headers: record(string(), string()),
9619
+ user: object({
9620
+ id: string(),
9621
+ username: string(),
9622
+ isAdmin: boolean()
9623
+ }).optional(),
9624
+ scopedToken: unknown().optional()
9625
+ });
9626
+ var InvokeReplyEnvelopeSchema = object({
9627
+ status: number().int(),
9628
+ headers: record(string(), string()),
9629
+ /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
9630
+ * sending `body`. Status defaults to 302 when this is set unless
9631
+ * the handler called `reply.code(...)` explicitly. */
9632
+ redirectUrl: string().nullable(),
9633
+ /** JSON-serializable body. `undefined` is treated as "no body". */
9634
+ body: unknown().optional(),
9635
+ /** Set when the handler called `reply.type(mime)`. */
9636
+ contentType: string().optional()
9637
+ });
9638
+ method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
9639
+ var ConfigTabDeclarationSchema = object({
9640
+ id: string(),
9641
+ label: string(),
9642
+ icon: string(),
9643
+ order: number().optional()
9644
+ });
9645
+ var ConfigSectionWithValuesSchema = object({
9646
+ id: string(),
9647
+ title: string(),
9648
+ description: string().optional(),
9649
+ style: _enum(["card", "accordion"]).optional(),
9650
+ defaultCollapsed: boolean().optional(),
9651
+ columns: union([
9652
+ literal(1),
9653
+ literal(2),
9654
+ literal(3),
9655
+ literal(4)
9656
+ ]).optional(),
9657
+ tab: string().optional(),
9658
+ location: _enum(["settings", "top-tab"]).optional(),
9659
+ order: number().optional(),
9660
+ fields: array(any())
9661
+ });
9662
+ var SettingsSchemaWithValuesSchema = object({
9663
+ tabs: array(ConfigTabDeclarationSchema).optional(),
9664
+ sections: array(ConfigSectionWithValuesSchema)
9665
+ });
9666
+ /** Patch object — keys are field names, values are the new field values. */
9667
+ var SettingsPatchSchema = record(string(), unknown());
9668
+ /** Standard success response for update operations. */
9669
+ var SettingsUpdateResultSchema = object({ success: literal(true) });
9670
+ method(object({
9671
+ addonId: string(),
9672
+ nodeId: string().optional(),
9673
+ overlay: record(string(), unknown()).optional(),
9674
+ cap: string().optional()
9675
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9676
+ addonId: string(),
9677
+ nodeId: string().optional(),
9678
+ patch: SettingsPatchSchema
9679
+ }), SettingsUpdateResultSchema, {
9680
+ kind: "mutation",
9681
+ auth: "admin"
9682
+ }), method(object({
9683
+ addonId: string(),
9684
+ deviceId: number(),
9685
+ nodeId: string().optional()
9686
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9687
+ addonId: string(),
9688
+ deviceId: number(),
9689
+ nodeId: string().optional(),
9690
+ patch: SettingsPatchSchema
9691
+ }), SettingsUpdateResultSchema, {
9692
+ kind: "mutation",
9693
+ auth: "admin"
9694
+ });
9695
+ /**
9696
+ * `addon-widgets-source` — collection cap exposing per-addon raw widget
9697
+ * declarations. Mirrors the addon-pages split: every addon shipping
9698
+ * widgets registers a provider on this collection cap; the hub-local
9699
+ * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
9700
+ * collection, stamps versioned `bundleUrl`s onto each declaration, and
9701
+ * exposes the public listing surface that admin-ui consumes.
9702
+ *
9703
+ * The split exists because the public listing has a different output
9704
+ * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
9705
+ * per-provider raw declarations. Both ends flow through codegen.
9706
+ *
9707
+ * Unified UI-contribution model (Task 10): a widget descriptor IS a
9708
+ * `UiContribution` with `kind:'remote'`. The host renders it through the
9709
+ * same `ContributionRenderer` / Module-Federation path as every other
9710
+ * contributed UI surface — no bespoke widget-rendering path. The widget-
9711
+ * only metadata (sizing hints, `requires`) lives as extra fields on the
9712
+ * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
9713
+ * `kind` / `remote`) carries identity + placement + the MF remote.
9714
+ */
9715
+ /** Where the widget makes sense to render — maps to a contribution `tab`. */
9716
+ var WidgetHostEnum = _enum([
9717
+ "device-tab",
9718
+ "dashboard",
9719
+ "integration-detail"
9720
+ ]);
9721
+ var WidgetSizeEnum = _enum([
9722
+ "xs",
9723
+ "sm",
9724
+ "md",
9725
+ "lg",
9726
+ "xl"
9727
+ ]);
9728
+ /**
9729
+ * MF remote descriptor — mirrors `UiContributionRemote` from
9730
+ * `capability-definition.ts`. Widget remotes expose a single
9731
+ * `'./widgets'` module whose default export is a
9732
+ * `Record<componentKey, Component>` map; `componentKey` (the widget
9733
+ * `stableId`) picks the entry the host mounts.
9734
+ */
9735
+ var WidgetRemoteSchema = object({
9736
+ remoteName: string(),
9737
+ exposedModule: string(),
9738
+ componentKey: string().optional()
9739
+ });
9740
+ /**
9741
+ * One widget declaration — a `UiContribution` (`kind:'remote'`) plus
9742
+ * widget-only metadata. The `UiContribution` core fields:
9743
+ *
9744
+ * - `tab` — where the widget hosts. A widget that runs on the
9745
+ * dashboard declares `tab:'dashboard'`; a device-tab
9746
+ * widget declares the target device-detail tab id.
9747
+ * - `subTab` — optional sub-tab within `tab`.
9748
+ * - `label` — operator-facing label.
9749
+ * - `order` — ordering within `(tab, subTab)`.
9750
+ * - `kind` — always `'remote'` for widgets.
9751
+ * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
9752
+ *
9753
+ * Widget-only fields retained alongside the contribution core:
9754
+ *
9755
+ * - `stableId` — stable identity within the addon (the MF
9756
+ * `componentKey`; kept top-level so consumers have
9757
+ * a stable key without reaching into `remote`).
9758
+ * - `description` / `icon` — picker metadata.
9759
+ * - `bundle` — entry filename inside the addon `dist/` dir; the
9760
+ * aggregator stamps a versioned `bundleUrl` from it.
9761
+ * - `hosts` — every host the widget supports (a widget can run
9762
+ * both on the dashboard and a device tab). `tab`
9763
+ * is the PRIMARY host; `hosts` is the full set the
9764
+ * picker filters on.
9765
+ * - `requires` — host-context requirements validated at mount.
9766
+ * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
9767
+ * — dashboard placement hints.
9768
+ */
9769
+ var WidgetMetadataSchema = object({
9770
+ /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
9771
+ tab: string(),
9772
+ /** Optional sub-tab within `tab`. */
9773
+ subTab: string().optional(),
9774
+ /** Operator-facing label. */
9775
+ label: string(),
9776
+ /** Ordering within `(tab, subTab)`, ascending. */
9777
+ order: number().optional(),
9778
+ /** Always `'remote'` — a widget is a Module Federation remote. */
9779
+ kind: literal("remote"),
9780
+ /** MF remote descriptor. */
9781
+ remote: WidgetRemoteSchema,
9782
+ /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
9783
+ stableId: string(),
9784
+ description: string().optional(),
9785
+ icon: string().optional(),
9786
+ /**
9787
+ * Bundle filename inside the addon's `dist/` dir served at
9788
+ * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
9789
+ * this is always `'remoteEntry.js'` — the value is kept on the
9790
+ * metadata so the static-file route can compute an mtime-based
9791
+ * cache-buster URL without a separate filesystem stat.
9792
+ */
9793
+ bundle: string(),
9794
+ /** Every host the widget supports. The picker filters on this set. */
9795
+ hosts: array(WidgetHostEnum).readonly(),
9796
+ /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
9797
+ requires: object({
9798
+ deviceContext: boolean().default(false),
9799
+ integrationContext: boolean().default(false)
9800
+ }),
9801
+ /**
9802
+ * Loadable BEFORE authentication. The normal widget registry listing
9803
+ * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
9804
+ * (the login page) cannot discover a widget through it. A widget that
9805
+ * declares `preAuth: true` marks itself as safe to mount on a pre-auth
9806
+ * screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
9807
+ * login-method contribution channel (see `login-method.cap.ts`) rather
9808
+ * than the authenticated registry, and its bundle is served by the
9809
+ * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
9810
+ */
9811
+ preAuth: boolean().optional().default(false),
9812
+ /** Dashboard placement HINTS (operator can override per instance). */
9813
+ defaultSize: WidgetSizeEnum.default("md"),
9814
+ allowedSizes: array(WidgetSizeEnum).readonly().default([
9815
+ "sm",
9816
+ "md",
9817
+ "lg"
9818
+ ]),
9819
+ defaultColumns: number().int().min(1).max(12).default(6),
9820
+ defaultRows: number().int().min(1).max(12).default(1)
9821
+ });
9822
+ method(_void(), array(WidgetMetadataSchema).readonly());
9823
+ /**
9824
+ * `addon-widgets` — system-scoped singleton aggregator cap. Public-facing
9825
+ * surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
9826
+ *
9827
+ * The provider iterates every `addon-widgets-source` (collection)
9828
+ * provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
9829
+ * `bundleUrl` strings pointing at
9830
+ * `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
9831
+ * `mtime` cache-buster lets the browser pick up addon rebuilds without
9832
+ * manual reload — same scheme used by `addon-pages`.
9833
+ *
9834
+ * The hub-local builtin `addon-widgets-aggregator` (see
9835
+ * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
9836
+ * provider. Splitting the public aggregator from the raw collection
9837
+ * keeps both ends in codegen — there's no hand-written wrapper.
9838
+ */
9839
+ var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
9840
+ addonId: string(),
9841
+ bundleUrl: string()
9842
+ });
9843
+ method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
9844
+ /**
9845
+ * Alerts capability — collection-based internal alert system.
9846
+ *
9847
+ * Multiple providers can register. Each provider filters by EventBus category
9848
+ * and creates/updates alerts. The built-in Alert Center addon persists alerts
9849
+ * in the DB and serves them to the admin UI.
9850
+ */
9851
+ var AlertSeveritySchema = _enum([
9852
+ "info",
9853
+ "success",
9854
+ "warning",
9855
+ "error"
9856
+ ]);
9857
+ var AlertStatusSchema = _enum([
9858
+ "active",
9859
+ "in-progress",
9860
+ "completed",
9861
+ "failed",
9862
+ "dismissed"
8984
9863
  ]);
8985
9864
  var AlertSourceSchema = object({
8986
9865
  type: string(),
@@ -9656,6 +10535,69 @@ var StreamFormatSchema = _enum([
9656
10535
  "mjpeg",
9657
10536
  "rtsp"
9658
10537
  ]);
10538
+ /** A container `produceEventMedia` can emit. */
10539
+ var EventMediaKindSchema = _enum(["mp4", "gif"]);
10540
+ /**
10541
+ * One produced artifact, referenced by HANDLE.
10542
+ *
10543
+ * Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
10544
+ * method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
10545
+ * [D18](../../../../docs/decisions/adr-0018.md) — cross-process media is fetched
10546
+ * on demand, compressed, by handle). `bytes` is here so a caller can decide
10547
+ * whether it wants the fetch at all.
10548
+ */
10549
+ var EventMediaArtifactSchema = object({
10550
+ kind: EventMediaKindSchema,
10551
+ /** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
10552
+ handle: string(),
10553
+ /**
10554
+ * The node holding the bytes — the ROUTING key for `fetchEventMedia`.
10555
+ *
10556
+ * `stream-broker` is a singleton cap and an unpinned call never leaves the
10557
+ * hub, so a handle produced on an agent's broker would be redeemed against
10558
+ * the hub's store and come back `null`. Same contract, same field name and
10559
+ * the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
10560
+ * lives and the consumer pins to it.
10561
+ */
10562
+ nodeId: string(),
10563
+ mime: string(),
10564
+ bytes: number().int(),
10565
+ width: number().int(),
10566
+ height: number().int()
10567
+ });
10568
+ /**
10569
+ * What a production actually covered — the answer to the only question an
10570
+ * operator asks about a notification clip.
10571
+ *
10572
+ * `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
10573
+ * so a caller can state "this clip starts 4.1 s before the event" instead of
10574
+ * inferring it from a duration. A production whose `fromTs` is later than the
10575
+ * event is a production with no pre-roll, and that is exactly the defect this
10576
+ * method exists to make visible rather than plausible.
10577
+ */
10578
+ var EventMediaCoverageSchema = object({
10579
+ fromTs: number(),
10580
+ toTs: number(),
10581
+ /** Encoded packets in the muxed window. */
10582
+ packets: number().int()
10583
+ });
10584
+ /**
10585
+ * The result of ONE cut, in every container the caller asked for.
10586
+ *
10587
+ * Every artifact in `media` came out of the SAME window of the SAME rendition —
10588
+ * that is the whole reason this is one method rather than one call per format.
10589
+ * A consumer attaching a gif and a video can no longer show two different
10590
+ * moments, because it never chose two sources.
10591
+ */
10592
+ var EventMediaProductionSchema = object({
10593
+ media: array(EventMediaArtifactSchema).readonly(),
10594
+ coverage: EventMediaCoverageSchema,
10595
+ /** The rendition actually cut from — what the default or the fallback chose. */
10596
+ profile: CamProfileSchema,
10597
+ /** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
10598
+ * source, a downscale, or a playback rate other than 1). */
10599
+ video: _enum(["copy", "encode"])
10600
+ });
9659
10601
  var RtspRestreamEntrySchema = object({
9660
10602
  brokerId: string(),
9661
10603
  url: string(),
@@ -10055,6 +10997,56 @@ method(object({
10055
10997
  }), {
10056
10998
  kind: "mutation",
10057
10999
  auth: "admin"
11000
+ }), method(object({
11001
+ deviceId: number(),
11002
+ /** Absent = the largest H.264 rendition at or below 1080p, which is
11003
+ * also the one that can be copied. Falls back to whatever the ring
11004
+ * actually retained, and the answer says which. */
11005
+ profile: CamProfileSchema.optional(),
11006
+ aroundMs: number(),
11007
+ preSeconds: number().min(0).max(20).default(4),
11008
+ postSeconds: number().min(0).max(20).default(6),
11009
+ kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
11010
+ /** GIF geometry. The video keeps the source's own. */
11011
+ gifMaxWidth: number().int().min(120).max(1280).default(640),
11012
+ /**
11013
+ * The gif's own PLAYBACK rate in frames per second — what the finished
11014
+ * gif runs at, not how many source frames feed it. The decimation that
11015
+ * feeds it samples `gifFps / gifSpeed` source frames per second, so at
11016
+ * the defaults a 12 fps gif is built out of 3 source frames a second.
11017
+ */
11018
+ gifFps: number().int().min(1).max(15).default(12),
11019
+ /**
11020
+ * How fast the GIF plays against real time, independent of `speed`.
11021
+ *
11022
+ * 4× by default, by operator request: a notification gif is glanced at
11023
+ * on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
11024
+ * a separate knob from `speed` even though both now default to 4 —
11025
+ * a caller wanting a real-time video and a fast gif must not have to
11026
+ * choose.
11027
+ */
11028
+ gifSpeed: number().min(1).max(8).default(4),
11029
+ /**
11030
+ * Playback rate of the VIDEO. Also 4× by default, by operator decision.
11031
+ *
11032
+ * `1` is real time and is the ONLY value that allows the copy branch —
11033
+ * anything else forces `libx264` over the window. That was priced
11034
+ * before it was chosen: a per-event burst measured at 0.23 s and 254 KB
11035
+ * on a real 615 720p cut, against 922 KB for the copy it replaces. A
11036
+ * re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
11037
+ * once the decode is forced the width stops being free.
11038
+ */
11039
+ speed: number().min(1).max(8).default(4)
11040
+ }), EventMediaProductionSchema, {
11041
+ kind: "mutation",
11042
+ auth: "admin"
11043
+ }), method(object({ handle: string() }), object({
11044
+ base64: string(),
11045
+ mime: string(),
11046
+ bytes: number().int()
11047
+ }).nullable(), {
11048
+ kind: "mutation",
11049
+ auth: "admin"
10058
11050
  }), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
10059
11051
  probed: boolean(),
10060
11052
  summary: string()
@@ -10247,25 +11239,6 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
10247
11239
  }),
10248
11240
  lastChangedAt: number()
10249
11241
  });
10250
- /**
10251
- * core-blocks — user-authored TypeScript, stored in the kernel and executed in
10252
- * its own process.
10253
- *
10254
- * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
10255
- *
10256
- * The first use is **owning devices without being a device provider**: a block
10257
- * declares devices under a system or custom integration and drives their state,
10258
- * with the same `ctx` an addon gets. Automations come later; nothing here
10259
- * models a trigger.
10260
- *
10261
- * **Stated plainly, because it does not change by being true:** a block has an
10262
- * addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
10263
- * with no review step. What makes that survivable is not a sandbox, it is
10264
- * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
10265
- * so a block that throws or never returns is marked `failed` and visible
10266
- * instead of taking the hub with it (D6). Every method here is admin-only, and
10267
- * must stay so.
10268
- */
10269
11242
  /** Where a block runs. The operator chooses — a block driving a device on an
10270
11243
  * agent is the reason placement is not fixed to the hub. */
10271
11244
  var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
@@ -10337,6 +11310,9 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
10337
11310
  }), object({ block: CoreBlockSchema }), {
10338
11311
  kind: "mutation",
10339
11312
  auth: "admin"
11313
+ }), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
11314
+ kind: "mutation",
11315
+ auth: "admin"
10340
11316
  }), method(object({ code: string() }), CoreBlockCompileResultSchema, {
10341
11317
  kind: "mutation",
10342
11318
  auth: "admin"
@@ -10871,826 +11847,231 @@ var AdoptionFilterSchema = object({
10871
11847
  id: string(),
10872
11848
  label: string(),
10873
11849
  isDefault: boolean().optional()
10874
- });
10875
- /**
10876
- * Candidate-list TEXT/query filter. Mirrors the prior `ha-discovery` filter,
10877
- * de-HA-ified — `area` / `manufacturer` stay free-form strings so any
10878
- * integration can populate them from its own metadata. Distinct from the
10879
- * granularity `AdoptionFilter` above: this narrows the candidate set within a
10880
- * chosen granularity, whereas the granularity `filter` selects WHAT a
10881
- * candidate is (a device vs an entity).
10882
- */
10883
- var CandidateQueryFilterSchema = object({
10884
- /** Substring filter on name + manufacturer + model. */
10885
- search: string().optional(),
10886
- /** Area-name exact match. */
10887
- area: string().optional(),
10888
- /** Manufacturer exact match. */
10889
- manufacturer: string().optional(),
10890
- /** When true, only return candidates the operator already adopted. */
10891
- adoptedOnly: boolean().optional(),
10892
- /** When true, only return candidates the operator hasn't adopted yet. */
10893
- unadoptedOnly: boolean().optional()
10894
- });
10895
- var ListCandidatesInputSchema = object({
10896
- integrationId: string(),
10897
- page: number().int().positive().default(1),
10898
- pageSize: number().int().positive().max(2e4).default(50),
10899
- /**
10900
- * Optional provider-declared discovery GRANULARITY id (opaque; see
10901
- * `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
10902
- * exactly the pre-existing behavior (fully back-compatible).
10903
- */
10904
- filter: string().optional(),
10905
- /** Optional candidate-list text/query narrowing within the granularity. */
10906
- filterText: CandidateQueryFilterSchema.optional()
10907
- });
10908
- var ListCandidatesOutputSchema = object({
10909
- candidates: array(DiscoveredChildDeviceSchema).readonly(),
10910
- totalCount: number().int().nonnegative(),
10911
- page: number().int().positive(),
10912
- pageSize: number().int().positive()
10913
- });
10914
- var GetCandidateInputSchema = object({
10915
- integrationId: string(),
10916
- childNativeId: string()
10917
- });
10918
- var AdoptionStatusSchema = object({
10919
- /** Last refresh timestamp (ms epoch) — null when never refreshed. */
10920
- lastDiscoveryAt: number().int().nonnegative().nullable(),
10921
- /** Count of candidates in the discovery cache. */
10922
- candidateCount: number().int().nonnegative(),
10923
- /** Count of candidates the operator has already adopted. */
10924
- adoptedCount: number().int().nonnegative(),
10925
- /** Last error message from a refresh attempt. */
10926
- lastError: string().nullable()
10927
- });
10928
- var PerCandidateSchema = object({
10929
- /** Override the default display name for this candidate's parent. */
10930
- name: string().min(1).optional(),
10931
- /** Pre-hide a subset of entity-children — created (state still flows)
10932
- * but listed in the parent's `accessories.hiddenChildIds`. */
10933
- hiddenChildIds: array(string()).optional()
10934
- });
10935
- var AdoptInputSchema = object({
10936
- integrationId: string(),
10937
- /**
10938
- * Candidate native ids to adopt. Their MEANING is filter-relative: under the
10939
- * default `'devices'` granularity these are device-native ids; under a
10940
- * provider-declared granularity (e.g. HA `'entities'`) they are that
10941
- * granularity's native ids (e.g. entity ids). The field name is kept stable
10942
- * to avoid a breaking rename.
10943
- */
10944
- childNativeIds: array(string()).min(1),
10945
- /**
10946
- * Optional provider-declared discovery GRANULARITY id (opaque; see
10947
- * `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
10948
- * exactly the pre-existing behavior (fully back-compatible).
10949
- */
10950
- filter: string().optional(),
10951
- /** When true, import each adopted device's source-system location (e.g. HA
10952
- * area) into CamStack — fuzzy-match an existing location or create it, then
10953
- * assign. Omitted/false = no location work (back-compat). */
10954
- importLocations: boolean().optional(),
10955
- perCandidate: record(string(), PerCandidateSchema).optional()
10956
- });
10957
- var AdoptResultSchema = object({ adopted: array(object({
10958
- childNativeId: string(),
10959
- parentDeviceId: number().int().nonnegative(),
10960
- accessoryDeviceIds: array(number().int().nonnegative()).readonly()
10961
- })).readonly() });
10962
- var ReleaseInputSchema = object({
10963
- integrationId: string(),
10964
- /** Parent CamStack device id (NOT an accessory child id). Removing
10965
- * the parent cascades into every accessory. */
10966
- camDeviceId: number().int().nonnegative()
10967
- });
10968
- var ResyncInputSchema = object({
10969
- /** Parent CamStack device id of an adopted device. The provider resolves its
10970
- * source (integration/broker + native id) and re-aligns the device's
10971
- * structural spec (type/role/capabilities/units) with the live mapping,
10972
- * rebuilding any child whose class changed while preserving operator edits. */
10973
- camDeviceId: number().int().nonnegative(),
10974
- /** "Resync from zero" (#19). When true, the kernel PURGES every accessory
10975
- * child of `camDeviceId` BEFORE the provider re-derives the device, so the
10976
- * children are rebuilt fresh from source — correct names, coords, and units —
10977
- * instead of being preserved by the incremental reconcile. Use to recover from
10978
- * legacy generic/placeholder names that the normal name-precedence keeps frozen
10979
- * (the operator's explicit reset). Push-driven integrations (no-op resync)
10980
- * rebuild on their next snapshot; pull/command integrations rebuild in `resync`.
10981
- * Operator edits on the PARENT (its name, layout, primary-child pick) survive —
10982
- * only the children are torn down. Omitted/false ⇒ the normal incremental
10983
- * re-sync that preserves children. */
10984
- resetToSource: boolean().optional()
10985
- });
10986
- var ResyncResultSchema = object({
10987
- /** True when the persisted spec actually changed (children may have been rebuilt). */
10988
- changed: boolean(),
10989
- /** Number of child devices rebuilt into a new class by this re-sync. */
10990
- rebuiltChildren: number().int().nonnegative(),
10991
- /** Number of accessory children torn down by a `resetToSource` purge before the
10992
- * provider re-derived the device. 0/absent for a normal incremental re-sync. */
10993
- removedChildren: number().int().nonnegative().optional()
10994
- });
10995
- 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, {
10996
- kind: "mutation",
10997
- auth: "admin"
10998
- }), method(AdoptInputSchema, AdoptResultSchema, {
10999
- kind: "mutation",
11000
- auth: "admin"
11001
- }), method(ReleaseInputSchema, _void(), {
11002
- kind: "mutation",
11003
- auth: "admin"
11004
- }), method(ResyncInputSchema, ResyncResultSchema, {
11005
- kind: "mutation",
11006
- auth: "admin"
11007
- });
11008
- /**
11009
- * `device-export` — collection cap for addons that export camstack
11010
- * devices to external ecosystems (HomeAssistant via MQTT discovery,
11011
- * HomeKit/HAP, Alexa Smart Home, …).
11012
- *
11013
- * No `ecosystem` enum — the addon id identifies the export target.
11014
- * Each addon owns its mapping logic in its own settings UI. The cap
11015
- * exposes only the COMMON surface: link state, supported device
11016
- * kinds (so the UI can filter the device picker), and per-device
11017
- * expose/unexpose.
11018
- */
11019
- var LinkStateSchema = _enum([
11020
- "unlinked",
11021
- "linked",
11022
- "error"
11023
- ]);
11024
- /**
11025
- * A single label/value row surfaced in the export panel's "Setup"
11026
- * section. Rendered with a copy-to-clipboard button. `secret: true`
11027
- * rows are masked by default with a reveal toggle (client ids,
11028
- * secrets, …).
11029
- */
11030
- var ExportSetupFieldSchema = object({
11031
- label: string(),
11032
- value: string(),
11033
- /** Mask the value by default + render a reveal toggle (client id, secrets). */
11034
- secret: boolean().optional()
11035
- });
11036
- /**
11037
- * Generic, addon-agnostic pairing/account block. Any export addon can
11038
- * surface a scannable QR, a set of copyable label/value rows, and a
11039
- * free-form operator note — the `DeviceExportPanel` renders whatever
11040
- * the provider supplies and skips the section entirely when `setup`
11041
- * is unset.
11042
- */
11043
- var ExportSetupSchema = object({
11044
- /** A string to render as a scannable QR — HAP `X-HM://…` URI, a pairing URL, etc. Omitted when there's nothing to scan. */
11045
- qr: string().optional(),
11046
- /** Label/value rows shown with a copy button (HAP setup code, OAuth URLs, client id, linked-account count, …). */
11047
- fields: array(ExportSetupFieldSchema).readonly().optional(),
11048
- /** Free-form operator instructions rendered above the fields. */
11049
- note: string().optional()
11050
- });
11051
- var DeviceExportStatusSchema = object({
11052
- linkState: LinkStateSchema,
11053
- exposedDeviceCount: number(),
11054
- error: string().optional(),
11055
- /**
11056
- * Optional pairing/account info the panel renders in a generic
11057
- * "Setup" section. Addon-agnostic — the addon id identifies the
11058
- * export target, never an `ecosystem` key here.
11059
- */
11060
- setup: ExportSetupSchema.optional()
11061
- });
11062
- var DeviceKindSchema = string();
11063
- var ExposedDeviceSchema = object({
11064
- deviceId: string(),
11065
- exposedAs: string().optional(),
11066
- capabilities: array(string()).optional()
11067
- });
11068
- var ExposeInputSchema = object({
11069
- deviceId: string(),
11070
- capabilities: array(string()).optional()
11071
- });
11072
- var UnexposeInputSchema = object({ deviceId: string() });
11073
- var deviceExportCapability = {
11074
- name: "device-export",
11075
- scope: "system",
11076
- mode: "collection",
11077
- providerKind: "device-export",
11078
- status: {
11079
- schema: DeviceExportStatusSchema,
11080
- kind: "poll"
11081
- },
11082
- /**
11083
- * Each export provider contributes its own per-device "Export" panel
11084
- * (enabled toggle, preferred stream, addon-specific knobs like the
11085
- * HA-MQTT discovery prefix). The three framework methods
11086
- * `getDeviceSettingsContribution` / `getDeviceLiveContribution` /
11087
- * `applyDeviceSettingsPatch` are auto-injected on the provider
11088
- * interface and routed to the device-details aggregator.
11089
- */
11090
- exposesDeviceSettings: true,
11091
- methods: {
11092
- getStatus: method(_void(), DeviceExportStatusSchema),
11093
- listSupportedDeviceKinds: method(_void(), array(DeviceKindSchema)),
11094
- listExposedDevices: method(_void(), array(ExposedDeviceSchema)),
11095
- exposeDevice: method(ExposeInputSchema, _void(), { kind: "mutation" }),
11096
- unexposeDevice: method(UnexposeInputSchema, _void(), { kind: "mutation" })
11097
- }
11098
- };
11099
- /**
11100
- * Resource-bound constants for the safe expression engine.
11101
- *
11102
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
11103
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
11104
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
11105
- * work a single author-supplied expression can request, so a hostile or
11106
- * accidental pathological string can never spend unbounded CPU/memory.
11107
- */
11108
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
11109
- * rejected without allocation. */
11110
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
11111
- /** A legal binding / identifier name. */
11112
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
11113
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
11114
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
11115
- var RESERVED_BINDING_NAMES = new Set([
11116
- "now",
11117
- "true",
11118
- "false",
11119
- "null"
11120
- ]);
11121
- /**
11122
- * Error types for the safe expression engine. Two distinct classes so callers
11123
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
11124
- * failure — both are non-fatal to the host: read paths degrade to "skip link".
11125
- */
11126
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
11127
- * the failure is anchored to a character (author-facing inline feedback). */
11128
- var ExpressionParseError = class extends Error {
11129
- position;
11130
- constructor(message, position) {
11131
- super(message);
11132
- this.name = "ExpressionParseError";
11133
- this.position = position;
11134
- }
11135
- };
11136
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
11137
- * result, unknown builtin, step-budget exceeded). */
11138
- var ExpressionEvalError = class extends Error {
11139
- constructor(message) {
11140
- super(message);
11141
- this.name = "ExpressionEvalError";
11142
- }
11143
- };
11144
- /**
11145
- * Frozen, null-prototype builtin function table for the expression engine
11146
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
11147
- * parser rejects any callee not in it, and the evaluator gates each call on an
11148
- * own-property check against it.
11149
- *
11150
- * Because the object has a NULL prototype AND is `Object.freeze`d:
11151
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
11152
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
11153
- * (there is no `Object.prototype` in the chain), so those names are not
11154
- * callable — they are simply "unknown function" at parse time.
11155
- *
11156
- * Every numeric argument is validated as a finite number and every numeric
11157
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
11158
- * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
11159
- * closed rather than emitting a garbage value.
11160
- */
11161
- function asFiniteNumber(value, name, index) {
11162
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
11163
- return value;
11164
- }
11165
- function asString$1(value, name, index) {
11166
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
11167
- return value;
11168
- }
11169
- function finiteResult(value, name) {
11170
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
11171
- return value;
11172
- }
11173
- function allFiniteNumbers(args, name) {
11174
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
11175
- }
11176
- var INF = Number.POSITIVE_INFINITY;
11177
- var table = {
11178
- min: {
11179
- minArgs: 1,
11180
- maxArgs: INF,
11181
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
11182
- },
11183
- max: {
11184
- minArgs: 1,
11185
- maxArgs: INF,
11186
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
11187
- },
11188
- abs: {
11189
- minArgs: 1,
11190
- maxArgs: 1,
11191
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
11192
- },
11193
- floor: {
11194
- minArgs: 1,
11195
- maxArgs: 1,
11196
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
11197
- },
11198
- ceil: {
11199
- minArgs: 1,
11200
- maxArgs: 1,
11201
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
11202
- },
11203
- sqrt: {
11204
- minArgs: 1,
11205
- maxArgs: 1,
11206
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
11207
- },
11208
- round: {
11209
- minArgs: 1,
11210
- maxArgs: 2,
11211
- apply: (args) => {
11212
- const x = asFiniteNumber(args[0], "round", 0);
11213
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
11214
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
11215
- const factor = 10 ** digits;
11216
- return finiteResult(Math.round(x * factor) / factor, "round");
11217
- }
11218
- },
11219
- pow: {
11220
- minArgs: 2,
11221
- maxArgs: 2,
11222
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
11223
- },
11224
- clamp: {
11225
- minArgs: 3,
11226
- maxArgs: 3,
11227
- apply: (args) => {
11228
- const x = asFiniteNumber(args[0], "clamp", 0);
11229
- const lo = asFiniteNumber(args[1], "clamp", 1);
11230
- const hi = asFiniteNumber(args[2], "clamp", 2);
11231
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
11232
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
11233
- }
11234
- },
11235
- avg: {
11236
- minArgs: 1,
11237
- maxArgs: INF,
11238
- apply: (args) => {
11239
- const nums = allFiniteNumbers(args, "avg");
11240
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
11241
- }
11242
- },
11243
- sum: {
11244
- minArgs: 1,
11245
- maxArgs: INF,
11246
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
11247
- },
11248
- coalesce: {
11249
- minArgs: 1,
11250
- maxArgs: INF,
11251
- apply: (args) => {
11252
- for (const a of args) if (a !== null) return a;
11253
- return null;
11254
- }
11255
- },
11256
- age: {
11257
- minArgs: 2,
11258
- maxArgs: 2,
11259
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
11260
- },
11261
- convert: {
11262
- minArgs: 3,
11263
- maxArgs: 3,
11264
- apply: (args, hooks) => {
11265
- const x = asFiniteNumber(args[0], "convert", 0);
11266
- const from = asString$1(args[1], "convert", 1).trim();
11267
- const to = asString$1(args[2], "convert", 2).trim();
11268
- if (hooks.convert) {
11269
- const out = hooks.convert(x, from, to);
11270
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
11271
- return finiteResult(out, "convert");
11272
- }
11273
- if (from === to) return x;
11274
- throw new ExpressionEvalError("convert: unit conversion table not installed");
11275
- }
11276
- }
11277
- };
11278
- Object.freeze(Object.assign(Object.create(null), table));
11279
- /** The set of valid builtin names — used by the parser to reject unknown
11280
- * callees at parse time (immediate author feedback). */
11281
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
11282
- /**
11283
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
11284
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
11285
- * single/double-quoted strings with a tiny escape set, identifiers, the three
11286
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
11287
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
11288
- * is a parse error with a source position, so member access / assignment /
11289
- * template literals are lexically impossible.
11290
- */
11291
- var KEYWORDS = new Set([
11292
- "true",
11293
- "false",
11294
- "null"
11295
- ]);
11296
- function isDigit(ch) {
11297
- return ch >= "0" && ch <= "9";
11298
- }
11299
- function isIdentStart(ch) {
11300
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
11301
- }
11302
- function isIdentPart(ch) {
11303
- return isIdentStart(ch) || isDigit(ch);
11304
- }
11305
- function isWhitespace(ch) {
11306
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
11307
- }
11308
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
11309
- * Throws `ExpressionParseError` on any illegal character or unterminated
11310
- * string. */
11311
- function tokenize(source) {
11312
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
11313
- const tokens = [];
11314
- let i = 0;
11315
- const n = source.length;
11316
- while (i < n) {
11317
- const ch = source[i];
11318
- if (isWhitespace(ch)) {
11319
- i += 1;
11320
- continue;
11321
- }
11322
- if (isDigit(ch)) {
11323
- const start = i;
11324
- while (i < n && isDigit(source[i])) i += 1;
11325
- if (i < n && source[i] === ".") {
11326
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
11327
- i += 1;
11328
- while (i < n && isDigit(source[i])) i += 1;
11329
- }
11330
- const text = source.slice(start, i);
11331
- const value = Number(text);
11332
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
11333
- tokens.push({
11334
- type: "number",
11335
- value,
11336
- pos: start
11337
- });
11338
- continue;
11339
- }
11340
- if (ch === "'" || ch === "\"") {
11341
- const quote = ch;
11342
- const start = i;
11343
- i += 1;
11344
- let out = "";
11345
- let closed = false;
11346
- while (i < n) {
11347
- const c = source[i];
11348
- if (c === "\\") {
11349
- const next = i + 1 < n ? source[i + 1] : "";
11350
- if (next === "\\" || next === "'" || next === "\"") {
11351
- out += next;
11352
- i += 2;
11353
- continue;
11354
- }
11355
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
11356
- }
11357
- if (c === quote) {
11358
- closed = true;
11359
- i += 1;
11360
- break;
11361
- }
11362
- out += c;
11363
- i += 1;
11364
- }
11365
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
11366
- tokens.push({
11367
- type: "string",
11368
- value: out,
11369
- pos: start
11370
- });
11371
- continue;
11372
- }
11373
- if (isIdentStart(ch)) {
11374
- const start = i;
11375
- while (i < n && isIdentPart(source[i])) i += 1;
11376
- const text = source.slice(start, i);
11377
- if (KEYWORDS.has(text)) tokens.push({
11378
- type: "keyword",
11379
- keyword: keywordOf(text),
11380
- pos: start
11381
- });
11382
- else tokens.push({
11383
- type: "identifier",
11384
- name: text,
11385
- pos: start
11386
- });
11387
- continue;
11388
- }
11389
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
11390
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
11391
- tokens.push({
11392
- type: "punct",
11393
- punct: two,
11394
- pos: i
11395
- });
11396
- i += 2;
11397
- continue;
11398
- }
11399
- if (isSinglePunct(ch)) {
11400
- tokens.push({
11401
- type: "punct",
11402
- punct: ch,
11403
- pos: i
11404
- });
11405
- i += 1;
11406
- continue;
11407
- }
11408
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
11409
- }
11410
- tokens.push({
11411
- type: "eof",
11412
- pos: n
11413
- });
11414
- return tokens;
11415
- }
11416
- function keywordOf(text) {
11417
- if (text === "true") return "true";
11418
- if (text === "false") return "false";
11419
- return "null";
11420
- }
11421
- function isSinglePunct(ch) {
11422
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
11423
- }
11424
- /**
11425
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
11426
- *
11427
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
11428
- * → relational → additive → multiplicative → unary `! -` → call / primary.
11429
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
11430
- * string validated against the builtin table at parse time, so an unknown
11431
- * function is rejected immediately (author feedback) and a persisted expression
11432
- * that references a since-removed builtin degrades at read.
11433
- *
11434
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
11435
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
11436
- */
11437
- /** Binary/logical operator precedence (higher binds tighter). */
11438
- var BINARY_PRECEDENCE = {
11439
- "||": 1,
11440
- "&&": 2,
11441
- "==": 3,
11442
- "!=": 3,
11443
- "<": 4,
11444
- "<=": 4,
11445
- ">": 4,
11446
- ">=": 4,
11447
- "+": 5,
11448
- "-": 5,
11449
- "*": 6,
11450
- "/": 6,
11451
- "%": 6
11452
- };
11453
- function isLogicalOp(op) {
11454
- return op === "&&" || op === "||";
11455
- }
11456
- function isBinaryOp(op) {
11457
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
11458
- }
11459
- var Parser = class {
11460
- tokens;
11461
- pos = 0;
11462
- nodeCount = 0;
11463
- identifiers = /* @__PURE__ */ new Set();
11464
- callees = /* @__PURE__ */ new Set();
11465
- constructor(tokens) {
11466
- this.tokens = tokens;
11467
- }
11468
- parse() {
11469
- const ast = this.parseTernary();
11470
- const tok = this.peek();
11471
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
11472
- return {
11473
- ast,
11474
- identifiers: this.identifiers,
11475
- callees: this.callees,
11476
- nodeCount: this.nodeCount
11477
- };
11478
- }
11479
- peek() {
11480
- return this.tokens[this.pos];
11481
- }
11482
- next() {
11483
- return this.tokens[this.pos++];
11484
- }
11485
- /** Consume a punctuator token, erroring if the next token isn't it. */
11486
- expectPunct(punct) {
11487
- const tok = this.peek();
11488
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
11489
- this.pos += 1;
11490
- }
11491
- matchPunct(punct) {
11492
- const tok = this.peek();
11493
- if (tok.type === "punct" && tok.punct === punct) {
11494
- this.pos += 1;
11495
- return true;
11496
- }
11497
- return false;
11498
- }
11499
- countNode() {
11500
- this.nodeCount += 1;
11501
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
11502
- }
11503
- parseTernary() {
11504
- const test = this.parseBinary(1);
11505
- if (this.matchPunct("?")) {
11506
- const consequent = this.parseTernary();
11507
- this.expectPunct(":");
11508
- const alternate = this.parseTernary();
11509
- this.countNode();
11510
- return {
11511
- kind: "conditional",
11512
- test,
11513
- consequent,
11514
- alternate
11515
- };
11516
- }
11517
- return test;
11518
- }
11519
- parseBinary(minPrec) {
11520
- let left = this.parseUnary();
11521
- for (;;) {
11522
- const tok = this.peek();
11523
- if (tok.type !== "punct") break;
11524
- const prec = BINARY_PRECEDENCE[tok.punct];
11525
- if (prec === void 0 || prec < minPrec) break;
11526
- const op = tok.punct;
11527
- this.pos += 1;
11528
- const right = this.parseBinary(prec + 1);
11529
- this.countNode();
11530
- if (isLogicalOp(op)) left = {
11531
- kind: "logical",
11532
- op,
11533
- left,
11534
- right
11535
- };
11536
- else if (isBinaryOp(op)) left = {
11537
- kind: "binary",
11538
- op,
11539
- left,
11540
- right
11541
- };
11542
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
11543
- }
11544
- return left;
11545
- }
11546
- parseUnary() {
11547
- const tok = this.peek();
11548
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
11549
- const op = tok.punct;
11550
- this.pos += 1;
11551
- const operand = this.parseUnary();
11552
- this.countNode();
11553
- return {
11554
- kind: "unary",
11555
- op,
11556
- operand
11557
- };
11558
- }
11559
- return this.parsePrimary();
11560
- }
11561
- parsePrimary() {
11562
- const tok = this.next();
11563
- switch (tok.type) {
11564
- case "number":
11565
- this.countNode();
11566
- return {
11567
- kind: "literal",
11568
- value: tok.value
11569
- };
11570
- case "string":
11571
- this.countNode();
11572
- return {
11573
- kind: "literal",
11574
- value: tok.value
11575
- };
11576
- case "keyword":
11577
- this.countNode();
11578
- return {
11579
- kind: "literal",
11580
- value: tok.keyword === "null" ? null : tok.keyword === "true"
11581
- };
11582
- case "identifier": {
11583
- const nextTok = this.peek();
11584
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
11585
- this.identifiers.add(tok.name);
11586
- this.countNode();
11587
- return {
11588
- kind: "identifier",
11589
- name: tok.name
11590
- };
11591
- }
11592
- case "punct":
11593
- if (tok.punct === "(") {
11594
- const inner = this.parseTernary();
11595
- this.expectPunct(")");
11596
- return inner;
11597
- }
11598
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
11599
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
11600
- }
11601
- }
11602
- parseCall(callee, pos) {
11603
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
11604
- this.expectPunct("(");
11605
- const args = [];
11606
- if (!this.matchPunct(")")) for (;;) {
11607
- args.push(this.parseTernary());
11608
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
11609
- if (this.matchPunct(",")) continue;
11610
- this.expectPunct(")");
11611
- break;
11612
- }
11613
- this.callees.add(callee);
11614
- this.countNode();
11615
- return {
11616
- kind: "call",
11617
- callee,
11618
- args
11619
- };
11620
- }
11621
- };
11622
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
11623
- * `ExpressionParseError` on any lexical or grammatical failure. */
11624
- function parseExpression(source) {
11625
- return new Parser(tokenize(source)).parse();
11626
- }
11850
+ });
11627
11851
  /**
11628
- * LRU compile cache for parsed expressions (spec §2.4 "parse once LRU keyed
11629
- * by expr"). The cache stores BOTH successes and failures (negative caching),
11630
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
11631
- * one per read on a hot resolve path.
11852
+ * Candidate-list TEXT/query filter. Mirrors the prior `ha-discovery` filter,
11853
+ * de-HA-ified `area` / `manufacturer` stay free-form strings so any
11854
+ * integration can populate them from its own metadata. Distinct from the
11855
+ * granularity `AdoptionFilter` above: this narrows the candidate set within a
11856
+ * chosen granularity, whereas the granularity `filter` selects WHAT a
11857
+ * candidate is (a device vs an entity).
11858
+ */
11859
+ var CandidateQueryFilterSchema = object({
11860
+ /** Substring filter on name + manufacturer + model. */
11861
+ search: string().optional(),
11862
+ /** Area-name exact match. */
11863
+ area: string().optional(),
11864
+ /** Manufacturer exact match. */
11865
+ manufacturer: string().optional(),
11866
+ /** When true, only return candidates the operator already adopted. */
11867
+ adoptedOnly: boolean().optional(),
11868
+ /** When true, only return candidates the operator hasn't adopted yet. */
11869
+ unadoptedOnly: boolean().optional()
11870
+ });
11871
+ var ListCandidatesInputSchema = object({
11872
+ integrationId: string(),
11873
+ page: number().int().positive().default(1),
11874
+ pageSize: number().int().positive().max(2e4).default(50),
11875
+ /**
11876
+ * Optional provider-declared discovery GRANULARITY id (opaque; see
11877
+ * `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
11878
+ * exactly the pre-existing behavior (fully back-compatible).
11879
+ */
11880
+ filter: string().optional(),
11881
+ /** Optional candidate-list text/query narrowing within the granularity. */
11882
+ filterText: CandidateQueryFilterSchema.optional()
11883
+ });
11884
+ var ListCandidatesOutputSchema = object({
11885
+ candidates: array(DiscoveredChildDeviceSchema).readonly(),
11886
+ totalCount: number().int().nonnegative(),
11887
+ page: number().int().positive(),
11888
+ pageSize: number().int().positive()
11889
+ });
11890
+ var GetCandidateInputSchema = object({
11891
+ integrationId: string(),
11892
+ childNativeId: string()
11893
+ });
11894
+ var AdoptionStatusSchema = object({
11895
+ /** Last refresh timestamp (ms epoch) — null when never refreshed. */
11896
+ lastDiscoveryAt: number().int().nonnegative().nullable(),
11897
+ /** Count of candidates in the discovery cache. */
11898
+ candidateCount: number().int().nonnegative(),
11899
+ /** Count of candidates the operator has already adopted. */
11900
+ adoptedCount: number().int().nonnegative(),
11901
+ /** Last error message from a refresh attempt. */
11902
+ lastError: string().nullable()
11903
+ });
11904
+ var PerCandidateSchema = object({
11905
+ /** Override the default display name for this candidate's parent. */
11906
+ name: string().min(1).optional(),
11907
+ /** Pre-hide a subset of entity-children — created (state still flows)
11908
+ * but listed in the parent's `accessories.hiddenChildIds`. */
11909
+ hiddenChildIds: array(string()).optional()
11910
+ });
11911
+ var AdoptInputSchema = object({
11912
+ integrationId: string(),
11913
+ /**
11914
+ * Candidate native ids to adopt. Their MEANING is filter-relative: under the
11915
+ * default `'devices'` granularity these are device-native ids; under a
11916
+ * provider-declared granularity (e.g. HA `'entities'`) they are that
11917
+ * granularity's native ids (e.g. entity ids). The field name is kept stable
11918
+ * to avoid a breaking rename.
11919
+ */
11920
+ childNativeIds: array(string()).min(1),
11921
+ /**
11922
+ * Optional provider-declared discovery GRANULARITY id (opaque; see
11923
+ * `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
11924
+ * exactly the pre-existing behavior (fully back-compatible).
11925
+ */
11926
+ filter: string().optional(),
11927
+ /** When true, import each adopted device's source-system location (e.g. HA
11928
+ * area) into CamStack — fuzzy-match an existing location or create it, then
11929
+ * assign. Omitted/false = no location work (back-compat). */
11930
+ importLocations: boolean().optional(),
11931
+ perCandidate: record(string(), PerCandidateSchema).optional()
11932
+ });
11933
+ var AdoptResultSchema = object({ adopted: array(object({
11934
+ childNativeId: string(),
11935
+ parentDeviceId: number().int().nonnegative(),
11936
+ accessoryDeviceIds: array(number().int().nonnegative()).readonly()
11937
+ })).readonly() });
11938
+ var ReleaseInputSchema = object({
11939
+ integrationId: string(),
11940
+ /** Parent CamStack device id (NOT an accessory child id). Removing
11941
+ * the parent cascades into every accessory. */
11942
+ camDeviceId: number().int().nonnegative()
11943
+ });
11944
+ var ResyncInputSchema = object({
11945
+ /** Parent CamStack device id of an adopted device. The provider resolves its
11946
+ * source (integration/broker + native id) and re-aligns the device's
11947
+ * structural spec (type/role/capabilities/units) with the live mapping,
11948
+ * rebuilding any child whose class changed while preserving operator edits. */
11949
+ camDeviceId: number().int().nonnegative(),
11950
+ /** "Resync from zero" (#19). When true, the kernel PURGES every accessory
11951
+ * child of `camDeviceId` BEFORE the provider re-derives the device, so the
11952
+ * children are rebuilt fresh from source — correct names, coords, and units —
11953
+ * instead of being preserved by the incremental reconcile. Use to recover from
11954
+ * legacy generic/placeholder names that the normal name-precedence keeps frozen
11955
+ * (the operator's explicit reset). Push-driven integrations (no-op resync)
11956
+ * rebuild on their next snapshot; pull/command integrations rebuild in `resync`.
11957
+ * Operator edits on the PARENT (its name, layout, primary-child pick) survive —
11958
+ * only the children are torn down. Omitted/false ⇒ the normal incremental
11959
+ * re-sync that preserves children. */
11960
+ resetToSource: boolean().optional()
11961
+ });
11962
+ var ResyncResultSchema = object({
11963
+ /** True when the persisted spec actually changed (children may have been rebuilt). */
11964
+ changed: boolean(),
11965
+ /** Number of child devices rebuilt into a new class by this re-sync. */
11966
+ rebuiltChildren: number().int().nonnegative(),
11967
+ /** Number of accessory children torn down by a `resetToSource` purge before the
11968
+ * provider re-derived the device. 0/absent for a normal incremental re-sync. */
11969
+ removedChildren: number().int().nonnegative().optional()
11970
+ });
11971
+ 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, {
11972
+ kind: "mutation",
11973
+ auth: "admin"
11974
+ }), method(AdoptInputSchema, AdoptResultSchema, {
11975
+ kind: "mutation",
11976
+ auth: "admin"
11977
+ }), method(ReleaseInputSchema, _void(), {
11978
+ kind: "mutation",
11979
+ auth: "admin"
11980
+ }), method(ResyncInputSchema, ResyncResultSchema, {
11981
+ kind: "mutation",
11982
+ auth: "admin"
11983
+ });
11984
+ /**
11985
+ * `device-export` — collection cap for addons that export camstack
11986
+ * devices to external ecosystems (HomeAssistant via MQTT discovery,
11987
+ * HomeKit/HAP, Alexa Smart Home, …).
11632
11988
  *
11633
- * The cache is a module-level singleton: entries are pure, content-addressed
11634
- * ASTs keyed by the raw source string, so sharing one instance across all
11635
- * callers is safe and maximises hit rate.
11989
+ * No `ecosystem` enum the addon id identifies the export target.
11990
+ * Each addon owns its mapping logic in its own settings UI. The cap
11991
+ * exposes only the COMMON surface: link state, supported device
11992
+ * kinds (so the UI can filter the device picker), and per-device
11993
+ * expose/unexpose.
11636
11994
  */
11637
- var cache = /* @__PURE__ */ new Map();
11638
- function getCached(source) {
11639
- const hit = cache.get(source);
11640
- if (hit !== void 0) {
11641
- cache.delete(source);
11642
- cache.set(source, hit);
11643
- return hit;
11644
- }
11645
- let result;
11646
- try {
11647
- result = {
11648
- ok: true,
11649
- parsed: parseExpression(source)
11650
- };
11651
- } catch (err) {
11652
- result = {
11653
- ok: false,
11654
- error: err instanceof ExpressionParseError ? err.message : String(err)
11655
- };
11656
- }
11657
- cache.set(source, result);
11658
- if (cache.size > 256) {
11659
- const oldest = cache.keys().next().value;
11660
- if (oldest !== void 0) cache.delete(oldest);
11661
- }
11662
- return result;
11663
- }
11664
- /** Compile `source`, returning a discriminated result instead of throwing.
11665
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
11666
- function compileExpressionSafe(source) {
11667
- return getCached(source);
11668
- }
11669
- Object.freeze({});
11995
+ var LinkStateSchema = _enum([
11996
+ "unlinked",
11997
+ "linked",
11998
+ "error"
11999
+ ]);
11670
12000
  /**
11671
- * Author-time validation. Returns `null` when the source is valid, else a
11672
- * human-readable error message. Checks: the expression compiles; binding count
11673
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
11674
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
11675
- * FREE identifier of the AST is covered by a binding or the injected `now`.
12001
+ * A single label/value row surfaced in the export panel's "Setup"
12002
+ * section. Rendered with a copy-to-clipboard button. `secret: true`
12003
+ * rows are masked by default with a reveal toggle (client ids,
12004
+ * secrets, ).
11676
12005
  */
11677
- function validateExpressionSource(src) {
11678
- const names = Object.keys(src.bindings);
11679
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
11680
- for (const name of names) {
11681
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
11682
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
11683
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
11684
- }
11685
- const compiled = compileExpressionSafe(src.expr);
11686
- if (!compiled.ok) return compiled.error;
11687
- const bound = new Set(names);
11688
- for (const id of compiled.parsed.identifiers) {
11689
- if (id === "now") continue;
11690
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
12006
+ var ExportSetupFieldSchema = object({
12007
+ label: string(),
12008
+ value: string(),
12009
+ /** Mask the value by default + render a reveal toggle (client id, secrets). */
12010
+ secret: boolean().optional()
12011
+ });
12012
+ /**
12013
+ * Generic, addon-agnostic pairing/account block. Any export addon can
12014
+ * surface a scannable QR, a set of copyable label/value rows, and a
12015
+ * free-form operator note — the `DeviceExportPanel` renders whatever
12016
+ * the provider supplies and skips the section entirely when `setup`
12017
+ * is unset.
12018
+ */
12019
+ var ExportSetupSchema = object({
12020
+ /** A string to render as a scannable QR — HAP `X-HM://…` URI, a pairing URL, etc. Omitted when there's nothing to scan. */
12021
+ qr: string().optional(),
12022
+ /** Label/value rows shown with a copy button (HAP setup code, OAuth URLs, client id, linked-account count, …). */
12023
+ fields: array(ExportSetupFieldSchema).readonly().optional(),
12024
+ /** Free-form operator instructions rendered above the fields. */
12025
+ note: string().optional()
12026
+ });
12027
+ var DeviceExportStatusSchema = object({
12028
+ linkState: LinkStateSchema,
12029
+ exposedDeviceCount: number(),
12030
+ error: string().optional(),
12031
+ /**
12032
+ * Optional pairing/account info the panel renders in a generic
12033
+ * "Setup" section. Addon-agnostic — the addon id identifies the
12034
+ * export target, never an `ecosystem` key here.
12035
+ */
12036
+ setup: ExportSetupSchema.optional()
12037
+ });
12038
+ var DeviceKindSchema = string();
12039
+ var ExposedDeviceSchema = object({
12040
+ deviceId: string(),
12041
+ exposedAs: string().optional(),
12042
+ capabilities: array(string()).optional()
12043
+ });
12044
+ var ExposeInputSchema = object({
12045
+ deviceId: string(),
12046
+ capabilities: array(string()).optional()
12047
+ });
12048
+ var UnexposeInputSchema = object({ deviceId: string() });
12049
+ var deviceExportCapability = {
12050
+ name: "device-export",
12051
+ scope: "system",
12052
+ mode: "collection",
12053
+ providerKind: "device-export",
12054
+ status: {
12055
+ schema: DeviceExportStatusSchema,
12056
+ kind: "poll"
12057
+ },
12058
+ /**
12059
+ * Each export provider contributes its own per-device "Export" panel
12060
+ * (enabled toggle, preferred stream, addon-specific knobs like the
12061
+ * HA-MQTT discovery prefix). The three framework methods
12062
+ * `getDeviceSettingsContribution` / `getDeviceLiveContribution` /
12063
+ * `applyDeviceSettingsPatch` are auto-injected on the provider
12064
+ * interface and routed to the device-details aggregator.
12065
+ */
12066
+ exposesDeviceSettings: true,
12067
+ methods: {
12068
+ getStatus: method(_void(), DeviceExportStatusSchema),
12069
+ listSupportedDeviceKinds: method(_void(), array(DeviceKindSchema)),
12070
+ listExposedDevices: method(_void(), array(ExposedDeviceSchema)),
12071
+ exposeDevice: method(ExposeInputSchema, _void(), { kind: "mutation" }),
12072
+ unexposeDevice: method(UnexposeInputSchema, _void(), { kind: "mutation" })
11691
12073
  }
11692
- return null;
11693
- }
12074
+ };
11694
12075
  var ProviderStatusSchema = object({
11695
12076
  connected: boolean(),
11696
12077
  deviceCount: number(),
@@ -11815,92 +12196,6 @@ var ChildLayoutEntrySchema = object({
11815
12196
  order: number().optional(),
11816
12197
  collapsed: boolean().optional()
11817
12198
  });
11818
- /** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
11819
- * `device-management.ts`. Source is a union: a FIELD source copies a sibling
11820
- * accessory's status field (`kind` optional/absent for wire compat); a
11821
- * LITERAL source carries a per-device constant (no sibling is read); a
11822
- * GLOBAL source (P2e) copies ANY device's status field, addressed by the
11823
- * source device's full re-sync-stable `stableId`. */
11824
- var DeviceLinkFieldSourceSchema = object({
11825
- kind: literal("field").optional(),
11826
- sourceKey: string(),
11827
- cap: string(),
11828
- fieldPath: string()
11829
- });
11830
- var DeviceLinkLiteralSourceSchema = object({
11831
- kind: literal("literal"),
11832
- value: union([
11833
- string(),
11834
- number(),
11835
- boolean(),
11836
- _null()
11837
- ])
11838
- });
11839
- var DeviceLinkGlobalSourceSchema = object({
11840
- kind: literal("global"),
11841
- sourceStableId: string(),
11842
- cap: string(),
11843
- fieldPath: string()
11844
- });
11845
- /** Expression source (Stage X): compute the target field from N named bindings
11846
- * via the safe expression engine. Bindings are field | literal | global — never
11847
- * another expression (no nesting). The `superRefine` runs the SAME author-time
11848
- * validation as `validateExpressionSource` (compiles the expr, checks binding
11849
- * names + identifier coverage) so every wire boundary that parses a DeviceLink
11850
- * (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
11851
- * Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
11852
- var DeviceLinkExpressionSourceSchema = object({
11853
- kind: literal("expression"),
11854
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
11855
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), union([
11856
- DeviceLinkFieldSourceSchema,
11857
- DeviceLinkLiteralSourceSchema,
11858
- DeviceLinkGlobalSourceSchema
11859
- ]))
11860
- }).superRefine((src, ctx) => {
11861
- const err = validateExpressionSource(src);
11862
- if (err !== null) ctx.addIssue({
11863
- code: "custom",
11864
- message: err,
11865
- path: ["expr"]
11866
- });
11867
- });
11868
- var DeviceLinkSchema = object({
11869
- id: string(),
11870
- source: union([
11871
- DeviceLinkFieldSourceSchema,
11872
- DeviceLinkLiteralSourceSchema,
11873
- DeviceLinkGlobalSourceSchema,
11874
- DeviceLinkExpressionSourceSchema
11875
- ]),
11876
- target: object({
11877
- cap: string(),
11878
- fieldPath: string(),
11879
- itemKey: string().optional()
11880
- }),
11881
- transform: discriminatedUnion("kind", [
11882
- object({ kind: literal("identity") }),
11883
- object({
11884
- kind: literal("enum-map"),
11885
- mapping: record(string(), union([
11886
- string(),
11887
- number(),
11888
- boolean()
11889
- ])),
11890
- fallback: union([
11891
- string(),
11892
- number(),
11893
- boolean()
11894
- ]).optional()
11895
- }),
11896
- object({
11897
- kind: literal("linear"),
11898
- scale: number(),
11899
- offset: number(),
11900
- clamp: tuple([number(), number()]).readonly().optional()
11901
- })
11902
- ]).optional()
11903
- });
11904
12199
  /** Cap-wire shape of a per-cap display refinement — mirrors
11905
12200
  * `DeviceCapDisplayOverride` in `device-management.ts`. */
11906
12201
  var DeviceCapDisplayOverrideSchema = object({
@@ -11980,8 +12275,6 @@ var DeviceInfoSchema = object({
11980
12275
  * named accordion sections (with optional intra-section order). See
11981
12276
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
11982
12277
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
11983
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
11984
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
11985
12278
  /** Operator-authored per-device display override. See `DeviceMeta.display`. */
11986
12279
  display: DeviceDisplayOverrideSchema.optional()
11987
12280
  });
@@ -11990,7 +12283,7 @@ var ConfigEntrySchema = object({
11990
12283
  value: unknown(),
11991
12284
  description: string().optional()
11992
12285
  });
11993
- var DeviceLinkModeSchema = _enum(["auto", "manual"]);
12286
+ var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
11994
12287
  /** One resolved linked device — the compact projection consumers need. */
11995
12288
  var LinkedDeviceSchema = object({
11996
12289
  deviceId: number(),
@@ -12053,8 +12346,6 @@ var DeviceMetaSchema = object({
12053
12346
  * accordion sections (with optional intra-section order). See
12054
12347
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
12055
12348
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
12056
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
12057
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
12058
12349
  /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
12059
12350
  * Optional: only present for accessory children that carry a known role. */
12060
12351
  role: string().nullable().optional(),
@@ -12147,12 +12438,6 @@ method(object({
12147
12438
  }), _void(), {
12148
12439
  kind: "mutation",
12149
12440
  auth: "admin"
12150
- }), method(object({
12151
- deviceId: number(),
12152
- deviceLinks: array(DeviceLinkSchema).readonly()
12153
- }), _void(), {
12154
- kind: "mutation",
12155
- auth: "admin"
12156
12441
  }), method(object({
12157
12442
  deviceId: number(),
12158
12443
  display: DeviceDisplayOverrideSchema.nullable()
@@ -12234,7 +12519,7 @@ method(object({
12234
12519
  * shipping 293 rows to find 12. */
12235
12520
  isCamera: boolean().optional()
12236
12521
  }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
12237
- mode: DeviceLinkModeSchema,
12522
+ mode: LinkedDevicesModeSchema,
12238
12523
  devices: array(LinkedDeviceSchema)
12239
12524
  })), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
12240
12525
  deviceId: number(),
@@ -12267,11 +12552,7 @@ method(object({
12267
12552
  deviceId: number(),
12268
12553
  entries: array(object({
12269
12554
  capName: string(),
12270
- kind: _enum([
12271
- "native",
12272
- "wrapped",
12273
- "linked"
12274
- ]),
12555
+ kind: _enum(["native", "wrapped"]),
12275
12556
  providerAddonId: string(),
12276
12557
  providerNodeId: string(),
12277
12558
  nativeAddonId: string()
@@ -12280,11 +12561,7 @@ method(object({
12280
12561
  deviceId: number(),
12281
12562
  entries: array(object({
12282
12563
  capName: string(),
12283
- kind: _enum([
12284
- "native",
12285
- "wrapped",
12286
- "linked"
12287
- ]),
12564
+ kind: _enum(["native", "wrapped"]),
12288
12565
  providerAddonId: string(),
12289
12566
  providerNodeId: string(),
12290
12567
  nativeAddonId: string()
@@ -12385,6 +12662,15 @@ method(object({
12385
12662
  }), method(ReleaseInputSchema.extend({ addonId: string() }), _void(), {
12386
12663
  kind: "mutation",
12387
12664
  auth: "admin"
12665
+ }), method(AdoptInputSchema.extend({ addonId: string() }), object({ jobId: string() }), {
12666
+ kind: "mutation",
12667
+ auth: "admin"
12668
+ }), method(object({
12669
+ addonId: string(),
12670
+ integrationId: string().optional()
12671
+ }), array(AdoptionJobSchema).readonly(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
12672
+ kind: "mutation",
12673
+ auth: "admin"
12388
12674
  }), method(ResyncInputSchema, ResyncResultSchema, {
12389
12675
  kind: "mutation",
12390
12676
  auth: "admin"
@@ -13087,7 +13373,7 @@ var MotionAnalysisResultSchema = object({
13087
13373
  frameHeight: number(),
13088
13374
  analysisMs: number()
13089
13375
  });
13090
- method(object({
13376
+ DeviceType.Camera, method(object({
13091
13377
  deviceId: number(),
13092
13378
  frame: FrameInputSchema.optional(),
13093
13379
  frameHandle: FrameHandleSchema.optional()
@@ -13107,7 +13393,7 @@ method(object({
13107
13393
  * Why: pub/sub routing over the system event-bus loses fidelity
13108
13394
  * (callback shape, QoS guarantees, will/retain semantics) and adds
13109
13395
  * refcount bookkeeping that addons would rather own themselves. The
13110
- * canonical consumer (`addon-export-ha-mqtt`) needs raw `mqtt.js`
13396
+ * canonical consumer needs raw `mqtt.js`
13111
13397
  * features anyway — give it the connection config, get out of the way.
13112
13398
  *
13113
13399
  * Consumer flow:
@@ -14272,8 +14558,35 @@ var NcDeliverySchema = _enum([
14272
14558
  "immediate",
14273
14559
  "track-end",
14274
14560
  "device-event",
14275
- "package-event"
14561
+ "package-event",
14562
+ "system-event"
14276
14563
  ]);
14564
+ /**
14565
+ * Stable Notification Center vocabulary over infrastructure/liveness events.
14566
+ * Bus categories are normalized into these intent-level kinds so rules do not
14567
+ * depend on a provider's raw event name or payload shape.
14568
+ */
14569
+ var NcSystemEventKindSchema = _enum([
14570
+ "camera-online",
14571
+ "camera-offline",
14572
+ "stream-online",
14573
+ "stream-offline",
14574
+ "node-online",
14575
+ "node-offline",
14576
+ "addon-update-available",
14577
+ "server-update-available"
14578
+ ]);
14579
+ /**
14580
+ * One coherent system-event condition. `kinds` is the required opt-in safety
14581
+ * gate; the remaining lists are optional narrowing filters relevant to the
14582
+ * selected kinds.
14583
+ */
14584
+ var NcSystemEventConditionSchema = object({
14585
+ kinds: array(NcSystemEventKindSchema).min(1),
14586
+ deviceIds: array(number().int()).min(1).optional(),
14587
+ nodeIds: array(string().min(1)).min(1).optional(),
14588
+ packageNames: array(string().min(1)).min(1).optional()
14589
+ });
14277
14590
  /** Weekly schedule — OR of windows; absence on the rule = always active. */
14278
14591
  var NcScheduleSchema = object({
14279
14592
  windows: array(object({
@@ -14578,6 +14891,8 @@ var NcConditionsSchema = object({
14578
14891
  "picked-up",
14579
14892
  "both"
14580
14893
  ]).optional(),
14894
+ /** Infrastructure/liveness/update event matcher (`system-event` delivery). */
14895
+ systemEvent: NcSystemEventConditionSchema.optional(),
14581
14896
  /**
14582
14897
  * PERSONAL-RULE custom zones (viewer-drawn). Inline normalized polygons
14583
14898
  * (MaskShape vocabulary). A record passes when its bbox overlaps ANY
@@ -14797,7 +15112,8 @@ var NcTestResultSchema = object({
14797
15112
  "object-event",
14798
15113
  "track",
14799
15114
  "device-event",
14800
- "package-event"
15115
+ "package-event",
15116
+ "system-event"
14801
15117
  ]),
14802
15118
  deviceId: number(),
14803
15119
  timestamp: number(),
@@ -14819,7 +15135,8 @@ var NcConditionDescriptorSchema = object({
14819
15135
  "schedule",
14820
15136
  "device",
14821
15137
  "package",
14822
- "occupancy"
15138
+ "occupancy",
15139
+ "system"
14823
15140
  ]),
14824
15141
  label: string(),
14825
15142
  /** Editor widget the UI renders — never hardcode per-condition forms. */
@@ -14837,7 +15154,8 @@ var NcConditionDescriptorSchema = object({
14837
15154
  "crossingSelect",
14838
15155
  "polygonDraw",
14839
15156
  "occupancy",
14840
- "deviceState"
15157
+ "deviceState",
15158
+ "systemEvent"
14841
15159
  ]),
14842
15160
  operator: _enum([
14843
15161
  "in",
@@ -14896,7 +15214,8 @@ var NcHistoryRecordKindSchema = _enum([
14896
15214
  "object-event",
14897
15215
  "track-end",
14898
15216
  "device-event",
14899
- "package-event"
15217
+ "package-event",
15218
+ "system-event"
14900
15219
  ]);
14901
15220
  /** Subject summary frozen on the row at fire time (survives rule/record edits). */
14902
15221
  var NcHistorySubjectSchema = object({
@@ -14904,7 +15223,14 @@ var NcHistorySubjectSchema = object({
14904
15223
  label: string().optional(),
14905
15224
  confidence: number().optional(),
14906
15225
  zones: array(string()),
14907
- timestamp: number()
15226
+ timestamp: number(),
15227
+ systemEvent: object({
15228
+ kind: NcSystemEventKindSchema,
15229
+ subject: string(),
15230
+ title: string(),
15231
+ body: string(),
15232
+ data: record(string(), unknown())
15233
+ }).optional()
14908
15234
  });
14909
15235
  /**
14910
15236
  * One delivery-history row. This is a read-only VIEW over the durable
@@ -15264,6 +15590,76 @@ object({
15264
15590
  * Each provider returns a static descriptor; the core enumerates them
15265
15591
  * to validate the `integration=` query param and resolve the consent
15266
15592
  * label + the scopes baked into the issued token.
15593
+ *
15594
+ * ## Declaring one
15595
+ *
15596
+ * An OAuth client is integration-specific knowledge — who the client is, what
15597
+ * it may ask for, where it may be sent — so it is declared by the ADDON that
15598
+ * owns the integration, never by the kernel and never as a branch inside
15599
+ * `oauth2-routes.ts` ([D101](../../../../docs/decisions/adr-0101.md)). Three
15600
+ * steps, no others:
15601
+ *
15602
+ * 1. Add `{ "name": "oauth-integration" }` to the addon's `camstack.addons[]`
15603
+ * manifest entry. This is also what tells the hub, at addon-LOAD time, that
15604
+ * a descriptor is owed — see "the boot window" below.
15605
+ * 2. Return a provider from `onInitialize()`:
15606
+ *
15607
+ * ```ts
15608
+ * const provider: IOauthIntegrationProvider = {
15609
+ * getDescriptor: async () => ({
15610
+ * integrationId: 'my-thing', // the `integration=` query param
15611
+ * displayName: 'My Thing',
15612
+ * requestedScopes: [ … ], // see below
15613
+ * allowedRedirectPrefixes: ['https://callback.example/'],
15614
+ * }),
15615
+ * }
15616
+ * return [{ capability: oauthIntegrationCapability, provider }]
15617
+ * ```
15618
+ *
15619
+ * The descriptor must be **static** — it is read on the authorize path, so
15620
+ * never put an await on network or disk behind it, and never register it
15621
+ * behind one either (a provider is registered only once `onInitialize`
15622
+ * RETURNS, so anything awaited before the return delays linking).
15623
+ * 3. Nothing else. There is no allow-list to join, no id to register with the
15624
+ * core, and no per-integration branch anywhere: `/api/oauth2/authorize` and
15625
+ * `/api/oauth2/integrations` are built from this collection alone.
15626
+ *
15627
+ * **Scopes. `requestedScopes` has exactly ONE meaning: what the integration
15628
+ * NEEDS to function.** Not a blast radius, not a conservative
15629
+ * under-declaration, not a description of some other path the addon happens to
15630
+ * have. Derive it from what the client actually calls **with this token** —
15631
+ * every tRPC path against `METHOD_ACCESS_MAP`, plus an `addon:` grant for every
15632
+ * addon HTTP route it posts to — and write the call that justifies each entry
15633
+ * next to it. Two integrations once used this field to mean two different
15634
+ * things; the operator ruled there is one meaning, and any third integration
15635
+ * inherits it (2026-08-09).
15636
+ *
15637
+ * This is not documentation, it is the ENFORCEMENT INPUT. Since
15638
+ * [D103](../../../../docs/decisions/adr-0103.md) the `/addon/:addonId/*` gate
15639
+ * checks an integration token's grant before letting it reach an
15640
+ * `access: 'authenticated'` route, so an **under-declaration is an integration
15641
+ * that stops working** — a missing `addon:` entry means `403 Token scope
15642
+ * mismatch` on every control the client tries to actuate. Widen the descriptor
15643
+ * honestly rather than weakening a check to make a route pass.
15644
+ *
15645
+ * Prefer a narrow `capability:` scope to a `category:` one unless the client
15646
+ * genuinely needs a whole family; a category scope grants every future member
15647
+ * of that category too. `category:system [create]` has been rejected once and
15648
+ * should stay rejected: it hands `addons.installPackage` to an integration.
15649
+ *
15650
+ * Calls the ADDON itself makes over `ctx.api` run as the addon and are not
15651
+ * scope-checked, so they are not what this field describes — but reaching the
15652
+ * addon's route in the first place IS, and that is the entry to declare.
15653
+ *
15654
+ * **The boot window.** An addon registers its provider after its runner forks
15655
+ * and initialises, so between hub start and that moment this collection is
15656
+ * incomplete and an `integrationId` can be legitimately absent. The core does
15657
+ * not wait, poll or cache around this ([D3](../../../../docs/decisions/adr-0003.md)):
15658
+ * it compares the manifest declarers against the registered providers and
15659
+ * answers `503 temporarily_unavailable` (with `Retry-After` and the pending
15660
+ * addon ids) instead of `400 unknown integration`, and reports
15661
+ * `complete: false` on `GET /api/oauth2/integrations`. A client should retry
15662
+ * while the list is incomplete rather than conclude the hub cannot do OAuth.
15267
15663
  */
15268
15664
  var OauthIntegrationDescriptorSchema = object({
15269
15665
  /** Stable id used as the `integration=` query param, e.g. 'export-alexa'. */
@@ -15276,13 +15672,48 @@ var OauthIntegrationDescriptorSchema = object({
15276
15672
  * redirect_uri that does not start with one of these. Required —
15277
15673
  * an empty list means the integration can never complete linking. */
15278
15674
  allowedRedirectPrefixes: array(string()).min(1),
15675
+ /** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
15676
+ * RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
15677
+ * `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
15678
+ * whose address the hub cannot know in advance (a Home Assistant at
15679
+ * `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
15680
+ * exactly; a public host never satisfies this branch, so it is not a
15681
+ * wildcard prefix by another name. */
15682
+ allowedPrivateHostPaths: array(string()).optional(),
15683
+ /** When true this is a PUBLIC client (source is published, no secret can be
15684
+ * protected) and PKCE is mandatory: `/authorize` refuses without an S256
15685
+ * `code_challenge`, `/token` refuses without the matching `code_verifier`. */
15686
+ requiresPkce: boolean().optional(),
15279
15687
  /** Optional public origin (no trailing slash) that this integration's
15280
15688
  * issued codes/tokens should carry as the `hubUrl` claim — typically the
15281
15689
  * operator-selected external-access endpoint resolved by the addon. When
15282
15690
  * present, /api/oauth2/authorize bakes THIS into the code instead of the
15283
15691
  * hub-global `publicHubUrl()`, so a forked exporter addon (which can't set
15284
15692
  * the hub's env) drives the claim that its cloud Lambda routes back on. */
15285
- hubUrl: string().optional()
15693
+ hubUrl: string().optional(),
15694
+ /**
15695
+ * How long a REFRESH token issued for this integration lives — seconds, or
15696
+ * `'never'` for a token minted with no `exp` claim at all. Omit to keep the
15697
+ * 30-day default, which is what every link used before this field existed.
15698
+ *
15699
+ * Declared here for the same reason `requestedScopes` is: the integration
15700
+ * knows what it needs. Amazon's account linking and a Home Assistant config
15701
+ * entry are both meant to survive indefinitely, and re-linking is a manual
15702
+ * user action, so a 30-day expiry silently unlinks a working integration.
15703
+ *
15704
+ * **The security posture, stated so it is owned deliberately.** A refresh
15705
+ * token that never expires is permanent access if it leaks. What bounds it is
15706
+ * revocation, not time: `oauthRefresh` re-reads the session on every use and
15707
+ * returns `null` once `revokedAt` is set, as does `oauthVerifyAccessToken`.
15708
+ * The one gap is the ACCESS token — it is a plain signed JWT that nothing
15709
+ * re-checks against the session on the `/trpc` and `/addon/*` paths, so
15710
+ * revoking a link takes effect there only after its remaining hour. That hour
15711
+ * is why the access TTL is not configurable.
15712
+ *
15713
+ * The value is baked into the authorization code at `/authorize` and travels
15714
+ * on the tokens, so editing this field changes FUTURE links only.
15715
+ */
15716
+ refreshTokenTtlSec: union([number().int().positive(), literal("never")]).optional()
15286
15717
  });
15287
15718
  method(_void(), OauthIntegrationDescriptorSchema);
15288
15719
  /**
@@ -15426,7 +15857,7 @@ var TrackEnvelopeSchema = object({
15426
15857
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
15427
15858
  * keeps every scalar the list surfaces actually render (ids, class(es),
15428
15859
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
15429
- * zonesVisited, bestEventId, envelope) and returns `positions` /
15860
+ * zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
15430
15861
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
15431
15862
  * `getTrack`. Mirrors the event-store `projection` convention
15432
15863
  * (`getObjectEvents` et al.).
@@ -15635,8 +16066,26 @@ var TrackSchema = object({
15635
16066
  /** Periodic snapshots at snapshotIntervalMs cadence (subject to
15636
16067
  * saveThumbnails policy). */
15637
16068
  snapshots: array(TrackSnapshotSchema).readonly(),
15638
- /** Deduplicated zones the track has entered at least once. */
16069
+ /** Deduplicated zones the track has entered at least once. Zone IDS. */
15639
16070
  zonesVisited: array(string()).readonly(),
16071
+ /**
16072
+ * Human NAMES for {@link zonesVisited}, resolved at READ time against the
16073
+ * `zones` capability.
16074
+ *
16075
+ * `zonesVisited` persists ids (`cfeec78c-8d69-…`), which no operator can type
16076
+ * and no card can render — so every free-text search surface was structurally
16077
+ * unable to answer "show me the tracks in Uscio", and did not fail loudly, it
16078
+ * just returned nothing. Resolving here rather than in each client keeps ONE
16079
+ * derivation and costs the clients no extra call (the `zones` cap is
16080
+ * per-device, so a client-side resolve would be a per-camera fan-out on a
16081
+ * surface built to avoid exactly that).
16082
+ *
16083
+ * Resolved, never invented: a zone deleted since the track was written has no
16084
+ * name and is DROPPED, so this array can be shorter than `zonesVisited` — the
16085
+ * two are not positionally aligned. Absent when the track visited no zone, or
16086
+ * when the zone catalogue could not be read.
16087
+ */
16088
+ zoneNames: array(string()).readonly().optional(),
15640
16089
  /** Deduplicated set of detector classes observed for this track over its
15641
16090
  * life (a track may be reclassified, e.g. person→vehicle). Absent on
15642
16091
  * legacy rows written before class accumulation shipped. */
@@ -15663,6 +16112,24 @@ var TrackSchema = object({
15663
16112
  * Populated from the persisted envelope columns on historical reads;
15664
16113
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
15665
16114
  envelope: TrackEnvelopeSchema.optional(),
16115
+ /**
16116
+ * A face DETECTOR found a face on this track — nothing more. It says the
16117
+ * detail plane produced a `face` detail; it does NOT say the face was
16118
+ * embedded, matched, above `minFacePx`, or that the recognizer was even
16119
+ * enabled. Set once and never cleared.
16120
+ *
16121
+ * **This exists so "face present but not recognised" is expressible.** A
16122
+ * recognised identity lands in `subLabel` (attributed to the face chain via
16123
+ * `subLabelMeta.stepId`), so before this field a track with an unmatched face
16124
+ * and a track with no face at all were byte-identical on the wire and no
16125
+ * surface could tell them apart. The read is `hasFace === true && subLabel
16126
+ * === undefined`.
16127
+ *
16128
+ * **Absent ≠ false.** Every row written before the column existed omits it,
16129
+ * and so does every server that predates the field — a consumer must test
16130
+ * `=== true` and render nothing otherwise, never infer "no face".
16131
+ */
16132
+ hasFace: boolean().optional(),
15666
16133
  ...TrackFlagFields,
15667
16134
  ...TrackRetrainFields
15668
16135
  });
@@ -17688,6 +18155,23 @@ var CameraRecordingStatusSchema = object({
17688
18155
  active: boolean(),
17689
18156
  storageBytes: number()
17690
18157
  });
18158
+ /** One stage of the fan-out that could NOT be read, and how long it cost. */
18159
+ var CameraStatusDegradationSchema = object({
18160
+ stage: _enum([
18161
+ "source",
18162
+ "broker",
18163
+ "detection",
18164
+ "recording",
18165
+ "switches"
18166
+ ]),
18167
+ reason: _enum([
18168
+ "timeout",
18169
+ "error",
18170
+ "partial"
18171
+ ]),
18172
+ /** Wall-clock ms spent on the stage before it was abandoned. */
18173
+ elapsedMs: number()
18174
+ });
17691
18175
  /**
17692
18176
  * Aggregated per-camera pipeline status — server-composed, single call.
17693
18177
  *
@@ -17718,9 +18202,28 @@ var CameraStatusSchema = object({
17718
18202
  * differently — a quiet camera that looks identical to a dead one is the
17719
18203
  * silence-reads-as-never-happened trap this repo keeps paying for.
17720
18204
  *
17721
- * Empty when nothing is off. Never contains a switch no provider offers.
18205
+ * Empty when nothing is off, and never contains a switch no provider offers
18206
+ * — but an empty list is only a POSITIVE claim when `degraded` does not name
18207
+ * `'switches'`. When it does, the switch set could not be read and nothing
18208
+ * here may be rendered as "the operator turned nothing off": that is the
18209
+ * D62 failure (a camera we could not read painted as broken) in the very
18210
+ * field that exists to prevent it.
17722
18211
  */
17723
18212
  switchedOff: array(CameraSwitchIdSchema).readonly(),
18213
+ /**
18214
+ * Stages of the bounded fan-out that were CUT SHORT — a timeout or a
18215
+ * rejection — and whose block is therefore `null` because we could not
18216
+ * READ it, not because there is nothing there.
18217
+ *
18218
+ * Without this, three different facts arrive as the same `null`: "the stage
18219
+ * timed out", "the stage failed", and "this camera legitimately has no
18220
+ * decoder / no recording". Every surface that draws a conclusion from a null
18221
+ * block (or from an empty `switchedOff`) must consult this first; a stage
18222
+ * named here supports no conclusion at all, only "unknown".
18223
+ *
18224
+ * Empty on a clean read — the overwhelmingly common case.
18225
+ */
18226
+ degraded: array(CameraStatusDegradationSchema).readonly(),
17724
18227
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
17725
18228
  fetchedAt: number()
17726
18229
  });
@@ -18238,6 +18741,37 @@ DeviceType.Camera, method(object({
18238
18741
  lastCapturedAt: number().nullable(),
18239
18742
  cacheAgeMs: number().nullable(),
18240
18743
  etag: string().nullable()
18744
+ }))), systemMethod(object({
18745
+ /** The tiles a surface is actually rendering. One entry per (device,
18746
+ * width) the caller will paint — the width is snapped to the server's
18747
+ * ladder and becomes part of the link's SIGNED identity. */
18748
+ targets: array(object({
18749
+ deviceId: number(),
18750
+ /** Target width in px. Omit for the frame as captured — correct
18751
+ * for a full-bleed surface, wrong (and expensive) for a grid. */
18752
+ width: number().int().positive().optional()
18753
+ })).min(1).max(200) }), array(object({
18754
+ deviceId: number(),
18755
+ /** Root-relative signed path, or null when the link plane is not
18756
+ * served (no data-plane facility). Present even for a device that has
18757
+ * never captured — the request is what triggers the first one (D94). */
18758
+ url: string().nullable(),
18759
+ /** Epoch ms of the frame this link serves. Null = never captured.
18760
+ * THE honest age: the tRPC path carried none before this. */
18761
+ capturedAt: number().nullable(),
18762
+ /** Age of that frame at the moment the answer was built. */
18763
+ ageMs: number().nullable(),
18764
+ /** Epoch ms after which `url` stops verifying. */
18765
+ expiresAt: number().nullable(),
18766
+ /** Ladder rung the bytes are at; null = the frame as captured. */
18767
+ width: number().nullable(),
18768
+ /** The device has never produced a frame. An empty state, not a
18769
+ * failure — and never a reason to withhold the link (D94). */
18770
+ neverCaptured: boolean(),
18771
+ /** A sleeping battery camera: the frame is deliberately stale and will
18772
+ * NOT refresh in the background. A surface should say so rather than
18773
+ * present it as current. */
18774
+ sleeping: boolean()
18241
18775
  })));
18242
18776
  /**
18243
18777
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
@@ -18277,13 +18811,35 @@ var SsoBridgeClaimsSchema = object({
18277
18811
  integrationId: string().optional(),
18278
18812
  /** JWT ID — unique per issued code; consumed-set enforces single-use. */
18279
18813
  jti: string().optional(),
18814
+ /** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
18815
+ * client. Its PRESENCE is what makes the verifier mandatory at exchange,
18816
+ * so the requirement travels with the code and not with mutable config. */
18817
+ codeChallenge: string().optional(),
18280
18818
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
18281
18819
  * tokens so the verify path can check the session is not revoked. */
18282
- sessionId: string().optional()
18820
+ sessionId: string().optional(),
18821
+ /**
18822
+ * The refresh lifetime this LINK was created with, in seconds, or `'never'`.
18823
+ * Baked into the code at `/authorize` from the integration's descriptor and
18824
+ * carried forward so `oauthRefresh` re-mints with the same lifetime. It rides
18825
+ * on the token rather than being re-read from the descriptor on purpose:
18826
+ * editing a descriptor must not retroactively extend or shorten a link the
18827
+ * operator already consented to.
18828
+ */
18829
+ refreshTtl: union([number().int().positive(), literal("never")]).optional()
18283
18830
  });
18284
18831
  method(object({
18285
18832
  claims: SsoBridgeClaimsSchema,
18286
- ttlSec: number().int().positive().optional()
18833
+ /**
18834
+ * Seconds, or `'never'` for a token minted with NO `exp` claim.
18835
+ *
18836
+ * `'never'` is a literal rather than `undefined`/`0` because omitting
18837
+ * this field already means "the 5-minute SSO hand-off default", and
18838
+ * `jwt.sign` THROWS on `{ expiresIn: undefined }` — a "no expiry" that
18839
+ * went through the numeric path would fail at mint time and break
18840
+ * linking rather than produce an eternal token.
18841
+ */
18842
+ ttlSec: union([number().int().positive(), literal("never")]).optional()
18287
18843
  }), object({ token: string() })), method(object({ token: string() }), SsoBridgeClaimsSchema.nullable());
18288
18844
  var ProviderListEntrySchema = discriminatedUnion("shouldSaveDiskSpace", [object({
18289
18845
  providerId: string().min(1),
@@ -18829,7 +19385,7 @@ var ClipPlaybackSchema = object({
18829
19385
  playbackEndpoints: array(string()).optional(),
18830
19386
  token: string().optional()
18831
19387
  });
18832
- method(object({
19388
+ DeviceType.Camera, method(object({
18833
19389
  deviceId: number(),
18834
19390
  since: number(),
18835
19391
  until: number(),
@@ -19984,6 +20540,37 @@ DeviceType.Light, method(object({
19984
20540
  mireds: number().int().optional(),
19985
20541
  lastChangedAt: number()
19986
20542
  });
20543
+ var ConnectionTestOutcomeSchema = discriminatedUnion("outcome", [
20544
+ object({
20545
+ outcome: literal("validated"),
20546
+ /** Round-trip of the sign-in, when the provider measured it. */
20547
+ latencyMs: number().nonnegative().optional(),
20548
+ /** Optional human detail worth showing next to the tick
20549
+ * ("3 devices visible on this account"). */
20550
+ detail: string().optional()
20551
+ }).strict(),
20552
+ object({
20553
+ outcome: literal("rejected"),
20554
+ error: string()
20555
+ }).strict(),
20556
+ object({
20557
+ outcome: literal("inconclusive"),
20558
+ error: string()
20559
+ }).strict()
20560
+ ]);
20561
+ var ConnectionTestInputSchema = object({
20562
+ /** Candidate integration settings, exactly as the create form collected them. */
20563
+ settings: record(string(), unknown()) });
20564
+ /**
20565
+ * What the provider's test actually DOES, so the UI can say it in words before
20566
+ * the operator presses the button ("Signs in to the Dreo cloud"). Purely
20567
+ * descriptive — it never changes routing.
20568
+ */
20569
+ var ConnectionTestDescriptorSchema = object({ label: string() });
20570
+ method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
20571
+ kind: "mutation",
20572
+ auth: "admin"
20573
+ }), method(_void(), ConnectionTestDescriptorSchema, { auth: "admin" });
19987
20574
  object({
19988
20575
  /** True when the upstream system considers the entity connected. */
19989
20576
  connected: boolean(),
@@ -20341,7 +20928,29 @@ var FaceInfoSchema = object({
20341
20928
  recognizedIdentityId: string().optional(),
20342
20929
  identityName: string().optional(),
20343
20930
  assigned: boolean(),
20931
+ /**
20932
+ * The crop, inline, base64.
20933
+ *
20934
+ * **Prefer {@link cropUrl}.** At the 500 rows the Faces view asks for this
20935
+ * field alone is ~2.87 MiB, re-sent in full on every operator assign and
20936
+ * every 30 s poll, base64-inflated over the msgpack socket and held in the
20937
+ * query heap. It stays for callers that have not migrated; `includeCrops:
20938
+ * false` turns it off once they have.
20939
+ */
20344
20940
  base64: string().optional(),
20941
+ /**
20942
+ * Same crop, as a data-plane URL for `<img src>` — the move the admin
20943
+ * snapshot surfaces made on 2026-08-08.
20944
+ *
20945
+ * Served by the `event-media` plane, which resolves a raw MediaStore key and
20946
+ * is `access: 'authenticated'`: a bare `<img>` carries the `camstack_session`
20947
+ * cookie, so no header plumbing is needed. The bytes then ride the browser's
20948
+ * HTTP cache with an ETag and `immutable`, instead of the WebSocket.
20949
+ *
20950
+ * Absent when the face has no stored crop, or when the addon has no data
20951
+ * plane — callers fall back to {@link base64}.
20952
+ */
20953
+ cropUrl: string().optional(),
20345
20954
  /** Design B: the face bbox (pixel space) on the key frame — lets a detail
20346
20955
  * view draw the box over the native `keyFrameMediaKey` frame. Absent on
20347
20956
  * legacy rows written before design B. */
@@ -20406,7 +21015,23 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
20406
21015
  auth: "admin"
20407
21016
  }), method(object({
20408
21017
  limit: number().int().positive().optional(),
20409
- filter: FaceFilterEnum.optional()
21018
+ filter: FaceFilterEnum.optional(),
21019
+ /**
21020
+ * Inline the base64 crop on every row. Default `true` — the existing
21021
+ * behaviour, kept so no caller breaks.
21022
+ *
21023
+ * Set `false` once the caller renders {@link FaceInfo.cropUrl}: that
21024
+ * drops ~2.87 MiB per 500-row page to a few KiB of metadata and lets
21025
+ * the browser cache the images.
21026
+ *
21027
+ * **This is an INPUT field, so it does not reach the addon until the
21028
+ * next train.** The hub router validates cap inputs against its own
21029
+ * compiled Zod, which strips a key it does not know — verified today
21030
+ * on the OUTPUT side, where an additive field DOES arrive immediately
21031
+ * (`Track.hasFace`). Until the train ships, sending `false` is
21032
+ * harmless and simply keeps the crops inline.
21033
+ */
21034
+ includeCrops: boolean().optional()
20410
21035
  }).optional(), array(FaceInfoSchema).readonly()), method(object({
20411
21036
  deviceId: number().int(),
20412
21037
  trackId: string()
@@ -20847,15 +21472,57 @@ var AvailableIntegrationTypeSchema = object({
20847
21472
  * flow can import (e.g. HA areas). Drives the adopt modal's "import
20848
21473
  * locations" checkbox. Provider-declared in the addon manifest. */
20849
21474
  supportsLocationImport: boolean(),
21475
+ /**
21476
+ * True when this integration DECLARES a pre-creation test (the
21477
+ * `connection-test` cap, or a broker whose settings it stores). Drives the
21478
+ * Test button: an integration that cannot be tested must say so up front
21479
+ * rather than offering a button that always answers the same nonsense.
21480
+ */
21481
+ canTest: boolean(),
20850
21482
  existingInstances: array(object({
20851
21483
  id: string(),
20852
21484
  name: string()
20853
21485
  })),
20854
21486
  canAdd: boolean()
20855
21487
  });
21488
+ /**
21489
+ * Why a test could not be answered as a plain boolean.
21490
+ *
21491
+ * `success` alone collapsed four different situations into one red box, and the
21492
+ * one that mattered most — "nobody ever asked the remote anything" — looked
21493
+ * exactly like "the remote said no". The status is the discriminator:
21494
+ *
21495
+ * - `validated` — a provider-declared test ran and the remote ACCEPTED.
21496
+ * - `rejected` — a provider-declared test ran and the remote REFUSED.
21497
+ * The only status that blocks `integrations.create`.
21498
+ * - `inconclusive` — a test IS declared but could not complete (timeout,
21499
+ * DNS, 5xx). Nothing was observed; not a failure.
21500
+ * - `unsupported` — this integration declares NO test. Nothing was
21501
+ * observed either; not a failure, and not a pass.
21502
+ *
21503
+ * `unsupported` and `inconclusive` both carry `success: false` so an older
21504
+ * client can never read them as a green tick, and both carry an `error` string
21505
+ * that SAYS the test did not run rather than inventing a failure.
21506
+ */
21507
+ var TestConnectionStatusEnum = _enum([
21508
+ "validated",
21509
+ "rejected",
21510
+ "inconclusive",
21511
+ "unsupported"
21512
+ ]);
20856
21513
  var TestConnectionResultSchema$1 = object({
21514
+ /** True ONLY for `validated`. Never true for a test that did not run. */
20857
21515
  success: boolean(),
20858
- error: string().optional()
21516
+ error: string().optional(),
21517
+ /** Optional for wire back-compat with clients built before the tri-state;
21518
+ * the server always sets it. */
21519
+ status: TestConnectionStatusEnum.optional(),
21520
+ /** Addon id whose declared test answered — `null` when none did. Lets the UI
21521
+ * attribute a result instead of blaming "the integration". */
21522
+ testedBy: string().nullable().optional(),
21523
+ latencyMs: number().nonnegative().optional(),
21524
+ /** Human detail from a `validated` result ("3 devices on this account"). */
21525
+ detail: string().optional()
20859
21526
  });
20860
21527
  var CreateIntegrationInputSchema = object({
20861
21528
  addonId: string(),
@@ -23905,13 +24572,23 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
23905
24572
  username: string(),
23906
24573
  scopes: array(TokenScopeSchema),
23907
24574
  redirectUri: string(),
23908
- hubUrl: string()
24575
+ hubUrl: string(),
24576
+ /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
24577
+ * that carries one can ONLY be exchanged with the matching verifier. */
24578
+ codeChallenge: string().optional(),
24579
+ /** The integration's declared refresh lifetime — seconds, or `'never'`.
24580
+ * From `OauthIntegrationDescriptor.refreshTokenTtlSec`. Baked into the
24581
+ * code so the link carries its own lifetime; omit for the 30-day
24582
+ * default. */
24583
+ refreshTtlSec: union([number().int().positive(), literal("never")]).optional()
23909
24584
  }), object({ code: string() }), {
23910
24585
  kind: "mutation",
23911
24586
  access: "create"
23912
24587
  }), method(object({
23913
24588
  code: string(),
23914
- redirectUri: string()
24589
+ redirectUri: string(),
24590
+ /** PKCE verifier. REQUIRED when the code carries a challenge. */
24591
+ codeVerifier: string().optional()
23915
24592
  }), object({
23916
24593
  accessToken: string(),
23917
24594
  refreshToken: string(),
@@ -25096,6 +25773,18 @@ Object.freeze({
25096
25773
  addonId: null,
25097
25774
  access: "create"
25098
25775
  },
25776
+ "connectionTest.describeTest": {
25777
+ capName: "connection-test",
25778
+ capScope: "system",
25779
+ addonId: null,
25780
+ access: "view"
25781
+ },
25782
+ "connectionTest.testSettings": {
25783
+ capName: "connection-test",
25784
+ capScope: "system",
25785
+ addonId: null,
25786
+ access: "create"
25787
+ },
25099
25788
  "consumables.reset": {
25100
25789
  capName: "consumables",
25101
25790
  capScope: "device",
@@ -25144,6 +25833,12 @@ Object.freeze({
25144
25833
  addonId: null,
25145
25834
  access: "view"
25146
25835
  },
25836
+ "coreBlocks.restart": {
25837
+ capName: "core-blocks",
25838
+ capScope: "system",
25839
+ addonId: null,
25840
+ access: "create"
25841
+ },
25147
25842
  "coreBlocks.setEnabled": {
25148
25843
  capName: "core-blocks",
25149
25844
  capScope: "system",
@@ -25480,6 +26175,12 @@ Object.freeze({
25480
26175
  addonId: null,
25481
26176
  access: "create"
25482
26177
  },
26178
+ "deviceManager.adoptionCancelJob": {
26179
+ capName: "device-manager",
26180
+ capScope: "system",
26181
+ addonId: null,
26182
+ access: "create"
26183
+ },
25483
26184
  "deviceManager.adoptionListCandidateFilters": {
25484
26185
  capName: "device-manager",
25485
26186
  capScope: "system",
@@ -25492,6 +26193,12 @@ Object.freeze({
25492
26193
  addonId: null,
25493
26194
  access: "view"
25494
26195
  },
26196
+ "deviceManager.adoptionListJobs": {
26197
+ capName: "device-manager",
26198
+ capScope: "system",
26199
+ addonId: null,
26200
+ access: "view"
26201
+ },
25495
26202
  "deviceManager.adoptionRefresh": {
25496
26203
  capName: "device-manager",
25497
26204
  capScope: "system",
@@ -25510,6 +26217,12 @@ Object.freeze({
25510
26217
  addonId: null,
25511
26218
  access: "create"
25512
26219
  },
26220
+ "deviceManager.adoptionStartJob": {
26221
+ capName: "device-manager",
26222
+ capScope: "system",
26223
+ addonId: null,
26224
+ access: "create"
26225
+ },
25513
26226
  "deviceManager.allocateDeviceId": {
25514
26227
  capName: "device-manager",
25515
26228
  capScope: "system",
@@ -25780,12 +26493,6 @@ Object.freeze({
25780
26493
  addonId: null,
25781
26494
  access: "create"
25782
26495
  },
25783
- "deviceManager.setDeviceLinks": {
25784
- capName: "device-manager",
25785
- capScope: "system",
25786
- addonId: null,
25787
- access: "create"
25788
- },
25789
26496
  "deviceManager.setDisabled": {
25790
26497
  capName: "device-manager",
25791
26498
  capScope: "system",
@@ -28654,6 +29361,12 @@ Object.freeze({
28654
29361
  addonId: null,
28655
29362
  access: "view"
28656
29363
  },
29364
+ "snapshot.getSnapshotLinks": {
29365
+ capName: "snapshot",
29366
+ capScope: "device",
29367
+ addonId: null,
29368
+ access: "view"
29369
+ },
28657
29370
  "snapshot.getSnapshotOverview": {
28658
29371
  capName: "snapshot",
28659
29372
  capScope: "device",
@@ -28930,6 +29643,12 @@ Object.freeze({
28930
29643
  addonId: null,
28931
29644
  access: "create"
28932
29645
  },
29646
+ "streamBroker.fetchEventMedia": {
29647
+ capName: "stream-broker",
29648
+ capScope: "system",
29649
+ addonId: null,
29650
+ access: "create"
29651
+ },
28933
29652
  "streamBroker.getAllRtspEntries": {
28934
29653
  capName: "stream-broker",
28935
29654
  capScope: "system",
@@ -29014,6 +29733,12 @@ Object.freeze({
29014
29733
  addonId: null,
29015
29734
  access: "create"
29016
29735
  },
29736
+ "streamBroker.produceEventMedia": {
29737
+ capName: "stream-broker",
29738
+ capScope: "system",
29739
+ addonId: null,
29740
+ access: "create"
29741
+ },
29017
29742
  "streamBroker.publishCameraStream": {
29018
29743
  capName: "stream-broker",
29019
29744
  capScope: "system",
@@ -43983,37 +44708,37 @@ function errMsg$8(err) {
43983
44708
  }
43984
44709
  //#endregion
43985
44710
  //#region src/mappers/builders/doorbell-delivery.ts
43986
- function isRecord(value) {
44711
+ function isRecord$1(value) {
43987
44712
  return typeof value === "object" && value !== null;
43988
44713
  }
43989
44714
  function numberOrNull(value) {
43990
44715
  return typeof value === "number" ? value : null;
43991
44716
  }
43992
44717
  function isConnectionLike(value) {
43993
- return isRecord(value) && typeof value["hasEventNotifications"] === "function";
44718
+ return isRecord$1(value) && typeof value["hasEventNotifications"] === "function";
43994
44719
  }
43995
- function isIterable(value) {
43996
- return isRecord(value) && typeof value[Symbol.iterator] === "function";
44720
+ function isIterable$1(value) {
44721
+ return isRecord$1(value) && typeof value[Symbol.iterator] === "function";
43997
44722
  }
43998
44723
  /** `accessory._server.httpServer.connections`, or null at any missing hop. */
43999
- function readConnections(accessory) {
44000
- if (!isRecord(accessory)) return null;
44724
+ function readConnections$1(accessory) {
44725
+ if (!isRecord$1(accessory)) return null;
44001
44726
  const server = accessory["_server"];
44002
- if (!isRecord(server)) return null;
44727
+ if (!isRecord$1(server)) return null;
44003
44728
  const httpServer = server["httpServer"];
44004
- if (!isRecord(httpServer)) return null;
44729
+ if (!isRecord$1(httpServer)) return null;
44005
44730
  const connections = httpServer["connections"];
44006
- return isIterable(connections) ? connections : null;
44731
+ return isIterable$1(connections) ? connections : null;
44007
44732
  }
44008
44733
  /**
44009
44734
  * Probe how far a ring on `characteristic` of `accessory` can travel RIGHT NOW.
44010
44735
  * Pure with respect to HAP state — it only reads. Never throws.
44011
44736
  */
44012
44737
  function describeDoorbellDelivery(accessory, characteristic) {
44013
- const aid = isRecord(accessory) ? numberOrNull(accessory["aid"]) : null;
44014
- const iid = isRecord(characteristic) ? numberOrNull(characteristic["iid"]) : null;
44015
- const serverPublished = isRecord(accessory) && isRecord(accessory["_server"]);
44016
- const connections = readConnections(accessory);
44738
+ const aid = isRecord$1(accessory) ? numberOrNull(accessory["aid"]) : null;
44739
+ const iid = isRecord$1(characteristic) ? numberOrNull(characteristic["iid"]) : null;
44740
+ const serverPublished = isRecord$1(accessory) && isRecord$1(accessory["_server"]);
44741
+ const connections = readConnections$1(accessory);
44017
44742
  if (connections === null) return {
44018
44743
  aid,
44019
44744
  iid,
@@ -44125,10 +44850,16 @@ async function buildMotionSensor(bctx, existing = null) {
44125
44850
  resetTimer = null;
44126
44851
  }, RESET_DEBOUNCE_MS);
44127
44852
  };
44853
+ const motionLog = ctx.logger.withTags({ deviceId: numericDeviceId });
44854
+ const hksvTrigger = existing !== null;
44128
44855
  const unsubscribe = ctx.eventBus.subscribe({ category: EventCategory.MotionOnMotionChanged }, (event) => {
44129
44856
  if (event.data.deviceId !== numericDeviceId) return;
44130
44857
  const detected = event.data.detected === true;
44131
44858
  motionService.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.MotionDetected, detected);
44859
+ motionLog.debug("export-hap: motion pushed to HomeKit", { meta: {
44860
+ detected,
44861
+ hksvTrigger
44862
+ } });
44132
44863
  if (detected) armReset();
44133
44864
  else if (resetTimer) {
44134
44865
  clearTimeout(resetTimer);
@@ -44505,36 +45236,84 @@ async function probe(call, label, log) {
44505
45236
  }
44506
45237
  }
44507
45238
  //#endregion
44508
- //#region src/hksv/recording-options.ts
45239
+ //#region src/hksv/controller-census.ts
45240
+ var EMPTY_CENSUS = {
45241
+ serverPublished: false,
45242
+ connections: 0,
45243
+ adminConnections: 0,
45244
+ nonAdminConnections: 0,
45245
+ unverifiedConnections: 0,
45246
+ pairedControllers: 0,
45247
+ pairedAdmins: 0
45248
+ };
45249
+ function isRecord(value) {
45250
+ return typeof value === "object" && value !== null;
45251
+ }
45252
+ function isIterable(value) {
45253
+ return isRecord(value) && typeof value[Symbol.iterator] === "function";
45254
+ }
45255
+ function isAccessoryInfoLike(value) {
45256
+ return isRecord(value) && isRecord(value["pairedClients"]) && typeof value["hasAdminPermissions"] === "function";
45257
+ }
45258
+ /** `accessory._server.httpServer.connections`, or null at any missing hop. */
45259
+ function readConnections(accessory) {
45260
+ const server = accessory["_server"];
45261
+ if (!isRecord(server)) return null;
45262
+ const httpServer = server["httpServer"];
45263
+ if (!isRecord(httpServer)) return null;
45264
+ const connections = httpServer["connections"];
45265
+ return isIterable(connections) ? connections : null;
45266
+ }
44509
45267
  /**
44510
- * The HomeKit Secure Video ADVERTISEMENT`CameraRecordingOptions`, derived
44511
- * from what the fMP4 sink will actually produce for THIS camera.
44512
- *
44513
- * ## The rule this file exists to enforce
44514
- *
44515
- * Never advertise something we cannot serve. That is not a slogan here: it is
44516
- * the diagnosis of [D50](../../../../docs/decisions/adr-0050.md) an
44517
- * advertised `recording` whose delegate yielded nothing put every motion-capable
44518
- * camera into a ~12 s timeout loop every 20-60 s, all day. So every number below
44519
- * is derived from the picked source (`recording-source.ts`) or from a measured
44520
- * property of the sink, and none of them is a plausible-looking constant.
44521
- *
44522
- * ## The fragment length is the subtle one
44523
- *
44524
- * HKSV requires every media fragment to be **no longer** than the length the
44525
- * controller selected. On the copy branch the fragment length is the SOURCE's
44526
- * key-frame cadence ([D80](../../../../docs/decisions/adr-0080.md)) — we do not
44527
- * get to choose it, we can only be honest about it. So:
44528
- *
44529
- * - when the camera reports its GOP (`stream-params`), the advertised length is
44530
- * the smallest offered value that COVERS it;
44531
- * - when it does not, we advertise the 4000 ms every HKSV camera uses and the
44532
- * delegate warns at `warn` with `tags: { deviceId }` if the fragments that
44533
- * actually arrive are longer.
44534
- *
44535
- * A camera whose GOP exceeds the longest value we offer does not advertise
44536
- * recording at all. See {@link deriveFragmentLengthMs}.
45268
+ * Census the accessory's HAP connections. Pure with respect to HAP state it
45269
+ * only reads and never throws.
45270
+ */
45271
+ function describeHapControllers(accessory) {
45272
+ if (!isRecord(accessory)) return EMPTY_CENSUS;
45273
+ const info = accessory["_accessoryInfo"];
45274
+ const paired = isAccessoryInfoLike(info) ? Object.keys(info.pairedClients) : [];
45275
+ const pairedAdmins = isAccessoryInfoLike(info) ? paired.filter((username) => info.hasAdminPermissions(username)).length : 0;
45276
+ const connections = readConnections(accessory);
45277
+ if (connections === null) return {
45278
+ ...EMPTY_CENSUS,
45279
+ pairedControllers: paired.length,
45280
+ pairedAdmins
45281
+ };
45282
+ let open = 0;
45283
+ let admins = 0;
45284
+ let nonAdmins = 0;
45285
+ let unverified = 0;
45286
+ for (const connection of connections) {
45287
+ open += 1;
45288
+ const username = isRecord(connection) ? connection["username"] : void 0;
45289
+ if (typeof username !== "string" || !isAccessoryInfoLike(info)) {
45290
+ unverified += 1;
45291
+ continue;
45292
+ }
45293
+ if (info.hasAdminPermissions(username)) admins += 1;
45294
+ else nonAdmins += 1;
45295
+ }
45296
+ return {
45297
+ serverPublished: true,
45298
+ connections: open,
45299
+ adminConnections: admins,
45300
+ nonAdminConnections: nonAdmins,
45301
+ unverifiedConnections: unverified,
45302
+ pairedControllers: paired.length,
45303
+ pairedAdmins
45304
+ };
45305
+ }
45306
+ /**
45307
+ * True when NO connected controller may write HKSV state. Every
45308
+ * `SelectedCameraRecordingConfiguration` write such a controller sends is
45309
+ * refused before it reaches us, so the recording configuration can never
45310
+ * arrive and the caller must say so out loud.
44537
45311
  */
45312
+ function noAdminControllerConnected(census) {
45313
+ return census.serverPublished && census.connections > 0 && census.adminConnections === 0;
45314
+ }
45315
+ //#endregion
45316
+ //#region src/hksv/recording-options.ts
44538
45317
  /**
44539
45318
  * The prebuffer we promise. HAP's floor is 4000 ms and its documented sensible
44540
45319
  * range is [4000, 8000]; the plane's ring is sized from this, so the two cannot
@@ -44557,6 +45336,16 @@ var HKSV_FRAGMENT_LENGTHS_MS = [4e3, 8e3];
44557
45336
  */
44558
45337
  var HKSV_AUDIO_SAMPLE_RATE_HZ = 24e3;
44559
45338
  /**
45339
+ * The frame rates the advertised {@link Resolution} may carry, and the only
45340
+ * ones. See {@link normaliseAdvertisedFps} for why the measured rate does not
45341
+ * go in raw.
45342
+ */
45343
+ var HKSV_ADVERTISED_FRAME_RATES = [
45344
+ 15,
45345
+ 24,
45346
+ 30
45347
+ ];
45348
+ /**
44560
45349
  * The advertised fragment length for a camera whose key-frame cadence is
44561
45350
  * `sourceGopMs`, or `null` when no offered length covers it.
44562
45351
  *
@@ -44572,6 +45361,46 @@ function deriveFragmentLengthMs(sourceGopMs) {
44572
45361
  return HKSV_FRAGMENT_LENGTHS_MS.find((ms) => ms >= sourceGopMs) ?? null;
44573
45362
  }
44574
45363
  /**
45364
+ * The frame rate to ADVERTISE for a slot that was measured at `measuredFps` —
45365
+ * the nearest member of {@link HKSV_ADVERTISED_FRAME_RATES}, ties going to the
45366
+ * lower rate.
45367
+ *
45368
+ * The measured rate does not go into the advertisement raw, for two reasons,
45369
+ * and the second one is the serious one.
45370
+ *
45371
+ * **It is the list the controller chooses from.** `[1280, 720, 10]` — 615's
45372
+ * measured 720p slot — is a frame rate no shipping HKSV camera offers, and the
45373
+ * controller has to find an acceptable configuration in what we advertise
45374
+ * before it will write one back.
45375
+ *
45376
+ * **A measurement makes the advertisement UNSTABLE, and hap-nodejs punishes
45377
+ * that by discarding the controller's selection.** `RecordingManagement`
45378
+ * hashes the supported-configuration TLVs and, on restore, keeps the persisted
45379
+ * `selectedConfiguration` only while the hash still matches — otherwise
45380
+ * `deserialize: discarding saved selectedConfiguration`, after which the
45381
+ * accessory answers every HDS `DATA_SEND OPEN` with `INVALID_CONFIGURATION`
45382
+ * and records nothing until the controller happens to re-select. The
45383
+ * advertised resolution is the one hashed input that came from a probe:
45384
+ * camera 590 measured 9 fps on one restart and 10 on the next, on
45385
+ * 2026-08-07/08, so this was a self-inflicted outage waiting on a reboot.
45386
+ * Quantising gives the probe a wide band to move inside without the
45387
+ * advertisement changing at all.
45388
+ */
45389
+ function normaliseAdvertisedFps(measuredFps) {
45390
+ const fallback = HKSV_ADVERTISED_FRAME_RATES[0] ?? 15;
45391
+ if (!Number.isFinite(measuredFps) || measuredFps <= 0) return fallback;
45392
+ let best = fallback;
45393
+ let bestDistance = Number.POSITIVE_INFINITY;
45394
+ for (const candidate of HKSV_ADVERTISED_FRAME_RATES) {
45395
+ const distance = Math.abs(candidate - measuredFps);
45396
+ if (distance < bestDistance) {
45397
+ best = candidate;
45398
+ bestDistance = distance;
45399
+ }
45400
+ }
45401
+ return best;
45402
+ }
45403
+ /**
44575
45404
  * Build the advertisement.
44576
45405
  *
44577
45406
  * ONE resolution is advertised — the one slot the recording child pulls. HAP's
@@ -44584,7 +45413,7 @@ function buildRecordingOptions(input) {
44584
45413
  const resolution = [
44585
45414
  input.width,
44586
45415
  input.height,
44587
- Math.max(1, Math.round(input.fps))
45416
+ normaliseAdvertisedFps(input.fps)
44588
45417
  ];
44589
45418
  return {
44590
45419
  prebufferLength: HKSV_PREBUFFER_MS,
@@ -44907,13 +45736,19 @@ var HksvRecordingDelegate = class {
44907
45736
  updateRecordingActive(active) {
44908
45737
  if (active === this.active) return;
44909
45738
  this.active = active;
45739
+ const census = this.input.describeControllers();
44910
45740
  this.log.info("hksv: recording active changed", {
44911
45741
  tags: { deviceId: this.input.deviceId },
44912
45742
  meta: {
44913
45743
  active,
44914
- hasConfiguration: this.configuration !== void 0
45744
+ hasConfiguration: this.configuration !== void 0,
45745
+ ...census
44915
45746
  }
44916
45747
  });
45748
+ 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", {
45749
+ tags: { deviceId: this.input.deviceId },
45750
+ meta: { ...census }
45751
+ });
44917
45752
  this.reconcile("recording-active");
44918
45753
  }
44919
45754
  updateRecordingConfiguration(configuration) {
@@ -44968,13 +45803,14 @@ var HksvRecordingDelegate = class {
44968
45803
  streamId,
44969
45804
  subscription
44970
45805
  };
44971
- const startedAt = Date.now();
45806
+ const startedAt = this.input.now();
44972
45807
  const prebufferSpanMs = source.prebufferSpanMs();
44973
45808
  let packets = 0;
44974
45809
  let bytes = 0;
44975
45810
  let markedLast = false;
44976
45811
  let longestFragmentGapMs = 0;
44977
- let lastPacketAt = startedAt;
45812
+ let firstFragmentAt = null;
45813
+ let lastFragmentAt = null;
44978
45814
  try {
44979
45815
  for await (const packet of subscription.packets()) {
44980
45816
  if (signal?.aborted === true) {
@@ -44990,9 +45826,10 @@ var HksvRecordingDelegate = class {
44990
45826
  packets += 1;
44991
45827
  bytes += packet.data.length;
44992
45828
  if (packet.kind === "fragment") {
44993
- const now = Date.now();
44994
- longestFragmentGapMs = Math.max(longestFragmentGapMs, now - lastPacketAt);
44995
- lastPacketAt = now;
45829
+ const now = this.input.now();
45830
+ if (lastFragmentAt === null) firstFragmentAt = now;
45831
+ else longestFragmentGapMs = Math.max(longestFragmentGapMs, now - lastFragmentAt);
45832
+ lastFragmentAt = now;
44996
45833
  }
44997
45834
  markedLast = markedLast || packet.isLast;
44998
45835
  yield {
@@ -45027,9 +45864,10 @@ var HksvRecordingDelegate = class {
45027
45864
  streamId,
45028
45865
  packets,
45029
45866
  bytes,
45030
- durationMs: Date.now() - startedAt,
45867
+ durationMs: this.input.now() - startedAt,
45031
45868
  prebufferSpanMs,
45032
45869
  longestFragmentGapMs,
45870
+ msToFirstFragmentMs: firstFragmentAt === null ? null : firstFragmentAt - startedAt,
45033
45871
  closedReason: subscription.closedReason,
45034
45872
  markedLast
45035
45873
  }
@@ -45246,6 +46084,8 @@ async function buildHksvRecording(input) {
45246
46084
  deviceId: numericDeviceId,
45247
46085
  isAudioActive: input.isAudioActive,
45248
46086
  advertisedFragmentMs: fragmentLengthMs,
46087
+ now: () => Date.now(),
46088
+ describeControllers: () => describeHapControllers(bctx.accessory),
45249
46089
  createSource: ({ fragmentMs, audioActive }) => new HksvFragmentSource({
45250
46090
  logger: log,
45251
46091
  deviceId: numericDeviceId,
@@ -45256,11 +46096,13 @@ async function buildHksvRecording(input) {
45256
46096
  audioActive
45257
46097
  })
45258
46098
  });
46099
+ const advertisedResolution = options.video.resolutions[0];
45259
46100
  log.info("export-hap: HKSV ADVERTISED — recording is offered for this camera", { meta: {
45260
46101
  brokerId: source.brokerId,
45261
46102
  profile: source.profile,
45262
46103
  resolution: `${source.width}x${source.height}`,
45263
- fps,
46104
+ measuredFps: fps,
46105
+ advertisedFps: advertisedResolution?.[2] ?? null,
45264
46106
  fragmentLengthMs,
45265
46107
  sourceGopMs: gopMs ?? "unknown"
45266
46108
  } });