@camstack/addon-pipeline-orchestrator 1.2.32 → 1.2.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (16) hide show
  1. package/assets/viewer/.viewer-version +1 -1
  2. package/assets/viewer/_expo/static/js/web/{Clipboard-a7e650a0275101956cfc27665a55251d.js → Clipboard-d48d3b588462f946bb7c6bbcb6496478.js} +8 -8
  3. package/assets/viewer/_expo/static/js/web/{Haptics-50dc91ae29a4c2dc03a4e867706d235d.js → Haptics-53c4c122a82bf429cb93a4fe5ae9fb46.js} +3 -3
  4. package/assets/viewer/_expo/static/js/web/{index-e01fa0fb69f3795f76a103cb309022d6.js → index-04e8b58b1472de4d69f8a5184a3bbb9b.js} +605 -604
  5. package/assets/viewer/index.html +1 -1
  6. package/dist/_stub.js +214 -214
  7. package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-DP-xubRv.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-DMsZ8quw.mjs} +3 -3
  8. package/dist/_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Xfc_tLhH.mjs +26 -0
  9. package/dist/_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DR6Zf4Up.mjs +26 -0
  10. package/dist/{hostInit-Bdc6cSED.mjs → hostInit-Cslwtzcn.mjs} +3 -3
  11. package/dist/index.js +2270 -1609
  12. package/dist/index.mjs +2270 -1609
  13. package/dist/remoteEntry.js +1 -1
  14. package/package.json +1 -1
  15. package/dist/_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-Celb3oc6.mjs +0 -26
  16. package/dist/_virtual_mf___mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-C_kzDF6N.mjs +0 -26
package/dist/index.mjs CHANGED
@@ -7651,6 +7651,104 @@ object({
7651
7651
  })
7652
7652
  });
7653
7653
  /**
7654
+ * Adoption job — the background form of `device-adoption.adopt`.
7655
+ *
7656
+ * ## Why this exists
7657
+ *
7658
+ * `adopt({childNativeIds: [...]})` materialises one CamStack device per
7659
+ * candidate PLUS every accessory child, and the whole array shares ONE UDS
7660
+ * request deadline (60s). Measured on the live hub against Home Assistant:
7661
+ * each device the kernel creates costs ~450 ms — `devices.create` pre-seeds
7662
+ * meta with up to eleven SEQUENTIAL round trips (`setName`, `setType`,
7663
+ * `setRole`, … `persistConfig`) before the class is constructed — and an
7664
+ * accessory child costs the same as its parent. So the real unit of work is
7665
+ * the CHILD, not the candidate:
7666
+ *
7667
+ * - 25 candidates averaging 6 children → ~150 devices → **>60s, times out**
7668
+ * - ONE candidate with 217 children → ~217 devices → **>60s, times out**
7669
+ *
7670
+ * That second line is why this is a job and not a smaller batch. No chunking,
7671
+ * no bounded concurrency over candidates and no per-call tuning can fix a
7672
+ * shape where **N=1 already exceeds the deadline** — the count that blows the
7673
+ * budget is the source system's accessory fan-out, which the operator does not
7674
+ * choose and cannot see. A design that only works below some N is the same bug
7675
+ * deferred.
7676
+ *
7677
+ * ## What the timeout did NOT do
7678
+ *
7679
+ * It did not stop the work. The UDS deadline ends the CALLER's wait; the
7680
+ * provider's loop runs to completion. Measured: a 25-candidate adopt that
7681
+ * "failed" at 60s had adopted 17 by 87s and all 25 by ~130s. The operator saw
7682
+ * an error and had no way to learn that. Every field below exists so that
7683
+ * question has an answer.
7684
+ *
7685
+ * ## Idempotency
7686
+ *
7687
+ * Jobs are in-RAM; a restart forgets them. That is safe here because adoption
7688
+ * is keyed by a stable id (`ha:<broker>:dev:<nativeId>` and equivalents), so
7689
+ * re-running a job re-adopts nothing: an already-adopted candidate is SKIPPED
7690
+ * by the engine before any provider call and lands in `alreadyAdopted`. It is
7691
+ * never a duplicate device, and never an error the operator has to interpret.
7692
+ */
7693
+ var AdoptionJobStateSchema = _enum([
7694
+ "running",
7695
+ "done",
7696
+ "failed",
7697
+ "cancelled"
7698
+ ]);
7699
+ /**
7700
+ * Per-candidate result. Every candidate the job was asked to adopt ends in
7701
+ * exactly one of these buckets — there is no silent drop, and the operator can
7702
+ * always answer "which of my 25 landed?".
7703
+ *
7704
+ * - `adopted` — created now by this job.
7705
+ * - `already-adopted` — a device for this candidate existed before the job
7706
+ * reached it (a re-run, or a retry after a timeout). Not an error.
7707
+ * - `failed` — the provider threw; `error` carries the message.
7708
+ * - `cancelled` — the operator cancelled before this candidate was reached.
7709
+ */
7710
+ var AdoptionOutcomeSchema = _enum([
7711
+ "adopted",
7712
+ "already-adopted",
7713
+ "failed",
7714
+ "cancelled"
7715
+ ]);
7716
+ var AdoptionCandidateResultSchema = object({
7717
+ childNativeId: string(),
7718
+ outcome: AdoptionOutcomeSchema,
7719
+ /** The materialised parent device id — null for `failed` / `cancelled`. */
7720
+ parentDeviceId: number().int().nonnegative().nullable(),
7721
+ /** Accessory children created for this candidate. */
7722
+ accessoryCount: number().int().nonnegative(),
7723
+ /** Failure message; null unless `outcome === 'failed'`. */
7724
+ error: string().nullable()
7725
+ });
7726
+ var AdoptionJobSchema = object({
7727
+ jobId: string(),
7728
+ /** The integration provider this job adopts through (the `addonId` pin). */
7729
+ addonId: string(),
7730
+ integrationId: string(),
7731
+ state: AdoptionJobStateSchema,
7732
+ /** Candidates the job was asked to adopt. Known up front, so never null. */
7733
+ total: number().int().nonnegative(),
7734
+ /** Candidates that have reached a terminal bucket. */
7735
+ processed: number().int().nonnegative(),
7736
+ adopted: number().int().nonnegative(),
7737
+ alreadyAdopted: number().int().nonnegative(),
7738
+ failed: number().int().nonnegative(),
7739
+ /** Accessory child devices created across every candidate — the real unit
7740
+ * of work, surfaced so a slow job is legible rather than mysterious. */
7741
+ accessoriesCreated: number().int().nonnegative(),
7742
+ /** The candidate currently being adopted; null when idle or finished. */
7743
+ currentChildNativeId: string().nullable(),
7744
+ /** One entry per candidate, in the order they were processed. */
7745
+ results: array(AdoptionCandidateResultSchema).readonly(),
7746
+ startedAt: number(),
7747
+ finishedAt: number().nullable(),
7748
+ /** Set only when the job itself broke (not a per-candidate failure). */
7749
+ error: string().nullable()
7750
+ });
7751
+ /**
7654
7752
  * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7655
7753
  * pipeline functions an operator thinks in terms of.
7656
7754
  *
@@ -8733,441 +8831,1215 @@ var ConvertResultSchema = object({
8733
8831
  })).readonly()
8734
8832
  });
8735
8833
  /**
8736
- * `addon-pages` — system-scoped singleton aggregator cap. Public-facing
8737
- * surface that admin-ui consumes through `useAddonPagesListPages()`.
8834
+ * Error types for the safe expression engine. Two distinct classes so callers
8835
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
8836
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
8837
+ */
8838
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
8839
+ * the failure is anchored to a character (author-facing inline feedback). */
8840
+ var ExpressionParseError = class extends Error {
8841
+ position;
8842
+ constructor(message, position) {
8843
+ super(message);
8844
+ this.name = "ExpressionParseError";
8845
+ this.position = position;
8846
+ }
8847
+ };
8848
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
8849
+ * result, unknown builtin, step-budget exceeded). */
8850
+ var ExpressionEvalError = class extends Error {
8851
+ constructor(message) {
8852
+ super(message);
8853
+ this.name = "ExpressionEvalError";
8854
+ }
8855
+ };
8856
+ /**
8857
+ * Frozen, null-prototype builtin function table for the expression engine
8858
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
8859
+ * parser rejects any callee not in it, and the evaluator gates each call on an
8860
+ * own-property check against it.
8738
8861
  *
8739
- * The provider iterates every `addon-pages-source` (collection) provider
8740
- * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
8741
- * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
8742
- * filesystem `mtime` cache-buster lets the browser pick up addon
8743
- * rebuilds without manual reload.
8862
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
8863
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
8864
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
8865
+ * (there is no `Object.prototype` in the chain), so those names are not
8866
+ * callable — they are simply "unknown function" at parse time.
8744
8867
  *
8745
- * The hub-local builtin `addon-pages-aggregator` (see
8746
- * `@camstack/system/builtins/addon-pages-aggregator`) registers the
8747
- * provider. Splitting the public aggregator from the raw collection
8748
- * keeps both ends in codegen — there's no hand-written
8749
- * `addon-pages.router.ts` wrapper anymore.
8868
+ * Every numeric argument is validated as a finite number and every numeric
8869
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
8870
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
8871
+ * closed rather than emitting a garbage value.
8750
8872
  */
8751
- var AddonPageDeclarationSchema$1 = object({
8752
- id: string(),
8753
- label: string(),
8754
- icon: string(),
8755
- path: string(),
8756
- remoteName: string(),
8757
- bundle: string(),
8758
- section: string().optional(),
8759
- sectionLabel: string().optional()
8760
- });
8761
- var AddonPageInfoSchema = object({
8762
- addonId: string(),
8763
- page: AddonPageDeclarationSchema$1,
8764
- bundleUrl: string()
8765
- });
8766
- method(_void(), array(AddonPageInfoSchema).readonly());
8873
+ function asFiniteNumber(value, name, index) {
8874
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
8875
+ return value;
8876
+ }
8877
+ function asString$1(value, name, index) {
8878
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
8879
+ return value;
8880
+ }
8881
+ function finiteResult(value, name) {
8882
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
8883
+ return value;
8884
+ }
8885
+ function allFiniteNumbers(args, name) {
8886
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
8887
+ }
8888
+ var INF = Number.POSITIVE_INFINITY;
8889
+ var table = {
8890
+ min: {
8891
+ minArgs: 1,
8892
+ maxArgs: INF,
8893
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
8894
+ },
8895
+ max: {
8896
+ minArgs: 1,
8897
+ maxArgs: INF,
8898
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
8899
+ },
8900
+ abs: {
8901
+ minArgs: 1,
8902
+ maxArgs: 1,
8903
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
8904
+ },
8905
+ floor: {
8906
+ minArgs: 1,
8907
+ maxArgs: 1,
8908
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
8909
+ },
8910
+ ceil: {
8911
+ minArgs: 1,
8912
+ maxArgs: 1,
8913
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
8914
+ },
8915
+ sqrt: {
8916
+ minArgs: 1,
8917
+ maxArgs: 1,
8918
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
8919
+ },
8920
+ round: {
8921
+ minArgs: 1,
8922
+ maxArgs: 2,
8923
+ apply: (args) => {
8924
+ const x = asFiniteNumber(args[0], "round", 0);
8925
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
8926
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
8927
+ const factor = 10 ** digits;
8928
+ return finiteResult(Math.round(x * factor) / factor, "round");
8929
+ }
8930
+ },
8931
+ pow: {
8932
+ minArgs: 2,
8933
+ maxArgs: 2,
8934
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
8935
+ },
8936
+ clamp: {
8937
+ minArgs: 3,
8938
+ maxArgs: 3,
8939
+ apply: (args) => {
8940
+ const x = asFiniteNumber(args[0], "clamp", 0);
8941
+ const lo = asFiniteNumber(args[1], "clamp", 1);
8942
+ const hi = asFiniteNumber(args[2], "clamp", 2);
8943
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
8944
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
8945
+ }
8946
+ },
8947
+ avg: {
8948
+ minArgs: 1,
8949
+ maxArgs: INF,
8950
+ apply: (args) => {
8951
+ const nums = allFiniteNumbers(args, "avg");
8952
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
8953
+ }
8954
+ },
8955
+ sum: {
8956
+ minArgs: 1,
8957
+ maxArgs: INF,
8958
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
8959
+ },
8960
+ coalesce: {
8961
+ minArgs: 1,
8962
+ maxArgs: INF,
8963
+ apply: (args) => {
8964
+ for (const a of args) if (a !== null) return a;
8965
+ return null;
8966
+ }
8967
+ },
8968
+ age: {
8969
+ minArgs: 2,
8970
+ maxArgs: 2,
8971
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
8972
+ },
8973
+ convert: {
8974
+ minArgs: 3,
8975
+ maxArgs: 3,
8976
+ apply: (args, hooks) => {
8977
+ const x = asFiniteNumber(args[0], "convert", 0);
8978
+ const from = asString$1(args[1], "convert", 1).trim();
8979
+ const to = asString$1(args[2], "convert", 2).trim();
8980
+ if (hooks.convert) {
8981
+ const out = hooks.convert(x, from, to);
8982
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
8983
+ return finiteResult(out, "convert");
8984
+ }
8985
+ if (from === to) return x;
8986
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
8987
+ }
8988
+ }
8989
+ };
8990
+ Object.freeze(Object.assign(Object.create(null), table));
8991
+ /** The set of valid builtin names — used by the parser to reject unknown
8992
+ * callees at parse time (immediate author feedback). */
8993
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
8767
8994
  /**
8768
- * `addon-pages-source` — collection cap exposing per-provider raw page
8769
- * declarations. Every addon that contributes a UI page registers a
8770
- * provider here. The hub-side singleton aggregator (`addon-pages` cap,
8771
- * see `addon-pages.cap.ts`) walks this collection, stamps versioned
8772
- * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
8773
- * that admin-ui consumes.
8995
+ * Resource-bound constants for the safe expression engine.
8774
8996
  *
8775
- * The split exists because the public listing has a different output
8776
- * shape than the per-provider raw declarations, and we want both ends
8777
- * to flow through codegen instead of relying on a hand-written wrapper.
8997
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
8998
+ * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
8999
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
9000
+ * work a single author-supplied expression can request, so a hostile or
9001
+ * accidental pathological string can never spend unbounded CPU/memory.
8778
9002
  */
8779
- var AddonPageDeclarationSchema = object({
8780
- id: string(),
8781
- label: string(),
8782
- icon: string(),
8783
- path: string(),
8784
- /**
8785
- * Module Federation remote name — must match the `name` field on the
8786
- * page addon's `federation()` plugin config. Used by admin-ui's
8787
- * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
8788
- * Conventionally `addon_<id>_page` (snake_case; MF names cannot
8789
- * contain hyphens).
8790
- */
8791
- remoteName: string(),
8792
- /**
8793
- * Bundle filename inside the addon's `dist/` dir served at
8794
- * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
8795
- * is always `'remoteEntry.js'`; the value is kept on the metadata so
8796
- * the static-file route can compute an mtime-based cache-buster URL
8797
- * without a separate filesystem stat.
8798
- */
8799
- bundle: string(),
8800
- /**
8801
- * Sidebar section this page docks into. Well-known ids: `'detection'`,
8802
- * `'cluster'`, `'administration'` — the page renders inside that group.
8803
- * Any OTHER string creates (or joins) a custom section rendered after
8804
- * the built-in groups; its label comes from `sectionLabel` (first
8805
- * declaration wins), falling back to the id. Absent → the legacy
8806
- * "Addon Pages" group.
8807
- */
8808
- section: string().optional(),
8809
- /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
8810
- sectionLabel: string().optional()
8811
- });
8812
- method(_void(), array(AddonPageDeclarationSchema).readonly());
8813
- var AddonHttpRouteSchema = object({
8814
- method: _enum([
8815
- "GET",
8816
- "POST",
8817
- "PUT",
8818
- "DELETE",
8819
- "PATCH"
8820
- ]),
8821
- path: string(),
8822
- access: _enum([
8823
- "public",
8824
- "authenticated",
8825
- "admin"
8826
- ]).optional(),
8827
- description: string().optional()
8828
- });
8829
- /**
8830
- * Cross-process route invocation envelope. The hub captures the
8831
- * request as plain data, ships it to the worker via Moleculer, and
8832
- * the worker runs the local handler against a capturing reply. The
8833
- * envelope returned describes what the handler intended (status,
8834
- * headers, body, or a redirect) so the hub can translate it back to
8835
- * the Fastify reply that's actually wired to the socket.
8836
- */
8837
- var InvokeRequestSchema = object({
8838
- method: string(),
8839
- path: string(),
8840
- params: record(string(), string()),
8841
- query: record(string(), string()),
8842
- body: unknown(),
8843
- headers: record(string(), string()),
8844
- user: object({
8845
- id: string(),
8846
- username: string(),
8847
- isAdmin: boolean()
8848
- }).optional(),
8849
- scopedToken: unknown().optional()
8850
- });
8851
- var InvokeReplyEnvelopeSchema = object({
8852
- status: number().int(),
8853
- headers: record(string(), string()),
8854
- /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
8855
- * sending `body`. Status defaults to 302 when this is set unless
8856
- * the handler called `reply.code(...)` explicitly. */
8857
- redirectUrl: string().nullable(),
8858
- /** JSON-serializable body. `undefined` is treated as "no body". */
8859
- body: unknown().optional(),
8860
- /** Set when the handler called `reply.type(mime)`. */
8861
- contentType: string().optional()
8862
- });
8863
- method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
8864
- var ConfigTabDeclarationSchema = object({
8865
- id: string(),
8866
- label: string(),
8867
- icon: string(),
8868
- order: number().optional()
8869
- });
8870
- var ConfigSectionWithValuesSchema = object({
8871
- id: string(),
8872
- title: string(),
8873
- description: string().optional(),
8874
- style: _enum(["card", "accordion"]).optional(),
8875
- defaultCollapsed: boolean().optional(),
8876
- columns: union([
8877
- literal(1),
8878
- literal(2),
8879
- literal(3),
8880
- literal(4)
8881
- ]).optional(),
8882
- tab: string().optional(),
8883
- location: _enum(["settings", "top-tab"]).optional(),
8884
- order: number().optional(),
8885
- fields: array(any())
8886
- });
8887
- var SettingsSchemaWithValuesSchema = object({
8888
- tabs: array(ConfigTabDeclarationSchema).optional(),
8889
- sections: array(ConfigSectionWithValuesSchema)
8890
- });
8891
- /** Patch object — keys are field names, values are the new field values. */
8892
- var SettingsPatchSchema = record(string(), unknown());
8893
- /** Standard success response for update operations. */
8894
- var SettingsUpdateResultSchema = object({ success: literal(true) });
8895
- method(object({
8896
- addonId: string(),
8897
- nodeId: string().optional(),
8898
- overlay: record(string(), unknown()).optional(),
8899
- cap: string().optional()
8900
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8901
- addonId: string(),
8902
- nodeId: string().optional(),
8903
- patch: SettingsPatchSchema
8904
- }), SettingsUpdateResultSchema, {
8905
- kind: "mutation",
8906
- auth: "admin"
8907
- }), method(object({
8908
- addonId: string(),
8909
- deviceId: number(),
8910
- nodeId: string().optional()
8911
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8912
- addonId: string(),
8913
- deviceId: number(),
8914
- nodeId: string().optional(),
8915
- patch: SettingsPatchSchema
8916
- }), SettingsUpdateResultSchema, {
8917
- kind: "mutation",
8918
- auth: "admin"
8919
- });
8920
- /**
8921
- * `addon-widgets-source` — collection cap exposing per-addon raw widget
8922
- * declarations. Mirrors the addon-pages split: every addon shipping
8923
- * widgets registers a provider on this collection cap; the hub-local
8924
- * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
8925
- * collection, stamps versioned `bundleUrl`s onto each declaration, and
8926
- * exposes the public listing surface that admin-ui consumes.
8927
- *
8928
- * The split exists because the public listing has a different output
8929
- * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
8930
- * per-provider raw declarations. Both ends flow through codegen.
8931
- *
8932
- * Unified UI-contribution model (Task 10): a widget descriptor IS a
8933
- * `UiContribution` with `kind:'remote'`. The host renders it through the
8934
- * same `ContributionRenderer` / Module-Federation path as every other
8935
- * contributed UI surface — no bespoke widget-rendering path. The widget-
8936
- * only metadata (sizing hints, `requires`) lives as extra fields on the
8937
- * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
8938
- * `kind` / `remote`) carries identity + placement + the MF remote.
8939
- */
8940
- /** Where the widget makes sense to render — maps to a contribution `tab`. */
8941
- var WidgetHostEnum = _enum([
8942
- "device-tab",
8943
- "dashboard",
8944
- "integration-detail"
8945
- ]);
8946
- var WidgetSizeEnum = _enum([
8947
- "xs",
8948
- "sm",
8949
- "md",
8950
- "lg",
8951
- "xl"
9003
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
9004
+ * rejected without allocation. */
9005
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
9006
+ /** A legal binding / identifier name. */
9007
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
9008
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
9009
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
9010
+ var RESERVED_BINDING_NAMES = new Set([
9011
+ "now",
9012
+ "true",
9013
+ "false",
9014
+ "null"
8952
9015
  ]);
8953
9016
  /**
8954
- * MF remote descriptor — mirrors `UiContributionRemote` from
8955
- * `capability-definition.ts`. Widget remotes expose a single
8956
- * `'./widgets'` module whose default export is a
8957
- * `Record<componentKey, Component>` map; `componentKey` (the widget
8958
- * `stableId`) picks the entry the host mounts.
8959
- */
8960
- var WidgetRemoteSchema = object({
8961
- remoteName: string(),
8962
- exposedModule: string(),
8963
- componentKey: string().optional()
8964
- });
8965
- /**
8966
- * One widget declaration — a `UiContribution` (`kind:'remote'`) plus
8967
- * widget-only metadata. The `UiContribution` core fields:
8968
- *
8969
- * - `tab` — where the widget hosts. A widget that runs on the
8970
- * dashboard declares `tab:'dashboard'`; a device-tab
8971
- * widget declares the target device-detail tab id.
8972
- * - `subTab` — optional sub-tab within `tab`.
8973
- * - `label` — operator-facing label.
8974
- * - `order` — ordering within `(tab, subTab)`.
8975
- * - `kind` — always `'remote'` for widgets.
8976
- * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
8977
- *
8978
- * Widget-only fields retained alongside the contribution core:
8979
- *
8980
- * - `stableId` — stable identity within the addon (the MF
8981
- * `componentKey`; kept top-level so consumers have
8982
- * a stable key without reaching into `remote`).
8983
- * - `description` / `icon` — picker metadata.
8984
- * - `bundle` — entry filename inside the addon `dist/` dir; the
8985
- * aggregator stamps a versioned `bundleUrl` from it.
8986
- * - `hosts` — every host the widget supports (a widget can run
8987
- * both on the dashboard and a device tab). `tab`
8988
- * is the PRIMARY host; `hosts` is the full set the
8989
- * picker filters on.
8990
- * - `requires` — host-context requirements validated at mount.
8991
- * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
8992
- * — dashboard placement hints.
9017
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
9018
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
9019
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
9020
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
9021
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
9022
+ * is a parse error with a source position, so member access / assignment /
9023
+ * template literals are lexically impossible.
8993
9024
  */
8994
- var WidgetMetadataSchema = object({
8995
- /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
8996
- tab: string(),
8997
- /** Optional sub-tab within `tab`. */
8998
- subTab: string().optional(),
8999
- /** Operator-facing label. */
9000
- label: string(),
9001
- /** Ordering within `(tab, subTab)`, ascending. */
9002
- order: number().optional(),
9003
- /** Always `'remote'` — a widget is a Module Federation remote. */
9004
- kind: literal("remote"),
9005
- /** MF remote descriptor. */
9006
- remote: WidgetRemoteSchema,
9007
- /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
9008
- stableId: string(),
9009
- description: string().optional(),
9010
- icon: string().optional(),
9011
- /**
9012
- * Bundle filename inside the addon's `dist/` dir served at
9013
- * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
9014
- * this is always `'remoteEntry.js'` — the value is kept on the
9015
- * metadata so the static-file route can compute an mtime-based
9016
- * cache-buster URL without a separate filesystem stat.
9017
- */
9018
- bundle: string(),
9019
- /** Every host the widget supports. The picker filters on this set. */
9020
- hosts: array(WidgetHostEnum).readonly(),
9021
- /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
9022
- requires: object({
9023
- deviceContext: boolean().default(false),
9024
- integrationContext: boolean().default(false)
9025
- }),
9026
- /**
9027
- * Loadable BEFORE authentication. The normal widget registry listing
9028
- * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
9029
- * (the login page) cannot discover a widget through it. A widget that
9030
- * declares `preAuth: true` marks itself as safe to mount on a pre-auth
9031
- * screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
9032
- * login-method contribution channel (see `login-method.cap.ts`) rather
9033
- * than the authenticated registry, and its bundle is served by the
9034
- * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
9035
- */
9036
- preAuth: boolean().optional().default(false),
9037
- /** Dashboard placement HINTS (operator can override per instance). */
9038
- defaultSize: WidgetSizeEnum.default("md"),
9039
- allowedSizes: array(WidgetSizeEnum).readonly().default([
9040
- "sm",
9041
- "md",
9042
- "lg"
9043
- ]),
9044
- defaultColumns: number().int().min(1).max(12).default(6),
9045
- defaultRows: number().int().min(1).max(12).default(1)
9046
- });
9047
- var addonWidgetsSourceCapability = {
9048
- name: "addon-widgets-source",
9049
- scope: "system",
9050
- mode: "collection",
9051
- internal: true,
9052
- methods: { listWidgets: method(_void(), array(WidgetMetadataSchema).readonly()) }
9053
- };
9025
+ var KEYWORDS = new Set([
9026
+ "true",
9027
+ "false",
9028
+ "null"
9029
+ ]);
9030
+ function isDigit(ch) {
9031
+ return ch >= "0" && ch <= "9";
9032
+ }
9033
+ function isIdentStart(ch) {
9034
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
9035
+ }
9036
+ function isIdentPart(ch) {
9037
+ return isIdentStart(ch) || isDigit(ch);
9038
+ }
9039
+ function isWhitespace(ch) {
9040
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
9041
+ }
9042
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
9043
+ * Throws `ExpressionParseError` on any illegal character or unterminated
9044
+ * string. */
9045
+ function tokenize(source) {
9046
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
9047
+ const tokens = [];
9048
+ let i = 0;
9049
+ const n = source.length;
9050
+ while (i < n) {
9051
+ const ch = source[i];
9052
+ if (isWhitespace(ch)) {
9053
+ i += 1;
9054
+ continue;
9055
+ }
9056
+ if (isDigit(ch)) {
9057
+ const start = i;
9058
+ while (i < n && isDigit(source[i])) i += 1;
9059
+ if (i < n && source[i] === ".") {
9060
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
9061
+ i += 1;
9062
+ while (i < n && isDigit(source[i])) i += 1;
9063
+ }
9064
+ const text = source.slice(start, i);
9065
+ const value = Number(text);
9066
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
9067
+ tokens.push({
9068
+ type: "number",
9069
+ value,
9070
+ pos: start
9071
+ });
9072
+ continue;
9073
+ }
9074
+ if (ch === "'" || ch === "\"") {
9075
+ const quote = ch;
9076
+ const start = i;
9077
+ i += 1;
9078
+ let out = "";
9079
+ let closed = false;
9080
+ while (i < n) {
9081
+ const c = source[i];
9082
+ if (c === "\\") {
9083
+ const next = i + 1 < n ? source[i + 1] : "";
9084
+ if (next === "\\" || next === "'" || next === "\"") {
9085
+ out += next;
9086
+ i += 2;
9087
+ continue;
9088
+ }
9089
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
9090
+ }
9091
+ if (c === quote) {
9092
+ closed = true;
9093
+ i += 1;
9094
+ break;
9095
+ }
9096
+ out += c;
9097
+ i += 1;
9098
+ }
9099
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
9100
+ tokens.push({
9101
+ type: "string",
9102
+ value: out,
9103
+ pos: start
9104
+ });
9105
+ continue;
9106
+ }
9107
+ if (isIdentStart(ch)) {
9108
+ const start = i;
9109
+ while (i < n && isIdentPart(source[i])) i += 1;
9110
+ const text = source.slice(start, i);
9111
+ if (KEYWORDS.has(text)) tokens.push({
9112
+ type: "keyword",
9113
+ keyword: keywordOf(text),
9114
+ pos: start
9115
+ });
9116
+ else tokens.push({
9117
+ type: "identifier",
9118
+ name: text,
9119
+ pos: start
9120
+ });
9121
+ continue;
9122
+ }
9123
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
9124
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
9125
+ tokens.push({
9126
+ type: "punct",
9127
+ punct: two,
9128
+ pos: i
9129
+ });
9130
+ i += 2;
9131
+ continue;
9132
+ }
9133
+ if (isSinglePunct(ch)) {
9134
+ tokens.push({
9135
+ type: "punct",
9136
+ punct: ch,
9137
+ pos: i
9138
+ });
9139
+ i += 1;
9140
+ continue;
9141
+ }
9142
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
9143
+ }
9144
+ tokens.push({
9145
+ type: "eof",
9146
+ pos: n
9147
+ });
9148
+ return tokens;
9149
+ }
9150
+ function keywordOf(text) {
9151
+ if (text === "true") return "true";
9152
+ if (text === "false") return "false";
9153
+ return "null";
9154
+ }
9155
+ function isSinglePunct(ch) {
9156
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
9157
+ }
9054
9158
  /**
9055
- * `addon-widgets` — system-scoped singleton aggregator cap. Public-facing
9056
- * surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
9057
- *
9058
- * The provider iterates every `addon-widgets-source` (collection)
9059
- * provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
9060
- * `bundleUrl` strings pointing at
9061
- * `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
9062
- * `mtime` cache-buster lets the browser pick up addon rebuilds without
9063
- * manual reload — same scheme used by `addon-pages`.
9159
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
9064
9160
  *
9065
- * The hub-local builtin `addon-widgets-aggregator` (see
9066
- * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
9067
- * provider. Splitting the public aggregator from the raw collection
9068
- * keeps both ends in codegen — there's no hand-written wrapper.
9069
- */
9070
- var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
9071
- addonId: string(),
9072
- bundleUrl: string()
9073
- });
9074
- method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
9075
- /**
9076
- * Alerts capability — collection-based internal alert system.
9161
+ * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
9162
+ * → relational → additive → multiplicative → unary `! -` → call / primary.
9163
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
9164
+ * string validated against the builtin table at parse time, so an unknown
9165
+ * function is rejected immediately (author feedback) and a persisted expression
9166
+ * that references a since-removed builtin degrades at read.
9077
9167
  *
9078
- * Multiple providers can register. Each provider filters by EventBus category
9079
- * and creates/updates alerts. The built-in Alert Center addon persists alerts
9080
- * in the DB and serves them to the admin UI.
9081
- */
9082
- var AlertSeveritySchema = _enum([
9083
- "info",
9084
- "success",
9085
- "warning",
9086
- "error"
9087
- ]);
9088
- var AlertStatusSchema = _enum([
9089
- "active",
9090
- "in-progress",
9091
- "completed",
9092
- "failed",
9093
- "dismissed"
9094
- ]);
9095
- var AlertSourceSchema = object({
9096
- type: string(),
9097
- id: string()
9098
- });
9099
- var AlertSchema = object({
9100
- id: string(),
9101
- category: string(),
9102
- severity: AlertSeveritySchema,
9103
- title: string(),
9104
- message: string(),
9105
- status: AlertStatusSchema,
9106
- progress: number().optional(),
9107
- read: boolean(),
9108
- createdAt: number(),
9109
- updatedAt: number(),
9110
- source: AlertSourceSchema.optional(),
9111
- metadata: record(string(), unknown()).optional()
9112
- });
9113
- method(AlertSchema, _void(), { kind: "mutation" }), method(object({
9114
- alertId: string(),
9115
- patch: AlertSchema.partial()
9116
- }), _void(), { kind: "mutation" }), method(object({
9117
- unreadOnly: boolean().optional(),
9118
- limit: number().optional()
9119
- }).optional(), array(AlertSchema).readonly()), method(_void(), number()), method(object({ alertId: string() }), _void(), { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(object({ alertId: string() }), _void(), { kind: "mutation" });
9120
- DeviceType.Camera, method(object({ deviceId: number() }), custom()), object({
9121
- deviceId: number(),
9122
- rms: number(),
9123
- dbfs: number()
9124
- });
9125
- /** Shared Zod schemas used across detection capabilities. */
9126
- /**
9127
- * Canonical frame-format enum mirrored on `FrameFormat` in
9128
- * `packages/types/src/types/io.ts`. Kept inline (vs imported) so the
9129
- * Zod runtime schema and TypeScript type stay in sync at the call site
9130
- * — adding a new format requires changing both this enum and the
9131
- * `FrameFormat` type alias together.
9168
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
9169
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
9132
9170
  */
9133
- var FrameFormatSchema = _enum([
9134
- "jpeg",
9135
- "rgb",
9136
- "bgr",
9137
- "yuv420",
9138
- "gray"
9139
- ]);
9140
- var FrameInputSchema = object({
9141
- data: custom(),
9142
- format: FrameFormatSchema,
9143
- width: number(),
9144
- height: number(),
9145
- timestamp: number()
9146
- });
9147
- var BoundingBoxSchema = object({
9148
- x: number(),
9149
- y: number(),
9150
- w: number(),
9151
- h: number()
9152
- });
9153
- object({
9154
- class: string(),
9155
- originalClass: string(),
9156
- score: number(),
9157
- bbox: BoundingBoxSchema
9158
- });
9159
- /**
9160
- * `data` carries the raw f32le bytes of the PCM samples (4 bytes per
9161
- * sample, little-endian IEEE 754). `Uint8Array` is the wire-safe choice
9162
- * because `@msgpack/msgpack` serialises it as a MsgPack `bin` type that
9163
- * round-trips losslessly over the UDS transport. `Float32Array` is NOT
9164
- * preserved — the encoder serialises it as `bin` (its raw bytes) but
9165
- * the decoder returns `Uint8Array`, so treating a `Float32Array` as the
9166
- * wire type causes receivers to read bytes as sample values, producing
9167
- * wildly wrong RMS/dBFS results (~+43 dBFS instead of ≤0).
9168
- *
9169
- * Callers that need float arithmetic reconstruct the view with:
9170
- * `new Float32Array(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength / 4)`
9171
+ /** Binary/logical operator precedence (higher binds tighter). */
9172
+ var BINARY_PRECEDENCE = {
9173
+ "||": 1,
9174
+ "&&": 2,
9175
+ "==": 3,
9176
+ "!=": 3,
9177
+ "<": 4,
9178
+ "<=": 4,
9179
+ ">": 4,
9180
+ ">=": 4,
9181
+ "+": 5,
9182
+ "-": 5,
9183
+ "*": 6,
9184
+ "/": 6,
9185
+ "%": 6
9186
+ };
9187
+ function isLogicalOp(op) {
9188
+ return op === "&&" || op === "||";
9189
+ }
9190
+ function isBinaryOp(op) {
9191
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
9192
+ }
9193
+ var Parser = class {
9194
+ tokens;
9195
+ pos = 0;
9196
+ nodeCount = 0;
9197
+ identifiers = /* @__PURE__ */ new Set();
9198
+ callees = /* @__PURE__ */ new Set();
9199
+ constructor(tokens) {
9200
+ this.tokens = tokens;
9201
+ }
9202
+ parse() {
9203
+ const ast = this.parseTernary();
9204
+ const tok = this.peek();
9205
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
9206
+ return {
9207
+ ast,
9208
+ identifiers: this.identifiers,
9209
+ callees: this.callees,
9210
+ nodeCount: this.nodeCount
9211
+ };
9212
+ }
9213
+ peek() {
9214
+ return this.tokens[this.pos];
9215
+ }
9216
+ next() {
9217
+ return this.tokens[this.pos++];
9218
+ }
9219
+ /** Consume a punctuator token, erroring if the next token isn't it. */
9220
+ expectPunct(punct) {
9221
+ const tok = this.peek();
9222
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
9223
+ this.pos += 1;
9224
+ }
9225
+ matchPunct(punct) {
9226
+ const tok = this.peek();
9227
+ if (tok.type === "punct" && tok.punct === punct) {
9228
+ this.pos += 1;
9229
+ return true;
9230
+ }
9231
+ return false;
9232
+ }
9233
+ countNode() {
9234
+ this.nodeCount += 1;
9235
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
9236
+ }
9237
+ parseTernary() {
9238
+ const test = this.parseBinary(1);
9239
+ if (this.matchPunct("?")) {
9240
+ const consequent = this.parseTernary();
9241
+ this.expectPunct(":");
9242
+ const alternate = this.parseTernary();
9243
+ this.countNode();
9244
+ return {
9245
+ kind: "conditional",
9246
+ test,
9247
+ consequent,
9248
+ alternate
9249
+ };
9250
+ }
9251
+ return test;
9252
+ }
9253
+ parseBinary(minPrec) {
9254
+ let left = this.parseUnary();
9255
+ for (;;) {
9256
+ const tok = this.peek();
9257
+ if (tok.type !== "punct") break;
9258
+ const prec = BINARY_PRECEDENCE[tok.punct];
9259
+ if (prec === void 0 || prec < minPrec) break;
9260
+ const op = tok.punct;
9261
+ this.pos += 1;
9262
+ const right = this.parseBinary(prec + 1);
9263
+ this.countNode();
9264
+ if (isLogicalOp(op)) left = {
9265
+ kind: "logical",
9266
+ op,
9267
+ left,
9268
+ right
9269
+ };
9270
+ else if (isBinaryOp(op)) left = {
9271
+ kind: "binary",
9272
+ op,
9273
+ left,
9274
+ right
9275
+ };
9276
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
9277
+ }
9278
+ return left;
9279
+ }
9280
+ parseUnary() {
9281
+ const tok = this.peek();
9282
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
9283
+ const op = tok.punct;
9284
+ this.pos += 1;
9285
+ const operand = this.parseUnary();
9286
+ this.countNode();
9287
+ return {
9288
+ kind: "unary",
9289
+ op,
9290
+ operand
9291
+ };
9292
+ }
9293
+ return this.parsePrimary();
9294
+ }
9295
+ parsePrimary() {
9296
+ const tok = this.next();
9297
+ switch (tok.type) {
9298
+ case "number":
9299
+ this.countNode();
9300
+ return {
9301
+ kind: "literal",
9302
+ value: tok.value
9303
+ };
9304
+ case "string":
9305
+ this.countNode();
9306
+ return {
9307
+ kind: "literal",
9308
+ value: tok.value
9309
+ };
9310
+ case "keyword":
9311
+ this.countNode();
9312
+ return {
9313
+ kind: "literal",
9314
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
9315
+ };
9316
+ case "identifier": {
9317
+ const nextTok = this.peek();
9318
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
9319
+ this.identifiers.add(tok.name);
9320
+ this.countNode();
9321
+ return {
9322
+ kind: "identifier",
9323
+ name: tok.name
9324
+ };
9325
+ }
9326
+ case "punct":
9327
+ if (tok.punct === "(") {
9328
+ const inner = this.parseTernary();
9329
+ this.expectPunct(")");
9330
+ return inner;
9331
+ }
9332
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
9333
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
9334
+ }
9335
+ }
9336
+ parseCall(callee, pos) {
9337
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
9338
+ this.expectPunct("(");
9339
+ const args = [];
9340
+ if (!this.matchPunct(")")) for (;;) {
9341
+ args.push(this.parseTernary());
9342
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
9343
+ if (this.matchPunct(",")) continue;
9344
+ this.expectPunct(")");
9345
+ break;
9346
+ }
9347
+ this.callees.add(callee);
9348
+ this.countNode();
9349
+ return {
9350
+ kind: "call",
9351
+ callee,
9352
+ args
9353
+ };
9354
+ }
9355
+ };
9356
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
9357
+ * `ExpressionParseError` on any lexical or grammatical failure. */
9358
+ function parseExpression(source) {
9359
+ return new Parser(tokenize(source)).parse();
9360
+ }
9361
+ /**
9362
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
9363
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
9364
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
9365
+ * one per read on a hot resolve path.
9366
+ *
9367
+ * The cache is a module-level singleton: entries are pure, content-addressed
9368
+ * ASTs keyed by the raw source string, so sharing one instance across all
9369
+ * callers is safe and maximises hit rate.
9370
+ */
9371
+ var cache = /* @__PURE__ */ new Map();
9372
+ function getCached(source) {
9373
+ const hit = cache.get(source);
9374
+ if (hit !== void 0) {
9375
+ cache.delete(source);
9376
+ cache.set(source, hit);
9377
+ return hit;
9378
+ }
9379
+ let result;
9380
+ try {
9381
+ result = {
9382
+ ok: true,
9383
+ parsed: parseExpression(source)
9384
+ };
9385
+ } catch (err) {
9386
+ result = {
9387
+ ok: false,
9388
+ error: err instanceof ExpressionParseError ? err.message : String(err)
9389
+ };
9390
+ }
9391
+ cache.set(source, result);
9392
+ if (cache.size > 256) {
9393
+ const oldest = cache.keys().next().value;
9394
+ if (oldest !== void 0) cache.delete(oldest);
9395
+ }
9396
+ return result;
9397
+ }
9398
+ /** Compile `source`, returning a discriminated result instead of throwing.
9399
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
9400
+ function compileExpressionSafe(source) {
9401
+ return getCached(source);
9402
+ }
9403
+ Object.freeze({});
9404
+ /**
9405
+ * Author-time validation. Returns `null` when the source is valid, else a
9406
+ * human-readable error message. Checks: the expression compiles; binding count
9407
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
9408
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
9409
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
9410
+ */
9411
+ function validateExpressionSource(src) {
9412
+ const names = Object.keys(src.bindings);
9413
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
9414
+ for (const name of names) {
9415
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
9416
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
9417
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
9418
+ }
9419
+ const compiled = compileExpressionSafe(src.expr);
9420
+ if (!compiled.ok) return compiled.error;
9421
+ const bound = new Set(names);
9422
+ for (const id of compiled.parsed.identifiers) {
9423
+ if (id === "now") continue;
9424
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
9425
+ }
9426
+ return null;
9427
+ }
9428
+ var ExpressionBindingSourceSchema = union([
9429
+ object({
9430
+ kind: literal("field").optional(),
9431
+ sourceKey: string(),
9432
+ cap: string(),
9433
+ fieldPath: string()
9434
+ }),
9435
+ object({
9436
+ kind: literal("literal"),
9437
+ value: union([
9438
+ string(),
9439
+ number(),
9440
+ boolean(),
9441
+ _null()
9442
+ ])
9443
+ }),
9444
+ object({
9445
+ kind: literal("global"),
9446
+ sourceStableId: string(),
9447
+ cap: string(),
9448
+ fieldPath: string()
9449
+ })
9450
+ ]);
9451
+ object({
9452
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
9453
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
9454
+ }).superRefine((src, ctx) => {
9455
+ const err = validateExpressionSource(src);
9456
+ if (err !== null) ctx.addIssue({
9457
+ code: "custom",
9458
+ message: err,
9459
+ path: ["expr"]
9460
+ });
9461
+ });
9462
+ /** How a leaf compares a device field to a value. Derived from the field's
9463
+ * `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
9464
+ var AutomationConditionOperatorSchema = _enum([
9465
+ "eq",
9466
+ "ne",
9467
+ "gt",
9468
+ "gte",
9469
+ "lt",
9470
+ "lte",
9471
+ "contains",
9472
+ "in"
9473
+ ]);
9474
+ var AutomationConditionLeafSchema = object({
9475
+ kind: literal("condition"),
9476
+ deviceId: number().int().nonnegative(),
9477
+ cap: string().min(1),
9478
+ fieldPath: string().min(1),
9479
+ operator: AutomationConditionOperatorSchema,
9480
+ value: union([
9481
+ string(),
9482
+ number(),
9483
+ boolean(),
9484
+ array(union([string(), number()]))
9485
+ ])
9486
+ });
9487
+ /**
9488
+ * The expression leaf, declared as a plain object rather than an intersection
9489
+ * with {@link ExpressionSourceSchema}: a discriminated union has to be able to
9490
+ * read `kind` off each option, and an intersection hides it. The author-time
9491
+ * validation is the SAME function `ExpressionSourceSchema` runs, so the two
9492
+ * cannot drift — an expression that one accepts, the other accepts.
9493
+ */
9494
+ var AutomationConditionExpressionSchema = object({
9495
+ kind: literal("expression"),
9496
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
9497
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
9498
+ }).superRefine((src, ctx) => {
9499
+ const err = validateExpressionSource(src);
9500
+ if (err !== null) ctx.addIssue({
9501
+ code: "custom",
9502
+ message: err,
9503
+ path: ["expr"]
9504
+ });
9505
+ });
9506
+ var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
9507
+ object({
9508
+ kind: literal("all"),
9509
+ children: array(AutomationConditionSchema)
9510
+ }),
9511
+ object({
9512
+ kind: literal("any"),
9513
+ children: array(AutomationConditionSchema)
9514
+ }),
9515
+ object({
9516
+ kind: literal("not"),
9517
+ child: AutomationConditionSchema
9518
+ }),
9519
+ AutomationConditionLeafSchema,
9520
+ AutomationConditionExpressionSchema
9521
+ ]));
9522
+ /**
9523
+ * What starts a run.
9524
+ *
9525
+ * D8 compliance, and it is the reason `device-state` is not merely an event
9526
+ * subscription: the trigger evaluates against the **state mirror**, which is
9527
+ * reconciled, and an event only WAKES the evaluation. A dropped event therefore
9528
+ * DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
9529
+ * already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
9530
+ * not.
9531
+ */
9532
+ var AutomationTriggerSchema = discriminatedUnion("kind", [
9533
+ object({
9534
+ kind: literal("device-state"),
9535
+ deviceId: number().int().nonnegative(),
9536
+ cap: string().min(1),
9537
+ fieldPath: string().min(1),
9538
+ /** Fire when the field takes this value. Omit to fire on any change. */
9539
+ becomes: union([
9540
+ string(),
9541
+ number(),
9542
+ boolean()
9543
+ ]).optional(),
9544
+ /** Only on a CHANGE of value, not on every re-report. */
9545
+ edge: boolean().optional(),
9546
+ /** The condition must hold this long before the run starts. */
9547
+ forMs: number().int().min(0).max(864e5).optional(),
9548
+ /** Collapse a burst into one run. */
9549
+ debounceMs: number().int().min(0).max(6e5).optional()
9550
+ }),
9551
+ object({
9552
+ kind: literal("device-event"),
9553
+ /** An `EventCategory` value. */
9554
+ category: string().min(1),
9555
+ deviceId: number().int().nonnegative().optional()
9556
+ }),
9557
+ object({
9558
+ kind: literal("schedule"),
9559
+ cron: string().min(1).max(120)
9560
+ }),
9561
+ object({ kind: literal("manual") })
9562
+ ]);
9563
+ /**
9564
+ * One action step.
9565
+ *
9566
+ * `wait` and `cap` are `NcRuleActionSchema`'s two members, kept structurally
9567
+ * identical so `NcRuleActionRunner` runs them unchanged — its device-scope
9568
+ * check, stop-at-first-failure and per-sequence throttle are the whole reason
9569
+ * to reuse it, and none of them are re-implemented here.
9570
+ *
9571
+ * **The one divergence, and it is forced.** `NcRuleActionSchema.cap.deviceId` is
9572
+ * a literal `z.number().int()`, and the NC runner's own `RunSequencesInput`
9573
+ * documents its subject device as *"for the log tag, never for routing"*. So an
9574
+ * NC action can never target the device that triggered it — which is fine for
9575
+ * the NC (its rules already scope to a device) and fatal for an automation
9576
+ * ("sound the siren of the camera that saw the person"). `deviceId` therefore
9577
+ * also accepts `{ $var }`, resolved from the run's `vars` bag BEFORE the runner
9578
+ * is called. The runner still receives a number and is untouched; the
9579
+ * resolution is the recipe's job, not the runner's.
9580
+ */
9581
+ var AutomationActionSchema = discriminatedUnion("kind", [
9582
+ object({
9583
+ kind: literal("wait"),
9584
+ seconds: number().min(0).max(300)
9585
+ }),
9586
+ object({
9587
+ kind: literal("cap"),
9588
+ deviceId: union([number().int(), object({ $var: string().min(1) })]),
9589
+ cap: string().min(1),
9590
+ method: string().min(1),
9591
+ /** Values may carry `{{vars.x}}` slots, which SUBSTITUTE and do not
9592
+ * evaluate (§3.2.3). Anything beyond substitution is the expression leaf. */
9593
+ args: record(string(), unknown()).optional()
9594
+ }),
9595
+ object({
9596
+ kind: literal("code"),
9597
+ /** Compiled into the automation's OWN block by esbuild — not a third
9598
+ * runtime, not a `vm`, and not dynamically evaluated. */
9599
+ code: string().min(1).max(2e4)
9600
+ })
9601
+ ]);
9602
+ object({
9603
+ triggers: array(AutomationTriggerSchema),
9604
+ conditions: AutomationConditionSchema.optional(),
9605
+ actions: array(AutomationActionSchema)
9606
+ });
9607
+ /**
9608
+ * `addon-pages` — system-scoped singleton aggregator cap. Public-facing
9609
+ * surface that admin-ui consumes through `useAddonPagesListPages()`.
9610
+ *
9611
+ * The provider iterates every `addon-pages-source` (collection) provider
9612
+ * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
9613
+ * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
9614
+ * filesystem `mtime` cache-buster lets the browser pick up addon
9615
+ * rebuilds without manual reload.
9616
+ *
9617
+ * The hub-local builtin `addon-pages-aggregator` (see
9618
+ * `@camstack/system/builtins/addon-pages-aggregator`) registers the
9619
+ * provider. Splitting the public aggregator from the raw collection
9620
+ * keeps both ends in codegen — there's no hand-written
9621
+ * `addon-pages.router.ts` wrapper anymore.
9622
+ */
9623
+ var AddonPageDeclarationSchema$1 = object({
9624
+ id: string(),
9625
+ label: string(),
9626
+ icon: string(),
9627
+ path: string(),
9628
+ remoteName: string(),
9629
+ bundle: string(),
9630
+ section: string().optional(),
9631
+ sectionLabel: string().optional()
9632
+ });
9633
+ var AddonPageInfoSchema = object({
9634
+ addonId: string(),
9635
+ page: AddonPageDeclarationSchema$1,
9636
+ bundleUrl: string()
9637
+ });
9638
+ method(_void(), array(AddonPageInfoSchema).readonly());
9639
+ /**
9640
+ * `addon-pages-source` — collection cap exposing per-provider raw page
9641
+ * declarations. Every addon that contributes a UI page registers a
9642
+ * provider here. The hub-side singleton aggregator (`addon-pages` cap,
9643
+ * see `addon-pages.cap.ts`) walks this collection, stamps versioned
9644
+ * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
9645
+ * that admin-ui consumes.
9646
+ *
9647
+ * The split exists because the public listing has a different output
9648
+ * shape than the per-provider raw declarations, and we want both ends
9649
+ * to flow through codegen instead of relying on a hand-written wrapper.
9650
+ */
9651
+ var AddonPageDeclarationSchema = object({
9652
+ id: string(),
9653
+ label: string(),
9654
+ icon: string(),
9655
+ path: string(),
9656
+ /**
9657
+ * Module Federation remote name — must match the `name` field on the
9658
+ * page addon's `federation()` plugin config. Used by admin-ui's
9659
+ * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
9660
+ * Conventionally `addon_<id>_page` (snake_case; MF names cannot
9661
+ * contain hyphens).
9662
+ */
9663
+ remoteName: string(),
9664
+ /**
9665
+ * Bundle filename inside the addon's `dist/` dir served at
9666
+ * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
9667
+ * is always `'remoteEntry.js'`; the value is kept on the metadata so
9668
+ * the static-file route can compute an mtime-based cache-buster URL
9669
+ * without a separate filesystem stat.
9670
+ */
9671
+ bundle: string(),
9672
+ /**
9673
+ * Sidebar section this page docks into. Well-known ids: `'detection'`,
9674
+ * `'cluster'`, `'administration'` — the page renders inside that group.
9675
+ * Any OTHER string creates (or joins) a custom section rendered after
9676
+ * the built-in groups; its label comes from `sectionLabel` (first
9677
+ * declaration wins), falling back to the id. Absent → the legacy
9678
+ * "Addon Pages" group.
9679
+ */
9680
+ section: string().optional(),
9681
+ /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
9682
+ sectionLabel: string().optional()
9683
+ });
9684
+ method(_void(), array(AddonPageDeclarationSchema).readonly());
9685
+ var AddonHttpRouteSchema = object({
9686
+ method: _enum([
9687
+ "GET",
9688
+ "POST",
9689
+ "PUT",
9690
+ "DELETE",
9691
+ "PATCH"
9692
+ ]),
9693
+ path: string(),
9694
+ access: _enum([
9695
+ "public",
9696
+ "authenticated",
9697
+ "admin"
9698
+ ]).optional(),
9699
+ description: string().optional()
9700
+ });
9701
+ /**
9702
+ * Cross-process route invocation envelope. The hub captures the
9703
+ * request as plain data, ships it to the worker via Moleculer, and
9704
+ * the worker runs the local handler against a capturing reply. The
9705
+ * envelope returned describes what the handler intended (status,
9706
+ * headers, body, or a redirect) so the hub can translate it back to
9707
+ * the Fastify reply that's actually wired to the socket.
9708
+ */
9709
+ var InvokeRequestSchema = object({
9710
+ method: string(),
9711
+ path: string(),
9712
+ params: record(string(), string()),
9713
+ query: record(string(), string()),
9714
+ body: unknown(),
9715
+ headers: record(string(), string()),
9716
+ user: object({
9717
+ id: string(),
9718
+ username: string(),
9719
+ isAdmin: boolean()
9720
+ }).optional(),
9721
+ scopedToken: unknown().optional()
9722
+ });
9723
+ var InvokeReplyEnvelopeSchema = object({
9724
+ status: number().int(),
9725
+ headers: record(string(), string()),
9726
+ /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
9727
+ * sending `body`. Status defaults to 302 when this is set unless
9728
+ * the handler called `reply.code(...)` explicitly. */
9729
+ redirectUrl: string().nullable(),
9730
+ /** JSON-serializable body. `undefined` is treated as "no body". */
9731
+ body: unknown().optional(),
9732
+ /** Set when the handler called `reply.type(mime)`. */
9733
+ contentType: string().optional()
9734
+ });
9735
+ method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
9736
+ var ConfigTabDeclarationSchema = object({
9737
+ id: string(),
9738
+ label: string(),
9739
+ icon: string(),
9740
+ order: number().optional()
9741
+ });
9742
+ var ConfigSectionWithValuesSchema = object({
9743
+ id: string(),
9744
+ title: string(),
9745
+ description: string().optional(),
9746
+ style: _enum(["card", "accordion"]).optional(),
9747
+ defaultCollapsed: boolean().optional(),
9748
+ columns: union([
9749
+ literal(1),
9750
+ literal(2),
9751
+ literal(3),
9752
+ literal(4)
9753
+ ]).optional(),
9754
+ tab: string().optional(),
9755
+ location: _enum(["settings", "top-tab"]).optional(),
9756
+ order: number().optional(),
9757
+ fields: array(any())
9758
+ });
9759
+ var SettingsSchemaWithValuesSchema = object({
9760
+ tabs: array(ConfigTabDeclarationSchema).optional(),
9761
+ sections: array(ConfigSectionWithValuesSchema)
9762
+ });
9763
+ /** Patch object — keys are field names, values are the new field values. */
9764
+ var SettingsPatchSchema = record(string(), unknown());
9765
+ /** Standard success response for update operations. */
9766
+ var SettingsUpdateResultSchema = object({ success: literal(true) });
9767
+ method(object({
9768
+ addonId: string(),
9769
+ nodeId: string().optional(),
9770
+ overlay: record(string(), unknown()).optional(),
9771
+ cap: string().optional()
9772
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9773
+ addonId: string(),
9774
+ nodeId: string().optional(),
9775
+ patch: SettingsPatchSchema
9776
+ }), SettingsUpdateResultSchema, {
9777
+ kind: "mutation",
9778
+ auth: "admin"
9779
+ }), method(object({
9780
+ addonId: string(),
9781
+ deviceId: number(),
9782
+ nodeId: string().optional()
9783
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9784
+ addonId: string(),
9785
+ deviceId: number(),
9786
+ nodeId: string().optional(),
9787
+ patch: SettingsPatchSchema
9788
+ }), SettingsUpdateResultSchema, {
9789
+ kind: "mutation",
9790
+ auth: "admin"
9791
+ });
9792
+ /**
9793
+ * `addon-widgets-source` — collection cap exposing per-addon raw widget
9794
+ * declarations. Mirrors the addon-pages split: every addon shipping
9795
+ * widgets registers a provider on this collection cap; the hub-local
9796
+ * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
9797
+ * collection, stamps versioned `bundleUrl`s onto each declaration, and
9798
+ * exposes the public listing surface that admin-ui consumes.
9799
+ *
9800
+ * The split exists because the public listing has a different output
9801
+ * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
9802
+ * per-provider raw declarations. Both ends flow through codegen.
9803
+ *
9804
+ * Unified UI-contribution model (Task 10): a widget descriptor IS a
9805
+ * `UiContribution` with `kind:'remote'`. The host renders it through the
9806
+ * same `ContributionRenderer` / Module-Federation path as every other
9807
+ * contributed UI surface — no bespoke widget-rendering path. The widget-
9808
+ * only metadata (sizing hints, `requires`) lives as extra fields on the
9809
+ * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
9810
+ * `kind` / `remote`) carries identity + placement + the MF remote.
9811
+ */
9812
+ /** Where the widget makes sense to render — maps to a contribution `tab`. */
9813
+ var WidgetHostEnum = _enum([
9814
+ "device-tab",
9815
+ "dashboard",
9816
+ "integration-detail"
9817
+ ]);
9818
+ var WidgetSizeEnum = _enum([
9819
+ "xs",
9820
+ "sm",
9821
+ "md",
9822
+ "lg",
9823
+ "xl"
9824
+ ]);
9825
+ /**
9826
+ * MF remote descriptor — mirrors `UiContributionRemote` from
9827
+ * `capability-definition.ts`. Widget remotes expose a single
9828
+ * `'./widgets'` module whose default export is a
9829
+ * `Record<componentKey, Component>` map; `componentKey` (the widget
9830
+ * `stableId`) picks the entry the host mounts.
9831
+ */
9832
+ var WidgetRemoteSchema = object({
9833
+ remoteName: string(),
9834
+ exposedModule: string(),
9835
+ componentKey: string().optional()
9836
+ });
9837
+ /**
9838
+ * One widget declaration — a `UiContribution` (`kind:'remote'`) plus
9839
+ * widget-only metadata. The `UiContribution` core fields:
9840
+ *
9841
+ * - `tab` — where the widget hosts. A widget that runs on the
9842
+ * dashboard declares `tab:'dashboard'`; a device-tab
9843
+ * widget declares the target device-detail tab id.
9844
+ * - `subTab` — optional sub-tab within `tab`.
9845
+ * - `label` — operator-facing label.
9846
+ * - `order` — ordering within `(tab, subTab)`.
9847
+ * - `kind` — always `'remote'` for widgets.
9848
+ * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
9849
+ *
9850
+ * Widget-only fields retained alongside the contribution core:
9851
+ *
9852
+ * - `stableId` — stable identity within the addon (the MF
9853
+ * `componentKey`; kept top-level so consumers have
9854
+ * a stable key without reaching into `remote`).
9855
+ * - `description` / `icon` — picker metadata.
9856
+ * - `bundle` — entry filename inside the addon `dist/` dir; the
9857
+ * aggregator stamps a versioned `bundleUrl` from it.
9858
+ * - `hosts` — every host the widget supports (a widget can run
9859
+ * both on the dashboard and a device tab). `tab`
9860
+ * is the PRIMARY host; `hosts` is the full set the
9861
+ * picker filters on.
9862
+ * - `requires` — host-context requirements validated at mount.
9863
+ * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
9864
+ * — dashboard placement hints.
9865
+ */
9866
+ var WidgetMetadataSchema = object({
9867
+ /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
9868
+ tab: string(),
9869
+ /** Optional sub-tab within `tab`. */
9870
+ subTab: string().optional(),
9871
+ /** Operator-facing label. */
9872
+ label: string(),
9873
+ /** Ordering within `(tab, subTab)`, ascending. */
9874
+ order: number().optional(),
9875
+ /** Always `'remote'` — a widget is a Module Federation remote. */
9876
+ kind: literal("remote"),
9877
+ /** MF remote descriptor. */
9878
+ remote: WidgetRemoteSchema,
9879
+ /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
9880
+ stableId: string(),
9881
+ description: string().optional(),
9882
+ icon: string().optional(),
9883
+ /**
9884
+ * Bundle filename inside the addon's `dist/` dir served at
9885
+ * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
9886
+ * this is always `'remoteEntry.js'` — the value is kept on the
9887
+ * metadata so the static-file route can compute an mtime-based
9888
+ * cache-buster URL without a separate filesystem stat.
9889
+ */
9890
+ bundle: string(),
9891
+ /** Every host the widget supports. The picker filters on this set. */
9892
+ hosts: array(WidgetHostEnum).readonly(),
9893
+ /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
9894
+ requires: object({
9895
+ deviceContext: boolean().default(false),
9896
+ integrationContext: boolean().default(false)
9897
+ }),
9898
+ /**
9899
+ * Loadable BEFORE authentication. The normal widget registry listing
9900
+ * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
9901
+ * (the login page) cannot discover a widget through it. A widget that
9902
+ * declares `preAuth: true` marks itself as safe to mount on a pre-auth
9903
+ * screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
9904
+ * login-method contribution channel (see `login-method.cap.ts`) rather
9905
+ * than the authenticated registry, and its bundle is served by the
9906
+ * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
9907
+ */
9908
+ preAuth: boolean().optional().default(false),
9909
+ /** Dashboard placement HINTS (operator can override per instance). */
9910
+ defaultSize: WidgetSizeEnum.default("md"),
9911
+ allowedSizes: array(WidgetSizeEnum).readonly().default([
9912
+ "sm",
9913
+ "md",
9914
+ "lg"
9915
+ ]),
9916
+ defaultColumns: number().int().min(1).max(12).default(6),
9917
+ defaultRows: number().int().min(1).max(12).default(1)
9918
+ });
9919
+ var addonWidgetsSourceCapability = {
9920
+ name: "addon-widgets-source",
9921
+ scope: "system",
9922
+ mode: "collection",
9923
+ internal: true,
9924
+ methods: { listWidgets: method(_void(), array(WidgetMetadataSchema).readonly()) }
9925
+ };
9926
+ /**
9927
+ * `addon-widgets` — system-scoped singleton aggregator cap. Public-facing
9928
+ * surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
9929
+ *
9930
+ * The provider iterates every `addon-widgets-source` (collection)
9931
+ * provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
9932
+ * `bundleUrl` strings pointing at
9933
+ * `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
9934
+ * `mtime` cache-buster lets the browser pick up addon rebuilds without
9935
+ * manual reload — same scheme used by `addon-pages`.
9936
+ *
9937
+ * The hub-local builtin `addon-widgets-aggregator` (see
9938
+ * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
9939
+ * provider. Splitting the public aggregator from the raw collection
9940
+ * keeps both ends in codegen — there's no hand-written wrapper.
9941
+ */
9942
+ var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
9943
+ addonId: string(),
9944
+ bundleUrl: string()
9945
+ });
9946
+ method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
9947
+ /**
9948
+ * Alerts capability — collection-based internal alert system.
9949
+ *
9950
+ * Multiple providers can register. Each provider filters by EventBus category
9951
+ * and creates/updates alerts. The built-in Alert Center addon persists alerts
9952
+ * in the DB and serves them to the admin UI.
9953
+ */
9954
+ var AlertSeveritySchema = _enum([
9955
+ "info",
9956
+ "success",
9957
+ "warning",
9958
+ "error"
9959
+ ]);
9960
+ var AlertStatusSchema = _enum([
9961
+ "active",
9962
+ "in-progress",
9963
+ "completed",
9964
+ "failed",
9965
+ "dismissed"
9966
+ ]);
9967
+ var AlertSourceSchema = object({
9968
+ type: string(),
9969
+ id: string()
9970
+ });
9971
+ var AlertSchema = object({
9972
+ id: string(),
9973
+ category: string(),
9974
+ severity: AlertSeveritySchema,
9975
+ title: string(),
9976
+ message: string(),
9977
+ status: AlertStatusSchema,
9978
+ progress: number().optional(),
9979
+ read: boolean(),
9980
+ createdAt: number(),
9981
+ updatedAt: number(),
9982
+ source: AlertSourceSchema.optional(),
9983
+ metadata: record(string(), unknown()).optional()
9984
+ });
9985
+ method(AlertSchema, _void(), { kind: "mutation" }), method(object({
9986
+ alertId: string(),
9987
+ patch: AlertSchema.partial()
9988
+ }), _void(), { kind: "mutation" }), method(object({
9989
+ unreadOnly: boolean().optional(),
9990
+ limit: number().optional()
9991
+ }).optional(), array(AlertSchema).readonly()), method(_void(), number()), method(object({ alertId: string() }), _void(), { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(object({ alertId: string() }), _void(), { kind: "mutation" });
9992
+ DeviceType.Camera, method(object({ deviceId: number() }), custom()), object({
9993
+ deviceId: number(),
9994
+ rms: number(),
9995
+ dbfs: number()
9996
+ });
9997
+ /** Shared Zod schemas used across detection capabilities. */
9998
+ /**
9999
+ * Canonical frame-format enum mirrored on `FrameFormat` in
10000
+ * `packages/types/src/types/io.ts`. Kept inline (vs imported) so the
10001
+ * Zod runtime schema and TypeScript type stay in sync at the call site
10002
+ * — adding a new format requires changing both this enum and the
10003
+ * `FrameFormat` type alias together.
10004
+ */
10005
+ var FrameFormatSchema = _enum([
10006
+ "jpeg",
10007
+ "rgb",
10008
+ "bgr",
10009
+ "yuv420",
10010
+ "gray"
10011
+ ]);
10012
+ var FrameInputSchema = object({
10013
+ data: custom(),
10014
+ format: FrameFormatSchema,
10015
+ width: number(),
10016
+ height: number(),
10017
+ timestamp: number()
10018
+ });
10019
+ var BoundingBoxSchema = object({
10020
+ x: number(),
10021
+ y: number(),
10022
+ w: number(),
10023
+ h: number()
10024
+ });
10025
+ object({
10026
+ class: string(),
10027
+ originalClass: string(),
10028
+ score: number(),
10029
+ bbox: BoundingBoxSchema
10030
+ });
10031
+ /**
10032
+ * `data` carries the raw f32le bytes of the PCM samples (4 bytes per
10033
+ * sample, little-endian IEEE 754). `Uint8Array` is the wire-safe choice
10034
+ * because `@msgpack/msgpack` serialises it as a MsgPack `bin` type that
10035
+ * round-trips losslessly over the UDS transport. `Float32Array` is NOT
10036
+ * preserved — the encoder serialises it as `bin` (its raw bytes) but
10037
+ * the decoder returns `Uint8Array`, so treating a `Float32Array` as the
10038
+ * wire type causes receivers to read bytes as sample values, producing
10039
+ * wildly wrong RMS/dBFS results (~+43 dBFS instead of ≤0).
10040
+ *
10041
+ * Callers that need float arithmetic reconstruct the view with:
10042
+ * `new Float32Array(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength / 4)`
9171
10043
  */
9172
10044
  var AudioChunkInputSchema = object({
9173
10045
  data: _instanceof(Uint8Array),
@@ -11872,6 +12744,15 @@ method(object({
11872
12744
  }), method(ReleaseInputSchema.extend({ addonId: string() }), _void(), {
11873
12745
  kind: "mutation",
11874
12746
  auth: "admin"
12747
+ }), method(AdoptInputSchema.extend({ addonId: string() }), object({ jobId: string() }), {
12748
+ kind: "mutation",
12749
+ auth: "admin"
12750
+ }), method(object({
12751
+ addonId: string(),
12752
+ integrationId: string().optional()
12753
+ }), array(AdoptionJobSchema).readonly(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
12754
+ kind: "mutation",
12755
+ auth: "admin"
11875
12756
  }), method(ResyncInputSchema, ResyncResultSchema, {
11876
12757
  kind: "mutation",
11877
12758
  auth: "admin"
@@ -12594,7 +13475,7 @@ DeviceType.Camera, method(object({
12594
13475
  * Why: pub/sub routing over the system event-bus loses fidelity
12595
13476
  * (callback shape, QoS guarantees, will/retain semantics) and adds
12596
13477
  * refcount bookkeeping that addons would rather own themselves. The
12597
- * canonical consumer (`addon-export-ha-mqtt`) needs raw `mqtt.js`
13478
+ * canonical consumer needs raw `mqtt.js`
12598
13479
  * features anyway — give it the connection config, get out of the way.
12599
13480
  *
12600
13481
  * Consumer flow:
@@ -14751,6 +15632,65 @@ object({
14751
15632
  * Each provider returns a static descriptor; the core enumerates them
14752
15633
  * to validate the `integration=` query param and resolve the consent
14753
15634
  * label + the scopes baked into the issued token.
15635
+ *
15636
+ * ## Declaring one
15637
+ *
15638
+ * An OAuth client is integration-specific knowledge — who the client is, what
15639
+ * it may ask for, where it may be sent — so it is declared by the ADDON that
15640
+ * owns the integration, never by the kernel and never as a branch inside
15641
+ * `oauth2-routes.ts` ([D101](../../../../docs/decisions/adr-0101.md)). Three
15642
+ * steps, no others:
15643
+ *
15644
+ * 1. Add `{ "name": "oauth-integration" }` to the addon's `camstack.addons[]`
15645
+ * manifest entry. This is also what tells the hub, at addon-LOAD time, that
15646
+ * a descriptor is owed — see "the boot window" below.
15647
+ * 2. Return a provider from `onInitialize()`:
15648
+ *
15649
+ * ```ts
15650
+ * const provider: IOauthIntegrationProvider = {
15651
+ * getDescriptor: async () => ({
15652
+ * integrationId: 'my-thing', // the `integration=` query param
15653
+ * displayName: 'My Thing',
15654
+ * requestedScopes: [ … ], // see below
15655
+ * allowedRedirectPrefixes: ['https://callback.example/'],
15656
+ * }),
15657
+ * }
15658
+ * return [{ capability: oauthIntegrationCapability, provider }]
15659
+ * ```
15660
+ *
15661
+ * The descriptor must be **static** — it is read on the authorize path, so
15662
+ * never put an await on network or disk behind it, and never register it
15663
+ * behind one either (a provider is registered only once `onInitialize`
15664
+ * RETURNS, so anything awaited before the return delays linking).
15665
+ * 3. Nothing else. There is no allow-list to join, no id to register with the
15666
+ * core, and no per-integration branch anywhere: `/api/oauth2/authorize` and
15667
+ * `/api/oauth2/integrations` are built from this collection alone.
15668
+ *
15669
+ * **Scopes.** `requestedScopes` is baked into every token this integration is
15670
+ * ever issued and the operator consents to it once. Derive it from the tRPC
15671
+ * paths the client calls **with that token**, against `METHOD_ACCESS_MAP`, and
15672
+ * prefer a narrow `capability:` scope to a `category:` one unless the client
15673
+ * genuinely needs a whole family. A category scope grants every future member
15674
+ * of that category too. `category:system [create]` has been rejected once and
15675
+ * should stay rejected: it hands `addons.installPackage` to an integration.
15676
+ *
15677
+ * What it does NOT cover: calls the ADDON makes over `ctx.api`, which run as
15678
+ * the addon and are not scope-checked. Alexa's descriptor is narrower than
15679
+ * Home Assistant's for exactly that reason — its Lambda posts directives and
15680
+ * the addon does the work, while the Home Assistant component calls tRPC
15681
+ * directly with the token. So `requestedScopes` describes the blast radius of
15682
+ * the GRANT, not the reach of the integration; do not widen one to describe the
15683
+ * other.
15684
+ *
15685
+ * **The boot window.** An addon registers its provider after its runner forks
15686
+ * and initialises, so between hub start and that moment this collection is
15687
+ * incomplete and an `integrationId` can be legitimately absent. The core does
15688
+ * not wait, poll or cache around this ([D3](../../../../docs/decisions/adr-0003.md)):
15689
+ * it compares the manifest declarers against the registered providers and
15690
+ * answers `503 temporarily_unavailable` (with `Retry-After` and the pending
15691
+ * addon ids) instead of `400 unknown integration`, and reports
15692
+ * `complete: false` on `GET /api/oauth2/integrations`. A client should retry
15693
+ * while the list is incomplete rather than conclude the hub cannot do OAuth.
14754
15694
  */
14755
15695
  var OauthIntegrationDescriptorSchema = object({
14756
15696
  /** Stable id used as the `integration=` query param, e.g. 'export-alexa'. */
@@ -15134,8 +16074,26 @@ var TrackSchema = object({
15134
16074
  /** Periodic snapshots at snapshotIntervalMs cadence (subject to
15135
16075
  * saveThumbnails policy). */
15136
16076
  snapshots: array(TrackSnapshotSchema).readonly(),
15137
- /** Deduplicated zones the track has entered at least once. */
16077
+ /** Deduplicated zones the track has entered at least once. Zone IDS. */
15138
16078
  zonesVisited: array(string()).readonly(),
16079
+ /**
16080
+ * Human NAMES for {@link zonesVisited}, resolved at READ time against the
16081
+ * `zones` capability.
16082
+ *
16083
+ * `zonesVisited` persists ids (`cfeec78c-8d69-…`), which no operator can type
16084
+ * and no card can render — so every free-text search surface was structurally
16085
+ * unable to answer "show me the tracks in Uscio", and did not fail loudly, it
16086
+ * just returned nothing. Resolving here rather than in each client keeps ONE
16087
+ * derivation and costs the clients no extra call (the `zones` cap is
16088
+ * per-device, so a client-side resolve would be a per-camera fan-out on a
16089
+ * surface built to avoid exactly that).
16090
+ *
16091
+ * Resolved, never invented: a zone deleted since the track was written has no
16092
+ * name and is DROPPED, so this array can be shorter than `zonesVisited` — the
16093
+ * two are not positionally aligned. Absent when the track visited no zone, or
16094
+ * when the zone catalogue could not be read.
16095
+ */
16096
+ zoneNames: array(string()).readonly().optional(),
15139
16097
  /** Deduplicated set of detector classes observed for this track over its
15140
16098
  * life (a track may be reclassified, e.g. person→vehicle). Absent on
15141
16099
  * legacy rows written before class accumulation shipped. */
@@ -17529,6 +18487,23 @@ var CameraRecordingStatusSchema = object({
17529
18487
  active: boolean(),
17530
18488
  storageBytes: number()
17531
18489
  });
18490
+ /** One stage of the fan-out that could NOT be read, and how long it cost. */
18491
+ var CameraStatusDegradationSchema = object({
18492
+ stage: _enum([
18493
+ "source",
18494
+ "broker",
18495
+ "detection",
18496
+ "recording",
18497
+ "switches"
18498
+ ]),
18499
+ reason: _enum([
18500
+ "timeout",
18501
+ "error",
18502
+ "partial"
18503
+ ]),
18504
+ /** Wall-clock ms spent on the stage before it was abandoned. */
18505
+ elapsedMs: number()
18506
+ });
17532
18507
  /**
17533
18508
  * Aggregated per-camera pipeline status — server-composed, single call.
17534
18509
  *
@@ -17559,9 +18534,28 @@ var CameraStatusSchema = object({
17559
18534
  * differently — a quiet camera that looks identical to a dead one is the
17560
18535
  * silence-reads-as-never-happened trap this repo keeps paying for.
17561
18536
  *
17562
- * Empty when nothing is off. Never contains a switch no provider offers.
18537
+ * Empty when nothing is off, and never contains a switch no provider offers
18538
+ * — but an empty list is only a POSITIVE claim when `degraded` does not name
18539
+ * `'switches'`. When it does, the switch set could not be read and nothing
18540
+ * here may be rendered as "the operator turned nothing off": that is the
18541
+ * D62 failure (a camera we could not read painted as broken) in the very
18542
+ * field that exists to prevent it.
17563
18543
  */
17564
18544
  switchedOff: array(CameraSwitchIdSchema).readonly(),
18545
+ /**
18546
+ * Stages of the bounded fan-out that were CUT SHORT — a timeout or a
18547
+ * rejection — and whose block is therefore `null` because we could not
18548
+ * READ it, not because there is nothing there.
18549
+ *
18550
+ * Without this, three different facts arrive as the same `null`: "the stage
18551
+ * timed out", "the stage failed", and "this camera legitimately has no
18552
+ * decoder / no recording". Every surface that draws a conclusion from a null
18553
+ * block (or from an empty `switchedOff`) must consult this first; a stage
18554
+ * named here supports no conclusion at all, only "unknown".
18555
+ *
18556
+ * Empty on a clean read — the overwhelmingly common case.
18557
+ */
18558
+ degraded: array(CameraStatusDegradationSchema).readonly(),
17565
18559
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
17566
18560
  fetchedAt: number()
17567
18561
  });
@@ -18371,6 +19365,37 @@ DeviceType.Camera, method(object({
18371
19365
  lastCapturedAt: number().nullable(),
18372
19366
  cacheAgeMs: number().nullable(),
18373
19367
  etag: string().nullable()
19368
+ }))), systemMethod(object({
19369
+ /** The tiles a surface is actually rendering. One entry per (device,
19370
+ * width) the caller will paint — the width is snapped to the server's
19371
+ * ladder and becomes part of the link's SIGNED identity. */
19372
+ targets: array(object({
19373
+ deviceId: number(),
19374
+ /** Target width in px. Omit for the frame as captured — correct
19375
+ * for a full-bleed surface, wrong (and expensive) for a grid. */
19376
+ width: number().int().positive().optional()
19377
+ })).min(1).max(200) }), array(object({
19378
+ deviceId: number(),
19379
+ /** Root-relative signed path, or null when the link plane is not
19380
+ * served (no data-plane facility). Present even for a device that has
19381
+ * never captured — the request is what triggers the first one (D94). */
19382
+ url: string().nullable(),
19383
+ /** Epoch ms of the frame this link serves. Null = never captured.
19384
+ * THE honest age: the tRPC path carried none before this. */
19385
+ capturedAt: number().nullable(),
19386
+ /** Age of that frame at the moment the answer was built. */
19387
+ ageMs: number().nullable(),
19388
+ /** Epoch ms after which `url` stops verifying. */
19389
+ expiresAt: number().nullable(),
19390
+ /** Ladder rung the bytes are at; null = the frame as captured. */
19391
+ width: number().nullable(),
19392
+ /** The device has never produced a frame. An empty state, not a
19393
+ * failure — and never a reason to withhold the link (D94). */
19394
+ neverCaptured: boolean(),
19395
+ /** A sleeping battery camera: the frame is deliberately stale and will
19396
+ * NOT refresh in the background. A surface should say so rather than
19397
+ * present it as current. */
19398
+ sleeping: boolean()
18374
19399
  })));
18375
19400
  /**
18376
19401
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
@@ -20121,6 +21146,37 @@ DeviceType.Light, method(object({
20121
21146
  mireds: number().int().optional(),
20122
21147
  lastChangedAt: number()
20123
21148
  });
21149
+ var ConnectionTestOutcomeSchema = discriminatedUnion("outcome", [
21150
+ object({
21151
+ outcome: literal("validated"),
21152
+ /** Round-trip of the sign-in, when the provider measured it. */
21153
+ latencyMs: number().nonnegative().optional(),
21154
+ /** Optional human detail worth showing next to the tick
21155
+ * ("3 devices visible on this account"). */
21156
+ detail: string().optional()
21157
+ }).strict(),
21158
+ object({
21159
+ outcome: literal("rejected"),
21160
+ error: string()
21161
+ }).strict(),
21162
+ object({
21163
+ outcome: literal("inconclusive"),
21164
+ error: string()
21165
+ }).strict()
21166
+ ]);
21167
+ var ConnectionTestInputSchema = object({
21168
+ /** Candidate integration settings, exactly as the create form collected them. */
21169
+ settings: record(string(), unknown()) });
21170
+ /**
21171
+ * What the provider's test actually DOES, so the UI can say it in words before
21172
+ * the operator presses the button ("Signs in to the Dreo cloud"). Purely
21173
+ * descriptive — it never changes routing.
21174
+ */
21175
+ var ConnectionTestDescriptorSchema = object({ label: string() });
21176
+ method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
21177
+ kind: "mutation",
21178
+ auth: "admin"
21179
+ }), method(_void(), ConnectionTestDescriptorSchema, { auth: "admin" });
20124
21180
  object({
20125
21181
  /** True when the upstream system considers the entity connected. */
20126
21182
  connected: boolean(),
@@ -21022,15 +22078,57 @@ var AvailableIntegrationTypeSchema = object({
21022
22078
  * flow can import (e.g. HA areas). Drives the adopt modal's "import
21023
22079
  * locations" checkbox. Provider-declared in the addon manifest. */
21024
22080
  supportsLocationImport: boolean(),
22081
+ /**
22082
+ * True when this integration DECLARES a pre-creation test (the
22083
+ * `connection-test` cap, or a broker whose settings it stores). Drives the
22084
+ * Test button: an integration that cannot be tested must say so up front
22085
+ * rather than offering a button that always answers the same nonsense.
22086
+ */
22087
+ canTest: boolean(),
21025
22088
  existingInstances: array(object({
21026
22089
  id: string(),
21027
22090
  name: string()
21028
22091
  })),
21029
22092
  canAdd: boolean()
21030
22093
  });
22094
+ /**
22095
+ * Why a test could not be answered as a plain boolean.
22096
+ *
22097
+ * `success` alone collapsed four different situations into one red box, and the
22098
+ * one that mattered most — "nobody ever asked the remote anything" — looked
22099
+ * exactly like "the remote said no". The status is the discriminator:
22100
+ *
22101
+ * - `validated` — a provider-declared test ran and the remote ACCEPTED.
22102
+ * - `rejected` — a provider-declared test ran and the remote REFUSED.
22103
+ * The only status that blocks `integrations.create`.
22104
+ * - `inconclusive` — a test IS declared but could not complete (timeout,
22105
+ * DNS, 5xx). Nothing was observed; not a failure.
22106
+ * - `unsupported` — this integration declares NO test. Nothing was
22107
+ * observed either; not a failure, and not a pass.
22108
+ *
22109
+ * `unsupported` and `inconclusive` both carry `success: false` so an older
22110
+ * client can never read them as a green tick, and both carry an `error` string
22111
+ * that SAYS the test did not run rather than inventing a failure.
22112
+ */
22113
+ var TestConnectionStatusEnum = _enum([
22114
+ "validated",
22115
+ "rejected",
22116
+ "inconclusive",
22117
+ "unsupported"
22118
+ ]);
21031
22119
  var TestConnectionResultSchema$1 = object({
22120
+ /** True ONLY for `validated`. Never true for a test that did not run. */
21032
22121
  success: boolean(),
21033
- error: string().optional()
22122
+ error: string().optional(),
22123
+ /** Optional for wire back-compat with clients built before the tri-state;
22124
+ * the server always sets it. */
22125
+ status: TestConnectionStatusEnum.optional(),
22126
+ /** Addon id whose declared test answered — `null` when none did. Lets the UI
22127
+ * attribute a result instead of blaming "the integration". */
22128
+ testedBy: string().nullable().optional(),
22129
+ latencyMs: number().nonnegative().optional(),
22130
+ /** Human detail from a `validated` result ("3 devices on this account"). */
22131
+ detail: string().optional()
21034
22132
  });
21035
22133
  var CreateIntegrationInputSchema = object({
21036
22134
  addonId: string(),
@@ -24031,1182 +25129,553 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
24031
25129
  }), method(object({
24032
25130
  username: string(),
24033
25131
  password: string()
24034
- }), UserSummarySchema.extend({ passwordHash: string() }).nullable(), {
24035
- kind: "mutation",
24036
- access: "view"
24037
- }), method(_void(), array(ApiKeySummarySchema), { auth: "admin" }), method(CreateApiKeyInputSchema, CreateApiKeyResultSchema, {
24038
- kind: "mutation",
24039
- auth: "admin",
24040
- access: "create"
24041
- }), method(object({ id: string() }), object({ success: literal(true) }), {
24042
- kind: "mutation",
24043
- auth: "admin",
24044
- access: "delete"
24045
- }), method(object({ token: string() }), ApiKeySummarySchema.nullable(), {
24046
- kind: "mutation",
24047
- access: "view"
24048
- }), method(CreateScopedTokenInputSchema, CreateScopedTokenResultSchema, {
24049
- kind: "mutation",
24050
- auth: "admin",
24051
- access: "create"
24052
- }), method(object({ id: string() }), object({ success: literal(true) }), {
24053
- kind: "mutation",
24054
- auth: "admin",
24055
- access: "delete"
24056
- }), method(object({ token: string() }), ScopedTokenSummarySchema.nullable(), { access: "view" }), method(object({ userId: string() }), array(ScopedTokenSummarySchema), { auth: "admin" }), method(object({ userId: string() }), TotpSetupResultSchema, {
24057
- kind: "mutation",
24058
- auth: "admin",
24059
- access: "create"
24060
- }), method(object({
24061
- userId: string(),
24062
- code: string()
24063
- }), object({ success: literal(true) }), {
24064
- kind: "mutation",
24065
- auth: "admin",
24066
- access: "create"
24067
- }), method(object({ userId: string() }), object({ success: literal(true) }), {
24068
- kind: "mutation",
24069
- auth: "admin",
24070
- access: "delete"
24071
- }), method(object({ userId: string() }), TotpStatusSchema, { auth: "admin" }), method(object({
24072
- userId: string(),
24073
- code: string()
24074
- }), object({ valid: boolean() }), {
24075
- kind: "mutation",
24076
- access: "view"
24077
- }), method(object({
24078
- integrationId: string(),
24079
- userId: string(),
24080
- username: string(),
24081
- scopes: array(TokenScopeSchema),
24082
- redirectUri: string(),
24083
- hubUrl: string(),
24084
- /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
24085
- * that carries one can ONLY be exchanged with the matching verifier. */
24086
- codeChallenge: string().optional()
24087
- }), object({ code: string() }), {
24088
- kind: "mutation",
24089
- access: "create"
24090
- }), method(object({
24091
- code: string(),
24092
- redirectUri: string(),
24093
- /** PKCE verifier. REQUIRED when the code carries a challenge. */
24094
- codeVerifier: string().optional()
24095
- }), object({
24096
- accessToken: string(),
24097
- refreshToken: string(),
24098
- expiresIn: number()
24099
- }).nullable(), {
24100
- kind: "mutation",
24101
- access: "view"
24102
- }), method(object({ refreshToken: string() }), object({
24103
- accessToken: string(),
24104
- refreshToken: string(),
24105
- expiresIn: number()
24106
- }).nullable(), {
24107
- kind: "mutation",
24108
- access: "view"
24109
- }), method(object({ token: string() }), object({
24110
- userId: string(),
24111
- username: string(),
24112
- scopes: array(TokenScopeSchema)
24113
- }).nullable(), { access: "view" }), method(_void(), array(OauthSessionSummarySchema), { auth: "admin" }), method(object({ id: string() }), object({ success: boolean() }), {
24114
- kind: "mutation",
24115
- auth: "admin",
24116
- access: "delete"
24117
- });
24118
- /**
24119
- * Robot-vacuum cap. Models HA `vacuum.*` entities — anything with a
24120
- * cleaning lifecycle plus a return-to-base / locate surface and an
24121
- * optional fan-speed selector.
24122
- *
24123
- * State follows HA's canonical vacuum lifecycle: `idle` / `cleaning` /
24124
- * `paused` / `returning` / `docked` / `error`. `batteryLevel`
24125
- * (0..100) is nullable — some vacuums don't report a battery
24126
- * percentage. `fanSpeed` is the current speed token (provider-verbatim,
24127
- * e.g. `'standard'` / `'turbo'`) and `availableFanSpeeds` lists the
24128
- * tokens the hardware accepts so the UI renders only supported choices.
24129
- *
24130
- * The `setFanSpeed` method takes the bare `speed` token — the provider
24131
- * validates it against the vacuum's own list. `locate` triggers the
24132
- * find-me chirp; `returnToBase` sends it home.
24133
- *
24134
- * Consumable / waste tanks: `cleanWater` / `dirtyWater` / `detergent` /
24135
- * `dustBin` each carry a nullable `{ level (0..100 %), status ('ok' |
24136
- * 'low' | 'full') }` reading. Native providers (e.g. Dreame, Roborock)
24137
- * SHOULD populate whichever tanks the hardware has — leave a field `null`
24138
- * only when the device has no such tank at all, and use a `TankStatus`
24139
- * with both inner fields `null` when the tank exists but its level is
24140
- * currently unknown. HA `vacuum.*` entities expose no per-tank telemetry,
24141
- * so the HA provider leaves all four `null`.
24142
- */
24143
- var VacuumStateSchema = _enum([
24144
- "idle",
24145
- "cleaning",
24146
- "paused",
24147
- "returning",
24148
- "docked",
24149
- "drying",
24150
- "error"
24151
- ]);
24152
- /**
24153
- * One consumable / waste tank on a robot vacuum (clean-water, dirty-water,
24154
- * detergent or dust-bin). A tank can report a numeric fill `level` (0..100),
24155
- * a discrete `status` (binary-style hardware), or both. Both `null` means the
24156
- * level is currently unknown; the OWNING field being `null` means the
24157
- * hardware has no such tank at all.
24158
- */
24159
- var TankStatusSchema = object({
24160
- /** Numeric fill 0..100 when the hardware reports a percentage; null otherwise. */
24161
- level: number().min(0).max(100).nullable(),
24162
- /** Discrete state when the hardware is binary-mode; null otherwise. */
24163
- status: _enum([
24164
- "ok",
24165
- "low",
24166
- "full"
24167
- ]).nullable()
24168
- });
24169
- object({
24170
- /** Lifecycle state of the vacuum. */
24171
- state: VacuumStateSchema,
24172
- /** 0..100 battery percentage. Null when the device has no battery
24173
- * reading. */
24174
- batteryLevel: number().min(0).max(100).nullable(),
24175
- /** Current fan-speed token (provider-verbatim). Null when unknown or
24176
- * the vacuum has no speed control. */
24177
- fanSpeed: string().nullable(),
24178
- /** Speed tokens the hardware accepts — drives the UI selector. */
24179
- availableFanSpeeds: array(string()),
24180
- /** Clean-water (mop) tank. Null when the hardware has no clean-water tank. */
24181
- cleanWater: TankStatusSchema.nullable(),
24182
- /** Dirty-water (recovery) tank. Null when the hardware has no dirty-water tank. */
24183
- dirtyWater: TankStatusSchema.nullable(),
24184
- /** Detergent tank. Null when the hardware has no detergent tank. */
24185
- detergent: TankStatusSchema.nullable(),
24186
- /** Dust bin. Null when the hardware has no dust bin. */
24187
- dustBin: TankStatusSchema.nullable(),
24188
- /** 0..100 cleaning-completion percentage of the current task, or null. */
24189
- progressPercent: number().min(0).max(100).nullable(),
24190
- /** Current error code (0 / null = no error). */
24191
- errorCode: number().nullable(),
24192
- /** Human label for {@link errorCode}, or null when none / undecodable. */
24193
- errorLabel: string().nullable(),
24194
- /** Ms epoch when the slice was last updated. */
24195
- lastChangedAt: number()
24196
- });
24197
- DeviceType.Vacuum, method(object({ deviceId: number().int().nonnegative() }), _void(), {
24198
- kind: "mutation",
24199
- auth: "admin"
24200
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
24201
- kind: "mutation",
24202
- auth: "admin"
24203
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
24204
- kind: "mutation",
24205
- auth: "admin"
24206
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
24207
- kind: "mutation",
24208
- auth: "admin"
24209
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
24210
- kind: "mutation",
24211
- auth: "admin"
24212
- }), method(object({
24213
- deviceId: number().int().nonnegative(),
24214
- speed: string().min(1)
24215
- }), _void(), {
24216
- kind: "mutation",
24217
- auth: "admin"
24218
- });
24219
- object({
24220
- /** Lifecycle state of the valve. */
24221
- state: _enum([
24222
- "open",
24223
- "opening",
24224
- "closing",
24225
- "closed",
24226
- "stopped"
24227
- ]),
24228
- /** 0 = fully closed, 100 = fully open. Null when the device has no
24229
- * intermediate position surface. */
24230
- position: number().min(0).max(100).nullable(),
24231
- /** Ms epoch when the slice was last updated. */
24232
- lastChangedAt: number()
24233
- });
24234
- DeviceType.Valve, method(object({ deviceId: number().int().nonnegative() }), _void(), {
25132
+ }), UserSummarySchema.extend({ passwordHash: string() }).nullable(), {
24235
25133
  kind: "mutation",
24236
- auth: "admin"
24237
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
25134
+ access: "view"
25135
+ }), method(_void(), array(ApiKeySummarySchema), { auth: "admin" }), method(CreateApiKeyInputSchema, CreateApiKeyResultSchema, {
24238
25136
  kind: "mutation",
24239
- auth: "admin"
24240
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
25137
+ auth: "admin",
25138
+ access: "create"
25139
+ }), method(object({ id: string() }), object({ success: literal(true) }), {
24241
25140
  kind: "mutation",
24242
- auth: "admin"
24243
- }), method(object({
24244
- deviceId: number().int().nonnegative(),
24245
- position: number().min(0).max(100)
24246
- }), _void(), {
25141
+ auth: "admin",
25142
+ access: "delete"
25143
+ }), method(object({ token: string() }), ApiKeySummarySchema.nullable(), {
24247
25144
  kind: "mutation",
24248
- auth: "admin"
24249
- });
24250
- object({
24251
- detected: boolean(),
24252
- /** Ms epoch of the last transition. 0 if never observed. */
24253
- lastChangedAt: number()
24254
- });
24255
- DeviceType.Sensor;
24256
- object({
24257
- /** Current measured temperature. Null when not reported. */
24258
- currentTemp: number().nullable(),
24259
- /** Target temperature setpoint. Null when no setpoint surface. */
24260
- targetTemp: number().nullable(),
24261
- /** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
24262
- * `heat_pump` / `high_demand` / `performance` / `off`). Null when the
24263
- * device reports an unknown state. */
24264
- operationMode: string().nullable(),
24265
- /** Available operation modes = HA `operation_list`. */
24266
- availableModes: array(string()),
24267
- /** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
24268
- * has no away surface. */
24269
- away: boolean().nullable(),
24270
- /** HA `min_temp` attribute. Null when not reported. */
24271
- minTemp: number().nullable(),
24272
- /** HA `max_temp` attribute. Null when not reported. */
24273
- maxTemp: number().nullable(),
24274
- /** Ms epoch when the slice was last updated. */
24275
- lastChangedAt: number()
24276
- });
24277
- DeviceType.WaterHeater, method(object({
24278
- deviceId: number().int().nonnegative(),
24279
- temp: number().finite()
24280
- }), _void(), {
25145
+ access: "view"
25146
+ }), method(CreateScopedTokenInputSchema, CreateScopedTokenResultSchema, {
24281
25147
  kind: "mutation",
24282
- auth: "admin"
25148
+ auth: "admin",
25149
+ access: "create"
25150
+ }), method(object({ id: string() }), object({ success: literal(true) }), {
25151
+ kind: "mutation",
25152
+ auth: "admin",
25153
+ access: "delete"
25154
+ }), method(object({ token: string() }), ScopedTokenSummarySchema.nullable(), { access: "view" }), method(object({ userId: string() }), array(ScopedTokenSummarySchema), { auth: "admin" }), method(object({ userId: string() }), TotpSetupResultSchema, {
25155
+ kind: "mutation",
25156
+ auth: "admin",
25157
+ access: "create"
24283
25158
  }), method(object({
24284
- deviceId: number().int().nonnegative(),
24285
- mode: string().min(1)
24286
- }), _void(), {
25159
+ userId: string(),
25160
+ code: string()
25161
+ }), object({ success: literal(true) }), {
24287
25162
  kind: "mutation",
24288
- auth: "admin"
25163
+ auth: "admin",
25164
+ access: "create"
25165
+ }), method(object({ userId: string() }), object({ success: literal(true) }), {
25166
+ kind: "mutation",
25167
+ auth: "admin",
25168
+ access: "delete"
25169
+ }), method(object({ userId: string() }), TotpStatusSchema, { auth: "admin" }), method(object({
25170
+ userId: string(),
25171
+ code: string()
25172
+ }), object({ valid: boolean() }), {
25173
+ kind: "mutation",
25174
+ access: "view"
24289
25175
  }), method(object({
24290
- deviceId: number().int().nonnegative(),
24291
- on: boolean()
24292
- }), _void(), {
25176
+ integrationId: string(),
25177
+ userId: string(),
25178
+ username: string(),
25179
+ scopes: array(TokenScopeSchema),
25180
+ redirectUri: string(),
25181
+ hubUrl: string(),
25182
+ /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
25183
+ * that carries one can ONLY be exchanged with the matching verifier. */
25184
+ codeChallenge: string().optional()
25185
+ }), object({ code: string() }), {
24293
25186
  kind: "mutation",
24294
- auth: "admin"
24295
- });
24296
- object({
24297
- /** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
24298
- * when no condition has been reported yet. */
24299
- condition: string().nullable(),
24300
- /** Current temperature in the reported unit. Null when not provided. */
24301
- temperature: number().nullable(),
24302
- /** Temperature unit string (e.g. `°C` / `°F`). Null when not provided. */
24303
- temperatureUnit: string().nullable(),
24304
- /** Relative humidity (0..100). Null when not provided. */
24305
- humidity: number().min(0).max(100).nullable(),
24306
- /** Barometric pressure in the reported unit. Null when not provided. */
24307
- pressure: number().nullable(),
24308
- /** Pressure unit string (e.g. `hPa` / `inHg`). Null when not provided. */
24309
- pressureUnit: string().nullable(),
24310
- /** Wind speed in the reported unit. Null when not provided. */
24311
- windSpeed: number().nullable(),
24312
- /** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
24313
- windSpeedUnit: string().nullable(),
24314
- /** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
24315
- windBearing: number().nullable(),
24316
- /** Ms epoch when the slice was last updated. */
24317
- lastFetchedAt: number()
24318
- });
24319
- DeviceType.Weather;
24320
- /**
24321
- * Per-zone occupancy aggregation produced by the analytics frame
24322
- * processor on every inference result. Covers the full combinatorial
24323
- * matrix the operator UI needs: total objects everywhere, total
24324
- * objects per zone, single class everywhere, single class per zone,
24325
- * objects outside any zone.
24326
- *
24327
- * Counts are derived from the analytics tracker (tracked detections
24328
- * with stable trackIds), not raw detector hits — this filters out
24329
- * one-off detector flickers and gives counts that match what the user
24330
- * sees on the live overlay.
24331
- *
24332
- * Overlap policy: a detection that intersects two zones counts in
24333
- * BOTH zones' `byClass` and `totalObjects` (count-in-each). The
24334
- * `frame` aggregate de-duplicates trivially since it's frame-wide.
24335
- * `unzoned` counts only detections that landed in zero zones.
24336
- */
24337
- var PerScopeBreakdownSchema = object({
24338
- /** Total tracked objects in this scope (frame / zone / unzoned). */
24339
- totalObjects: number().int().nonnegative(),
24340
- /** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
24341
- byClass: record(string(), number().int().nonnegative())
24342
- });
24343
- var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
24344
- zoneId: string(),
24345
- zoneName: string(),
24346
- /** TrackIds of objects currently inside this zone — for cross-reference
24347
- * with the per-track detail panel and live overlay. */
24348
- trackIds: array(string()).readonly()
24349
- });
24350
- /**
24351
- * A parked ("stationary") object surfaced alongside occupancy — an object that
24352
- * settled and stopped moving. It is NO LONGER a tracked object (the tracker was
24353
- * told to forget it so it stops re-spawning tracks/events), but it IS still
24354
- * physically present, so it keeps counting toward `frame` occupancy and is
24355
- * listed here so the UI can show it in a dedicated "Stationary" section instead
24356
- * of flooding the live event feed.
24357
- */
24358
- var StationaryObjectSchema = object({
24359
- id: string(),
24360
- className: string(),
24361
- bbox: object({
24362
- x: number(),
24363
- y: number(),
24364
- w: number(),
24365
- h: number()
24366
- }),
24367
- frameWidth: number().int().nonnegative(),
24368
- frameHeight: number().int().nonnegative(),
24369
- /** When the source track was first seen. */
24370
- firstSeenAt: number().int(),
24371
- /** When the object was recognised as parked (promotion time). */
24372
- becameStationaryAt: number().int(),
24373
- /** Last frame a detection confirmed the object is still there. */
24374
- lastConfirmedAt: number().int(),
24375
- /** Enrichment label carried from the source track (identity / plate). */
24376
- label: string().optional(),
24377
- /** Native-resolution key-frame media key for the parked object's best image. */
24378
- keyFrameMediaKey: string().optional()
24379
- });
24380
- var CameraOccupancySnapshotSchema = object({
24381
- /** Frame timestamp of the inference result that produced this snapshot. */
24382
- ts: number().int(),
24383
- /** Frame width/height in pixels — let the UI normalize bbox coords. */
24384
- frameWidth: number().int().nonnegative(),
24385
- frameHeight: number().int().nonnegative(),
24386
- /** Per-zone breakdown — one entry per defined zone (user + onboard). */
24387
- zones: array(ZoneScopeBreakdownSchema).readonly(),
24388
- /** Frame-wide aggregate (everywhere, regardless of zone membership).
24389
- * INCLUDES currently-confirmed stationary objects (they are still present). */
24390
- frame: PerScopeBreakdownSchema,
24391
- /** Detections that landed outside every zone. Empty when no zones defined. */
24392
- unzoned: PerScopeBreakdownSchema,
24393
- /** Parked objects on this camera (additive — absent on legacy snapshots).
24394
- * Surfaced separately so the UI shows them in a dedicated section rather
24395
- * than as repeated tracks/events. */
24396
- stationaryObjects: array(StationaryObjectSchema).readonly().optional()
24397
- });
24398
- /**
24399
- * Time-series resolution. The history methods return one bucket per
24400
- * step over the requested range. Smaller resolutions cost more
24401
- * memory + bandwidth; bound to discrete steps so caller cannot ask
24402
- * for arbitrary fractional buckets.
24403
- */
24404
- var HistoryResolutionEnum = _enum([
24405
- "minute",
24406
- "5min",
24407
- "hour"
24408
- ]);
24409
- var HistoryRangeSchema = object({
24410
- /** Range start (epoch ms, inclusive). */
24411
- from: number().int(),
24412
- /** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
24413
- to: number().int(),
24414
- resolution: HistoryResolutionEnum
24415
- });
24416
- var HistoryPointSchema = object({
24417
- /** Bucket midpoint (epoch ms). */
24418
- ts: number().int(),
24419
- /** Object count averaged over the bucket (rounded to nearest integer). */
24420
- count: number().int().nonnegative()
24421
- });
24422
- DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({
24423
- deviceId: number(),
24424
- zoneId: string(),
24425
- className: string().optional()
24426
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
24427
- deviceId: number(),
24428
- className: string().optional()
24429
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
24430
- deviceId: number(),
24431
- className: string().optional()
24432
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
24433
- /**
24434
- * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
24435
- * cap so a single CRUD surface backs every consumer; each stage has
24436
- * its own dev-state mirror slice (`motion-zone-rules`,
24437
- * `detection-zone-rules`, …) so consumer addons subscribe independently.
24438
- *
24439
- * Extend the enum here when a new gating consumer comes online (audio
24440
- * gating, alert filtering, …) — no other surface needs to change.
24441
- */
24442
- var ZoneRuleStageEnum = _enum([
24443
- "motion",
24444
- "detection",
24445
- "package"
24446
- ]);
25187
+ access: "create"
25188
+ }), method(object({
25189
+ code: string(),
25190
+ redirectUri: string(),
25191
+ /** PKCE verifier. REQUIRED when the code carries a challenge. */
25192
+ codeVerifier: string().optional()
25193
+ }), object({
25194
+ accessToken: string(),
25195
+ refreshToken: string(),
25196
+ expiresIn: number()
25197
+ }).nullable(), {
25198
+ kind: "mutation",
25199
+ access: "view"
25200
+ }), method(object({ refreshToken: string() }), object({
25201
+ accessToken: string(),
25202
+ refreshToken: string(),
25203
+ expiresIn: number()
25204
+ }).nullable(), {
25205
+ kind: "mutation",
25206
+ access: "view"
25207
+ }), method(object({ token: string() }), object({
25208
+ userId: string(),
25209
+ username: string(),
25210
+ scopes: array(TokenScopeSchema)
25211
+ }).nullable(), { access: "view" }), method(_void(), array(OauthSessionSummarySchema), { auth: "admin" }), method(object({ id: string() }), object({ success: boolean() }), {
25212
+ kind: "mutation",
25213
+ auth: "admin",
25214
+ access: "delete"
25215
+ });
24447
25216
  /**
24448
- * Zone rules capability — per-camera CRUD over the {@link ZoneRule}
24449
- * arrays that decide how each pipeline stage uses the polygon zones.
24450
- *
24451
- * Hosted by `addon-pipeline-orchestrator` alongside the zones provider
24452
- * so the operator has a single hub-side source of truth for both
24453
- * geometry and behaviour. Per-stage rules are stored under the
24454
- * `zoneRules.<stage>` key in the orchestrator's per-device store and
24455
- * mirrored to the device-state slice `<stage>-zone-rules` on every
24456
- * mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
24457
- * subscribe to that slice and refresh their gating without
24458
- * round-tripping the cap.
25217
+ * Robot-vacuum cap. Models HA `vacuum.*` entities — anything with a
25218
+ * cleaning lifecycle plus a return-to-base / locate surface and an
25219
+ * optional fan-speed selector.
24459
25220
  *
24460
- * Sets are bulk-replace — the operator UI sends the new rule list
24461
- * wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
24462
- * naturally in the rule editor without per-rule mutation chatter.
24463
- */
24464
- var zoneRulesCapability = {
24465
- name: "zone-rules",
24466
- scope: "device",
24467
- mode: "singleton",
24468
- deviceTypes: [DeviceType.Camera],
24469
- methods: {
24470
- /** Read the full rule list for a given stage (empty when no rules
24471
- * are defined yet). */
24472
- listRules: method(object({
24473
- deviceId: number(),
24474
- stage: ZoneRuleStageEnum
24475
- }), array(ZoneRuleSchema).readonly()),
24476
- /** Bulk-replace the rule list for one stage. The provider validates
24477
- * each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
24478
- * thresholds in range) and rejects the whole patch if any entry
24479
- * is invalid — partial writes are a configuration footgun. */
24480
- setRules: method(object({
24481
- deviceId: number(),
24482
- stage: ZoneRuleStageEnum,
24483
- rules: array(ZoneRuleSchema).readonly()
24484
- }), _void(), {
24485
- kind: "mutation",
24486
- auth: "admin"
24487
- })
24488
- },
24489
- /**
24490
- * Runtime-state slice — every stage mirrored together so consumers
24491
- * see one reactive handle (`device.state.zoneRules.value`) instead
24492
- * of one per stage. Bulk-replace mutations on any stage write the full
24493
- * `{motion, detection, package}` shape, so subscribers always get the
24494
- * complete current set. Consumers that only care about one stage
24495
- * just read the matching property.
24496
- *
24497
- * `package` backs the package-drop detector — a package zone is a
24498
- * `ZoneRule` on the `'package'` stage referencing drawn polygons
24499
- * (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
24500
- * The orchestrator provider writes this stage as a first-class slice
24501
- * (Phase 4): every mutation mirrors the full `{motion, detection,
24502
- * package}` shape, so consumers read the current package rules directly
24503
- * off `device.state.zoneRules.value.package`.
24504
- */
24505
- runtimeState: object({
24506
- motion: array(ZoneRuleSchema).readonly(),
24507
- detection: array(ZoneRuleSchema).readonly(),
24508
- package: array(ZoneRuleSchema).readonly()
24509
- })
24510
- };
24511
- /**
24512
- * Accessory device helpers — shared across drivers.
25221
+ * State follows HA's canonical vacuum lifecycle: `idle` / `cleaning` /
25222
+ * `paused` / `returning` / `docked` / `error`. `batteryLevel`
25223
+ * (0..100) is nullable — some vacuums don't report a battery
25224
+ * percentage. `fanSpeed` is the current speed token (provider-verbatim,
25225
+ * e.g. `'standard'` / `'turbo'`) and `availableFanSpeeds` lists the
25226
+ * tokens the hardware accepts so the UI renders only supported choices.
24513
25227
  *
24514
- * Many vendor-specific drivers register accessory child devices on
24515
- * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
24516
- * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
24517
- * driver picks the right `DeviceType` + `DeviceRole` explicitly when
24518
- * spawning, builds a name derived from the parent, and produces a
24519
- * stableId tied to the parent so boot-restore can reconstruct the
24520
- * relationship.
25228
+ * The `setFanSpeed` method takes the bare `speed` token — the provider
25229
+ * validates it against the vacuum's own list. `locate` triggers the
25230
+ * find-me chirp; `returnToBase` sends it home.
24521
25231
  *
24522
- * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
24523
- * drivers may reasonably disagree on the right type for an accessory
24524
- * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
24525
- * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
24526
- * one canonical mapping was over-prescriptive and added a layer of
24527
- * indirection without saving meaningful code at call sites — the
24528
- * driver knows its own hardware best.
24529
- */
24530
- /**
24531
- * Subset of `DeviceRole` values that drivers register as child
24532
- * accessories of a parent device. Sourced verbatim from `DeviceRole`
24533
- * — `AccessoryKind` is the alias drivers use when building accessory
24534
- * children, so the call site reads as
24535
- * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
24536
- * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
24537
- * any role works, including non-accessory ones like Doorbell).
24538
- */
24539
- var AccessoryKind = {
24540
- Siren: DeviceRole.Siren,
24541
- Floodlight: DeviceRole.Floodlight,
24542
- Spotlight: DeviceRole.Spotlight,
24543
- PirSensor: DeviceRole.PirSensor,
24544
- Chime: DeviceRole.Chime,
24545
- Autotrack: DeviceRole.Autotrack,
24546
- Nightvision: DeviceRole.Nightvision,
24547
- PrivacyMask: DeviceRole.PrivacyMask
24548
- };
24549
- AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
24550
- DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
24551
- new Set(Object.values(DeviceType));
24552
- /**
24553
- * Heuristic check — `device.features?.includes(profile.when.hasFeature)`.
24554
- */
24555
- function deviceMatchesProfile(features, profile) {
24556
- if (!features || features.length === 0) return false;
24557
- return features.includes(profile.when.hasFeature);
24558
- }
24559
- /**
24560
- * Profile registry — order matters when multiple profiles match the
24561
- * same device (first match wins). Today there's only one entry.
24562
- */
24563
- var DEVICE_PROFILES = [{
24564
- id: "battery",
24565
- label: "Battery-operated camera",
24566
- when: { hasFeature: DeviceFeature.BatteryOperated },
24567
- defaults: {
24568
- audioMode: "disabled",
24569
- detectionMode: "on-motion"
24570
- },
24571
- settings: {}
24572
- }];
24573
- /**
24574
- * Resolve the profile that matches a device's features, or `null` when
24575
- * no profile matches. First-match-wins.
25232
+ * Consumable / waste tanks: `cleanWater` / `dirtyWater` / `detergent` /
25233
+ * `dustBin` each carry a nullable `{ level (0..100 %), status ('ok' |
25234
+ * 'low' | 'full') }` reading. Native providers (e.g. Dreame, Roborock)
25235
+ * SHOULD populate whichever tanks the hardware has — leave a field `null`
25236
+ * only when the device has no such tank at all, and use a `TankStatus`
25237
+ * with both inner fields `null` when the tank exists but its level is
25238
+ * currently unknown. HA `vacuum.*` entities expose no per-tank telemetry,
25239
+ * so the HA provider leaves all four `null`.
24576
25240
  */
24577
- function resolveDeviceProfile(features) {
24578
- for (const profile of DEVICE_PROFILES) if (deviceMatchesProfile(features, profile)) return profile;
24579
- return null;
24580
- }
25241
+ var VacuumStateSchema = _enum([
25242
+ "idle",
25243
+ "cleaning",
25244
+ "paused",
25245
+ "returning",
25246
+ "docked",
25247
+ "drying",
25248
+ "error"
25249
+ ]);
24581
25250
  /**
24582
- * Error types for the safe expression engine. Two distinct classes so callers
24583
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
24584
- * failure — both are non-fatal to the host: read paths degrade to "skip link".
25251
+ * One consumable / waste tank on a robot vacuum (clean-water, dirty-water,
25252
+ * detergent or dust-bin). A tank can report a numeric fill `level` (0..100),
25253
+ * a discrete `status` (binary-style hardware), or both. Both `null` means the
25254
+ * level is currently unknown; the OWNING field being `null` means the
25255
+ * hardware has no such tank at all.
24585
25256
  */
24586
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
24587
- * the failure is anchored to a character (author-facing inline feedback). */
24588
- var ExpressionParseError = class extends Error {
24589
- position;
24590
- constructor(message, position) {
24591
- super(message);
24592
- this.name = "ExpressionParseError";
24593
- this.position = position;
24594
- }
24595
- };
24596
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
24597
- * result, unknown builtin, step-budget exceeded). */
24598
- var ExpressionEvalError = class extends Error {
24599
- constructor(message) {
24600
- super(message);
24601
- this.name = "ExpressionEvalError";
24602
- }
24603
- };
25257
+ var TankStatusSchema = object({
25258
+ /** Numeric fill 0..100 when the hardware reports a percentage; null otherwise. */
25259
+ level: number().min(0).max(100).nullable(),
25260
+ /** Discrete state when the hardware is binary-mode; null otherwise. */
25261
+ status: _enum([
25262
+ "ok",
25263
+ "low",
25264
+ "full"
25265
+ ]).nullable()
25266
+ });
25267
+ object({
25268
+ /** Lifecycle state of the vacuum. */
25269
+ state: VacuumStateSchema,
25270
+ /** 0..100 battery percentage. Null when the device has no battery
25271
+ * reading. */
25272
+ batteryLevel: number().min(0).max(100).nullable(),
25273
+ /** Current fan-speed token (provider-verbatim). Null when unknown or
25274
+ * the vacuum has no speed control. */
25275
+ fanSpeed: string().nullable(),
25276
+ /** Speed tokens the hardware accepts — drives the UI selector. */
25277
+ availableFanSpeeds: array(string()),
25278
+ /** Clean-water (mop) tank. Null when the hardware has no clean-water tank. */
25279
+ cleanWater: TankStatusSchema.nullable(),
25280
+ /** Dirty-water (recovery) tank. Null when the hardware has no dirty-water tank. */
25281
+ dirtyWater: TankStatusSchema.nullable(),
25282
+ /** Detergent tank. Null when the hardware has no detergent tank. */
25283
+ detergent: TankStatusSchema.nullable(),
25284
+ /** Dust bin. Null when the hardware has no dust bin. */
25285
+ dustBin: TankStatusSchema.nullable(),
25286
+ /** 0..100 cleaning-completion percentage of the current task, or null. */
25287
+ progressPercent: number().min(0).max(100).nullable(),
25288
+ /** Current error code (0 / null = no error). */
25289
+ errorCode: number().nullable(),
25290
+ /** Human label for {@link errorCode}, or null when none / undecodable. */
25291
+ errorLabel: string().nullable(),
25292
+ /** Ms epoch when the slice was last updated. */
25293
+ lastChangedAt: number()
25294
+ });
25295
+ DeviceType.Vacuum, method(object({ deviceId: number().int().nonnegative() }), _void(), {
25296
+ kind: "mutation",
25297
+ auth: "admin"
25298
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
25299
+ kind: "mutation",
25300
+ auth: "admin"
25301
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
25302
+ kind: "mutation",
25303
+ auth: "admin"
25304
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
25305
+ kind: "mutation",
25306
+ auth: "admin"
25307
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
25308
+ kind: "mutation",
25309
+ auth: "admin"
25310
+ }), method(object({
25311
+ deviceId: number().int().nonnegative(),
25312
+ speed: string().min(1)
25313
+ }), _void(), {
25314
+ kind: "mutation",
25315
+ auth: "admin"
25316
+ });
25317
+ object({
25318
+ /** Lifecycle state of the valve. */
25319
+ state: _enum([
25320
+ "open",
25321
+ "opening",
25322
+ "closing",
25323
+ "closed",
25324
+ "stopped"
25325
+ ]),
25326
+ /** 0 = fully closed, 100 = fully open. Null when the device has no
25327
+ * intermediate position surface. */
25328
+ position: number().min(0).max(100).nullable(),
25329
+ /** Ms epoch when the slice was last updated. */
25330
+ lastChangedAt: number()
25331
+ });
25332
+ DeviceType.Valve, method(object({ deviceId: number().int().nonnegative() }), _void(), {
25333
+ kind: "mutation",
25334
+ auth: "admin"
25335
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
25336
+ kind: "mutation",
25337
+ auth: "admin"
25338
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
25339
+ kind: "mutation",
25340
+ auth: "admin"
25341
+ }), method(object({
25342
+ deviceId: number().int().nonnegative(),
25343
+ position: number().min(0).max(100)
25344
+ }), _void(), {
25345
+ kind: "mutation",
25346
+ auth: "admin"
25347
+ });
25348
+ object({
25349
+ detected: boolean(),
25350
+ /** Ms epoch of the last transition. 0 if never observed. */
25351
+ lastChangedAt: number()
25352
+ });
25353
+ DeviceType.Sensor;
25354
+ object({
25355
+ /** Current measured temperature. Null when not reported. */
25356
+ currentTemp: number().nullable(),
25357
+ /** Target temperature setpoint. Null when no setpoint surface. */
25358
+ targetTemp: number().nullable(),
25359
+ /** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
25360
+ * `heat_pump` / `high_demand` / `performance` / `off`). Null when the
25361
+ * device reports an unknown state. */
25362
+ operationMode: string().nullable(),
25363
+ /** Available operation modes = HA `operation_list`. */
25364
+ availableModes: array(string()),
25365
+ /** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
25366
+ * has no away surface. */
25367
+ away: boolean().nullable(),
25368
+ /** HA `min_temp` attribute. Null when not reported. */
25369
+ minTemp: number().nullable(),
25370
+ /** HA `max_temp` attribute. Null when not reported. */
25371
+ maxTemp: number().nullable(),
25372
+ /** Ms epoch when the slice was last updated. */
25373
+ lastChangedAt: number()
25374
+ });
25375
+ DeviceType.WaterHeater, method(object({
25376
+ deviceId: number().int().nonnegative(),
25377
+ temp: number().finite()
25378
+ }), _void(), {
25379
+ kind: "mutation",
25380
+ auth: "admin"
25381
+ }), method(object({
25382
+ deviceId: number().int().nonnegative(),
25383
+ mode: string().min(1)
25384
+ }), _void(), {
25385
+ kind: "mutation",
25386
+ auth: "admin"
25387
+ }), method(object({
25388
+ deviceId: number().int().nonnegative(),
25389
+ on: boolean()
25390
+ }), _void(), {
25391
+ kind: "mutation",
25392
+ auth: "admin"
25393
+ });
25394
+ object({
25395
+ /** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
25396
+ * when no condition has been reported yet. */
25397
+ condition: string().nullable(),
25398
+ /** Current temperature in the reported unit. Null when not provided. */
25399
+ temperature: number().nullable(),
25400
+ /** Temperature unit string (e.g. `°C` / `°F`). Null when not provided. */
25401
+ temperatureUnit: string().nullable(),
25402
+ /** Relative humidity (0..100). Null when not provided. */
25403
+ humidity: number().min(0).max(100).nullable(),
25404
+ /** Barometric pressure in the reported unit. Null when not provided. */
25405
+ pressure: number().nullable(),
25406
+ /** Pressure unit string (e.g. `hPa` / `inHg`). Null when not provided. */
25407
+ pressureUnit: string().nullable(),
25408
+ /** Wind speed in the reported unit. Null when not provided. */
25409
+ windSpeed: number().nullable(),
25410
+ /** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
25411
+ windSpeedUnit: string().nullable(),
25412
+ /** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
25413
+ windBearing: number().nullable(),
25414
+ /** Ms epoch when the slice was last updated. */
25415
+ lastFetchedAt: number()
25416
+ });
25417
+ DeviceType.Weather;
24604
25418
  /**
24605
- * Frozen, null-prototype builtin function table for the expression engine
24606
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
24607
- * parser rejects any callee not in it, and the evaluator gates each call on an
24608
- * own-property check against it.
24609
- *
24610
- * Because the object has a NULL prototype AND is `Object.freeze`d:
24611
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
24612
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
24613
- * (there is no `Object.prototype` in the chain), so those names are not
24614
- * callable — they are simply "unknown function" at parse time.
25419
+ * Per-zone occupancy aggregation produced by the analytics frame
25420
+ * processor on every inference result. Covers the full combinatorial
25421
+ * matrix the operator UI needs: total objects everywhere, total
25422
+ * objects per zone, single class everywhere, single class per zone,
25423
+ * objects outside any zone.
24615
25424
  *
24616
- * Every numeric argument is validated as a finite number and every numeric
24617
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
24618
- * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
24619
- * closed rather than emitting a garbage value.
24620
- */
24621
- function asFiniteNumber(value, name, index) {
24622
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
24623
- return value;
24624
- }
24625
- function asString$1(value, name, index) {
24626
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
24627
- return value;
24628
- }
24629
- function finiteResult(value, name) {
24630
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
24631
- return value;
24632
- }
24633
- function allFiniteNumbers(args, name) {
24634
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
24635
- }
24636
- var INF = Number.POSITIVE_INFINITY;
24637
- var table = {
24638
- min: {
24639
- minArgs: 1,
24640
- maxArgs: INF,
24641
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
24642
- },
24643
- max: {
24644
- minArgs: 1,
24645
- maxArgs: INF,
24646
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
24647
- },
24648
- abs: {
24649
- minArgs: 1,
24650
- maxArgs: 1,
24651
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
24652
- },
24653
- floor: {
24654
- minArgs: 1,
24655
- maxArgs: 1,
24656
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
24657
- },
24658
- ceil: {
24659
- minArgs: 1,
24660
- maxArgs: 1,
24661
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
24662
- },
24663
- sqrt: {
24664
- minArgs: 1,
24665
- maxArgs: 1,
24666
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
24667
- },
24668
- round: {
24669
- minArgs: 1,
24670
- maxArgs: 2,
24671
- apply: (args) => {
24672
- const x = asFiniteNumber(args[0], "round", 0);
24673
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
24674
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
24675
- const factor = 10 ** digits;
24676
- return finiteResult(Math.round(x * factor) / factor, "round");
24677
- }
24678
- },
24679
- pow: {
24680
- minArgs: 2,
24681
- maxArgs: 2,
24682
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
24683
- },
24684
- clamp: {
24685
- minArgs: 3,
24686
- maxArgs: 3,
24687
- apply: (args) => {
24688
- const x = asFiniteNumber(args[0], "clamp", 0);
24689
- const lo = asFiniteNumber(args[1], "clamp", 1);
24690
- const hi = asFiniteNumber(args[2], "clamp", 2);
24691
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
24692
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
24693
- }
24694
- },
24695
- avg: {
24696
- minArgs: 1,
24697
- maxArgs: INF,
24698
- apply: (args) => {
24699
- const nums = allFiniteNumbers(args, "avg");
24700
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
24701
- }
24702
- },
24703
- sum: {
24704
- minArgs: 1,
24705
- maxArgs: INF,
24706
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
24707
- },
24708
- coalesce: {
24709
- minArgs: 1,
24710
- maxArgs: INF,
24711
- apply: (args) => {
24712
- for (const a of args) if (a !== null) return a;
24713
- return null;
24714
- }
24715
- },
24716
- age: {
24717
- minArgs: 2,
24718
- maxArgs: 2,
24719
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
24720
- },
24721
- convert: {
24722
- minArgs: 3,
24723
- maxArgs: 3,
24724
- apply: (args, hooks) => {
24725
- const x = asFiniteNumber(args[0], "convert", 0);
24726
- const from = asString$1(args[1], "convert", 1).trim();
24727
- const to = asString$1(args[2], "convert", 2).trim();
24728
- if (hooks.convert) {
24729
- const out = hooks.convert(x, from, to);
24730
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
24731
- return finiteResult(out, "convert");
24732
- }
24733
- if (from === to) return x;
24734
- throw new ExpressionEvalError("convert: unit conversion table not installed");
24735
- }
24736
- }
24737
- };
24738
- Object.freeze(Object.assign(Object.create(null), table));
24739
- /** The set of valid builtin names — used by the parser to reject unknown
24740
- * callees at parse time (immediate author feedback). */
24741
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
24742
- /**
24743
- * Resource-bound constants for the safe expression engine.
25425
+ * Counts are derived from the analytics tracker (tracked detections
25426
+ * with stable trackIds), not raw detector hits — this filters out
25427
+ * one-off detector flickers and gives counts that match what the user
25428
+ * sees on the live overlay.
24744
25429
  *
24745
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
24746
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
24747
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
24748
- * work a single author-supplied expression can request, so a hostile or
24749
- * accidental pathological string can never spend unbounded CPU/memory.
25430
+ * Overlap policy: a detection that intersects two zones counts in
25431
+ * BOTH zones' `byClass` and `totalObjects` (count-in-each). The
25432
+ * `frame` aggregate de-duplicates trivially since it's frame-wide.
25433
+ * `unzoned` counts only detections that landed in zero zones.
24750
25434
  */
24751
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
24752
- * rejected without allocation. */
24753
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
24754
- /** A legal binding / identifier name. */
24755
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
24756
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
24757
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
24758
- var RESERVED_BINDING_NAMES = new Set([
24759
- "now",
24760
- "true",
24761
- "false",
24762
- "null"
24763
- ]);
25435
+ var PerScopeBreakdownSchema = object({
25436
+ /** Total tracked objects in this scope (frame / zone / unzoned). */
25437
+ totalObjects: number().int().nonnegative(),
25438
+ /** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
25439
+ byClass: record(string(), number().int().nonnegative())
25440
+ });
25441
+ var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
25442
+ zoneId: string(),
25443
+ zoneName: string(),
25444
+ /** TrackIds of objects currently inside this zone — for cross-reference
25445
+ * with the per-track detail panel and live overlay. */
25446
+ trackIds: array(string()).readonly()
25447
+ });
24764
25448
  /**
24765
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
24766
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
24767
- * single/double-quoted strings with a tiny escape set, identifiers, the three
24768
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
24769
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
24770
- * is a parse error with a source position, so member access / assignment /
24771
- * template literals are lexically impossible.
25449
+ * A parked ("stationary") object surfaced alongside occupancy — an object that
25450
+ * settled and stopped moving. It is NO LONGER a tracked object (the tracker was
25451
+ * told to forget it so it stops re-spawning tracks/events), but it IS still
25452
+ * physically present, so it keeps counting toward `frame` occupancy and is
25453
+ * listed here so the UI can show it in a dedicated "Stationary" section instead
25454
+ * of flooding the live event feed.
24772
25455
  */
24773
- var KEYWORDS = new Set([
24774
- "true",
24775
- "false",
24776
- "null"
25456
+ var StationaryObjectSchema = object({
25457
+ id: string(),
25458
+ className: string(),
25459
+ bbox: object({
25460
+ x: number(),
25461
+ y: number(),
25462
+ w: number(),
25463
+ h: number()
25464
+ }),
25465
+ frameWidth: number().int().nonnegative(),
25466
+ frameHeight: number().int().nonnegative(),
25467
+ /** When the source track was first seen. */
25468
+ firstSeenAt: number().int(),
25469
+ /** When the object was recognised as parked (promotion time). */
25470
+ becameStationaryAt: number().int(),
25471
+ /** Last frame a detection confirmed the object is still there. */
25472
+ lastConfirmedAt: number().int(),
25473
+ /** Enrichment label carried from the source track (identity / plate). */
25474
+ label: string().optional(),
25475
+ /** Native-resolution key-frame media key for the parked object's best image. */
25476
+ keyFrameMediaKey: string().optional()
25477
+ });
25478
+ var CameraOccupancySnapshotSchema = object({
25479
+ /** Frame timestamp of the inference result that produced this snapshot. */
25480
+ ts: number().int(),
25481
+ /** Frame width/height in pixels — let the UI normalize bbox coords. */
25482
+ frameWidth: number().int().nonnegative(),
25483
+ frameHeight: number().int().nonnegative(),
25484
+ /** Per-zone breakdown — one entry per defined zone (user + onboard). */
25485
+ zones: array(ZoneScopeBreakdownSchema).readonly(),
25486
+ /** Frame-wide aggregate (everywhere, regardless of zone membership).
25487
+ * INCLUDES currently-confirmed stationary objects (they are still present). */
25488
+ frame: PerScopeBreakdownSchema,
25489
+ /** Detections that landed outside every zone. Empty when no zones defined. */
25490
+ unzoned: PerScopeBreakdownSchema,
25491
+ /** Parked objects on this camera (additive — absent on legacy snapshots).
25492
+ * Surfaced separately so the UI shows them in a dedicated section rather
25493
+ * than as repeated tracks/events. */
25494
+ stationaryObjects: array(StationaryObjectSchema).readonly().optional()
25495
+ });
25496
+ /**
25497
+ * Time-series resolution. The history methods return one bucket per
25498
+ * step over the requested range. Smaller resolutions cost more
25499
+ * memory + bandwidth; bound to discrete steps so caller cannot ask
25500
+ * for arbitrary fractional buckets.
25501
+ */
25502
+ var HistoryResolutionEnum = _enum([
25503
+ "minute",
25504
+ "5min",
25505
+ "hour"
24777
25506
  ]);
24778
- function isDigit(ch) {
24779
- return ch >= "0" && ch <= "9";
24780
- }
24781
- function isIdentStart(ch) {
24782
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
24783
- }
24784
- function isIdentPart(ch) {
24785
- return isIdentStart(ch) || isDigit(ch);
24786
- }
24787
- function isWhitespace(ch) {
24788
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
24789
- }
24790
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
24791
- * Throws `ExpressionParseError` on any illegal character or unterminated
24792
- * string. */
24793
- function tokenize(source) {
24794
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
24795
- const tokens = [];
24796
- let i = 0;
24797
- const n = source.length;
24798
- while (i < n) {
24799
- const ch = source[i];
24800
- if (isWhitespace(ch)) {
24801
- i += 1;
24802
- continue;
24803
- }
24804
- if (isDigit(ch)) {
24805
- const start = i;
24806
- while (i < n && isDigit(source[i])) i += 1;
24807
- if (i < n && source[i] === ".") {
24808
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
24809
- i += 1;
24810
- while (i < n && isDigit(source[i])) i += 1;
24811
- }
24812
- const text = source.slice(start, i);
24813
- const value = Number(text);
24814
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
24815
- tokens.push({
24816
- type: "number",
24817
- value,
24818
- pos: start
24819
- });
24820
- continue;
24821
- }
24822
- if (ch === "'" || ch === "\"") {
24823
- const quote = ch;
24824
- const start = i;
24825
- i += 1;
24826
- let out = "";
24827
- let closed = false;
24828
- while (i < n) {
24829
- const c = source[i];
24830
- if (c === "\\") {
24831
- const next = i + 1 < n ? source[i + 1] : "";
24832
- if (next === "\\" || next === "'" || next === "\"") {
24833
- out += next;
24834
- i += 2;
24835
- continue;
24836
- }
24837
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
24838
- }
24839
- if (c === quote) {
24840
- closed = true;
24841
- i += 1;
24842
- break;
24843
- }
24844
- out += c;
24845
- i += 1;
24846
- }
24847
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
24848
- tokens.push({
24849
- type: "string",
24850
- value: out,
24851
- pos: start
24852
- });
24853
- continue;
24854
- }
24855
- if (isIdentStart(ch)) {
24856
- const start = i;
24857
- while (i < n && isIdentPart(source[i])) i += 1;
24858
- const text = source.slice(start, i);
24859
- if (KEYWORDS.has(text)) tokens.push({
24860
- type: "keyword",
24861
- keyword: keywordOf(text),
24862
- pos: start
24863
- });
24864
- else tokens.push({
24865
- type: "identifier",
24866
- name: text,
24867
- pos: start
24868
- });
24869
- continue;
24870
- }
24871
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
24872
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
24873
- tokens.push({
24874
- type: "punct",
24875
- punct: two,
24876
- pos: i
24877
- });
24878
- i += 2;
24879
- continue;
24880
- }
24881
- if (isSinglePunct(ch)) {
24882
- tokens.push({
24883
- type: "punct",
24884
- punct: ch,
24885
- pos: i
24886
- });
24887
- i += 1;
24888
- continue;
24889
- }
24890
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
24891
- }
24892
- tokens.push({
24893
- type: "eof",
24894
- pos: n
24895
- });
24896
- return tokens;
24897
- }
24898
- function keywordOf(text) {
24899
- if (text === "true") return "true";
24900
- if (text === "false") return "false";
24901
- return "null";
24902
- }
24903
- function isSinglePunct(ch) {
24904
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
24905
- }
25507
+ var HistoryRangeSchema = object({
25508
+ /** Range start (epoch ms, inclusive). */
25509
+ from: number().int(),
25510
+ /** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
25511
+ to: number().int(),
25512
+ resolution: HistoryResolutionEnum
25513
+ });
25514
+ var HistoryPointSchema = object({
25515
+ /** Bucket midpoint (epoch ms). */
25516
+ ts: number().int(),
25517
+ /** Object count averaged over the bucket (rounded to nearest integer). */
25518
+ count: number().int().nonnegative()
25519
+ });
25520
+ DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({
25521
+ deviceId: number(),
25522
+ zoneId: string(),
25523
+ className: string().optional()
25524
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
25525
+ deviceId: number(),
25526
+ className: string().optional()
25527
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
25528
+ deviceId: number(),
25529
+ className: string().optional()
25530
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
24906
25531
  /**
24907
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
25532
+ * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
25533
+ * cap so a single CRUD surface backs every consumer; each stage has
25534
+ * its own dev-state mirror slice (`motion-zone-rules`,
25535
+ * `detection-zone-rules`, …) so consumer addons subscribe independently.
24908
25536
  *
24909
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
24910
- * → relational → additive → multiplicative → unary `! -` → call / primary.
24911
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
24912
- * string validated against the builtin table at parse time, so an unknown
24913
- * function is rejected immediately (author feedback) and a persisted expression
24914
- * that references a since-removed builtin degrades at read.
25537
+ * Extend the enum here when a new gating consumer comes online (audio
25538
+ * gating, alert filtering, …) — no other surface needs to change.
25539
+ */
25540
+ var ZoneRuleStageEnum = _enum([
25541
+ "motion",
25542
+ "detection",
25543
+ "package"
25544
+ ]);
25545
+ /**
25546
+ * Zone rules capability — per-camera CRUD over the {@link ZoneRule}
25547
+ * arrays that decide how each pipeline stage uses the polygon zones.
24915
25548
  *
24916
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
24917
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
25549
+ * Hosted by `addon-pipeline-orchestrator` alongside the zones provider
25550
+ * so the operator has a single hub-side source of truth for both
25551
+ * geometry and behaviour. Per-stage rules are stored under the
25552
+ * `zoneRules.<stage>` key in the orchestrator's per-device store and
25553
+ * mirrored to the device-state slice `<stage>-zone-rules` on every
25554
+ * mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
25555
+ * subscribe to that slice and refresh their gating without
25556
+ * round-tripping the cap.
25557
+ *
25558
+ * Sets are bulk-replace — the operator UI sends the new rule list
25559
+ * wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
25560
+ * naturally in the rule editor without per-rule mutation chatter.
24918
25561
  */
24919
- /** Binary/logical operator precedence (higher binds tighter). */
24920
- var BINARY_PRECEDENCE = {
24921
- "||": 1,
24922
- "&&": 2,
24923
- "==": 3,
24924
- "!=": 3,
24925
- "<": 4,
24926
- "<=": 4,
24927
- ">": 4,
24928
- ">=": 4,
24929
- "+": 5,
24930
- "-": 5,
24931
- "*": 6,
24932
- "/": 6,
24933
- "%": 6
24934
- };
24935
- function isLogicalOp(op) {
24936
- return op === "&&" || op === "||";
24937
- }
24938
- function isBinaryOp(op) {
24939
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
24940
- }
24941
- var Parser = class {
24942
- tokens;
24943
- pos = 0;
24944
- nodeCount = 0;
24945
- identifiers = /* @__PURE__ */ new Set();
24946
- callees = /* @__PURE__ */ new Set();
24947
- constructor(tokens) {
24948
- this.tokens = tokens;
24949
- }
24950
- parse() {
24951
- const ast = this.parseTernary();
24952
- const tok = this.peek();
24953
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
24954
- return {
24955
- ast,
24956
- identifiers: this.identifiers,
24957
- callees: this.callees,
24958
- nodeCount: this.nodeCount
24959
- };
24960
- }
24961
- peek() {
24962
- return this.tokens[this.pos];
24963
- }
24964
- next() {
24965
- return this.tokens[this.pos++];
24966
- }
24967
- /** Consume a punctuator token, erroring if the next token isn't it. */
24968
- expectPunct(punct) {
24969
- const tok = this.peek();
24970
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
24971
- this.pos += 1;
24972
- }
24973
- matchPunct(punct) {
24974
- const tok = this.peek();
24975
- if (tok.type === "punct" && tok.punct === punct) {
24976
- this.pos += 1;
24977
- return true;
24978
- }
24979
- return false;
24980
- }
24981
- countNode() {
24982
- this.nodeCount += 1;
24983
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
24984
- }
24985
- parseTernary() {
24986
- const test = this.parseBinary(1);
24987
- if (this.matchPunct("?")) {
24988
- const consequent = this.parseTernary();
24989
- this.expectPunct(":");
24990
- const alternate = this.parseTernary();
24991
- this.countNode();
24992
- return {
24993
- kind: "conditional",
24994
- test,
24995
- consequent,
24996
- alternate
24997
- };
24998
- }
24999
- return test;
25000
- }
25001
- parseBinary(minPrec) {
25002
- let left = this.parseUnary();
25003
- for (;;) {
25004
- const tok = this.peek();
25005
- if (tok.type !== "punct") break;
25006
- const prec = BINARY_PRECEDENCE[tok.punct];
25007
- if (prec === void 0 || prec < minPrec) break;
25008
- const op = tok.punct;
25009
- this.pos += 1;
25010
- const right = this.parseBinary(prec + 1);
25011
- this.countNode();
25012
- if (isLogicalOp(op)) left = {
25013
- kind: "logical",
25014
- op,
25015
- left,
25016
- right
25017
- };
25018
- else if (isBinaryOp(op)) left = {
25019
- kind: "binary",
25020
- op,
25021
- left,
25022
- right
25023
- };
25024
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
25025
- }
25026
- return left;
25027
- }
25028
- parseUnary() {
25029
- const tok = this.peek();
25030
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
25031
- const op = tok.punct;
25032
- this.pos += 1;
25033
- const operand = this.parseUnary();
25034
- this.countNode();
25035
- return {
25036
- kind: "unary",
25037
- op,
25038
- operand
25039
- };
25040
- }
25041
- return this.parsePrimary();
25042
- }
25043
- parsePrimary() {
25044
- const tok = this.next();
25045
- switch (tok.type) {
25046
- case "number":
25047
- this.countNode();
25048
- return {
25049
- kind: "literal",
25050
- value: tok.value
25051
- };
25052
- case "string":
25053
- this.countNode();
25054
- return {
25055
- kind: "literal",
25056
- value: tok.value
25057
- };
25058
- case "keyword":
25059
- this.countNode();
25060
- return {
25061
- kind: "literal",
25062
- value: tok.keyword === "null" ? null : tok.keyword === "true"
25063
- };
25064
- case "identifier": {
25065
- const nextTok = this.peek();
25066
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
25067
- this.identifiers.add(tok.name);
25068
- this.countNode();
25069
- return {
25070
- kind: "identifier",
25071
- name: tok.name
25072
- };
25073
- }
25074
- case "punct":
25075
- if (tok.punct === "(") {
25076
- const inner = this.parseTernary();
25077
- this.expectPunct(")");
25078
- return inner;
25079
- }
25080
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
25081
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
25082
- }
25083
- }
25084
- parseCall(callee, pos) {
25085
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
25086
- this.expectPunct("(");
25087
- const args = [];
25088
- if (!this.matchPunct(")")) for (;;) {
25089
- args.push(this.parseTernary());
25090
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
25091
- if (this.matchPunct(",")) continue;
25092
- this.expectPunct(")");
25093
- break;
25094
- }
25095
- this.callees.add(callee);
25096
- this.countNode();
25097
- return {
25098
- kind: "call",
25099
- callee,
25100
- args
25101
- };
25102
- }
25562
+ var zoneRulesCapability = {
25563
+ name: "zone-rules",
25564
+ scope: "device",
25565
+ mode: "singleton",
25566
+ deviceTypes: [DeviceType.Camera],
25567
+ methods: {
25568
+ /** Read the full rule list for a given stage (empty when no rules
25569
+ * are defined yet). */
25570
+ listRules: method(object({
25571
+ deviceId: number(),
25572
+ stage: ZoneRuleStageEnum
25573
+ }), array(ZoneRuleSchema).readonly()),
25574
+ /** Bulk-replace the rule list for one stage. The provider validates
25575
+ * each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
25576
+ * thresholds in range) and rejects the whole patch if any entry
25577
+ * is invalid — partial writes are a configuration footgun. */
25578
+ setRules: method(object({
25579
+ deviceId: number(),
25580
+ stage: ZoneRuleStageEnum,
25581
+ rules: array(ZoneRuleSchema).readonly()
25582
+ }), _void(), {
25583
+ kind: "mutation",
25584
+ auth: "admin"
25585
+ })
25586
+ },
25587
+ /**
25588
+ * Runtime-state slice — every stage mirrored together so consumers
25589
+ * see one reactive handle (`device.state.zoneRules.value`) instead
25590
+ * of one per stage. Bulk-replace mutations on any stage write the full
25591
+ * `{motion, detection, package}` shape, so subscribers always get the
25592
+ * complete current set. Consumers that only care about one stage
25593
+ * just read the matching property.
25594
+ *
25595
+ * `package` backs the package-drop detector — a package zone is a
25596
+ * `ZoneRule` on the `'package'` stage referencing drawn polygons
25597
+ * (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
25598
+ * The orchestrator provider writes this stage as a first-class slice
25599
+ * (Phase 4): every mutation mirrors the full `{motion, detection,
25600
+ * package}` shape, so consumers read the current package rules directly
25601
+ * off `device.state.zoneRules.value.package`.
25602
+ */
25603
+ runtimeState: object({
25604
+ motion: array(ZoneRuleSchema).readonly(),
25605
+ detection: array(ZoneRuleSchema).readonly(),
25606
+ package: array(ZoneRuleSchema).readonly()
25607
+ })
25103
25608
  };
25104
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
25105
- * `ExpressionParseError` on any lexical or grammatical failure. */
25106
- function parseExpression(source) {
25107
- return new Parser(tokenize(source)).parse();
25108
- }
25109
25609
  /**
25110
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
25111
- * by expr"). The cache stores BOTH successes and failures (negative caching),
25112
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
25113
- * one per read on a hot resolve path.
25610
+ * Accessory device helpers — shared across drivers.
25114
25611
  *
25115
- * The cache is a module-level singleton: entries are pure, content-addressed
25116
- * ASTs keyed by the raw source string, so sharing one instance across all
25117
- * callers is safe and maximises hit rate.
25612
+ * Many vendor-specific drivers register accessory child devices on
25613
+ * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
25614
+ * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
25615
+ * driver picks the right `DeviceType` + `DeviceRole` explicitly when
25616
+ * spawning, builds a name derived from the parent, and produces a
25617
+ * stableId tied to the parent so boot-restore can reconstruct the
25618
+ * relationship.
25619
+ *
25620
+ * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
25621
+ * drivers may reasonably disagree on the right type for an accessory
25622
+ * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
25623
+ * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
25624
+ * one canonical mapping was over-prescriptive and added a layer of
25625
+ * indirection without saving meaningful code at call sites — the
25626
+ * driver knows its own hardware best.
25118
25627
  */
25119
- var cache = /* @__PURE__ */ new Map();
25120
- function getCached(source) {
25121
- const hit = cache.get(source);
25122
- if (hit !== void 0) {
25123
- cache.delete(source);
25124
- cache.set(source, hit);
25125
- return hit;
25126
- }
25127
- let result;
25128
- try {
25129
- result = {
25130
- ok: true,
25131
- parsed: parseExpression(source)
25132
- };
25133
- } catch (err) {
25134
- result = {
25135
- ok: false,
25136
- error: err instanceof ExpressionParseError ? err.message : String(err)
25137
- };
25138
- }
25139
- cache.set(source, result);
25140
- if (cache.size > 256) {
25141
- const oldest = cache.keys().next().value;
25142
- if (oldest !== void 0) cache.delete(oldest);
25143
- }
25144
- return result;
25145
- }
25146
- /** Compile `source`, returning a discriminated result instead of throwing.
25147
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
25148
- function compileExpressionSafe(source) {
25149
- return getCached(source);
25628
+ /**
25629
+ * Subset of `DeviceRole` values that drivers register as child
25630
+ * accessories of a parent device. Sourced verbatim from `DeviceRole`
25631
+ * — `AccessoryKind` is the alias drivers use when building accessory
25632
+ * children, so the call site reads as
25633
+ * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
25634
+ * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
25635
+ * any role works, including non-accessory ones like Doorbell).
25636
+ */
25637
+ var AccessoryKind = {
25638
+ Siren: DeviceRole.Siren,
25639
+ Floodlight: DeviceRole.Floodlight,
25640
+ Spotlight: DeviceRole.Spotlight,
25641
+ PirSensor: DeviceRole.PirSensor,
25642
+ Chime: DeviceRole.Chime,
25643
+ Autotrack: DeviceRole.Autotrack,
25644
+ Nightvision: DeviceRole.Nightvision,
25645
+ PrivacyMask: DeviceRole.PrivacyMask
25646
+ };
25647
+ AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
25648
+ DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Camera, DeviceType.Hub, DeviceType.Switch, DeviceType.Siren, DeviceType.Light, DeviceType.Fan, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder;
25649
+ new Set(Object.values(DeviceType));
25650
+ /**
25651
+ * Heuristic check — `device.features?.includes(profile.when.hasFeature)`.
25652
+ */
25653
+ function deviceMatchesProfile(features, profile) {
25654
+ if (!features || features.length === 0) return false;
25655
+ return features.includes(profile.when.hasFeature);
25150
25656
  }
25151
- Object.freeze({});
25152
25657
  /**
25153
- * Author-time validation. Returns `null` when the source is valid, else a
25154
- * human-readable error message. Checks: the expression compiles; binding count
25155
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
25156
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
25157
- * FREE identifier of the AST is covered by a binding or the injected `now`.
25658
+ * Profile registry — order matters when multiple profiles match the
25659
+ * same device (first match wins). Today there's only one entry.
25158
25660
  */
25159
- function validateExpressionSource(src) {
25160
- const names = Object.keys(src.bindings);
25161
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
25162
- for (const name of names) {
25163
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
25164
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
25165
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
25166
- }
25167
- const compiled = compileExpressionSafe(src.expr);
25168
- if (!compiled.ok) return compiled.error;
25169
- const bound = new Set(names);
25170
- for (const id of compiled.parsed.identifiers) {
25171
- if (id === "now") continue;
25172
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
25173
- }
25661
+ var DEVICE_PROFILES = [{
25662
+ id: "battery",
25663
+ label: "Battery-operated camera",
25664
+ when: { hasFeature: DeviceFeature.BatteryOperated },
25665
+ defaults: {
25666
+ audioMode: "disabled",
25667
+ detectionMode: "on-motion"
25668
+ },
25669
+ settings: {}
25670
+ }];
25671
+ /**
25672
+ * Resolve the profile that matches a device's features, or `null` when
25673
+ * no profile matches. First-match-wins.
25674
+ */
25675
+ function resolveDeviceProfile(features) {
25676
+ for (const profile of DEVICE_PROFILES) if (deviceMatchesProfile(features, profile)) return profile;
25174
25677
  return null;
25175
25678
  }
25176
- var ExpressionBindingSourceSchema = union([
25177
- object({
25178
- kind: literal("field").optional(),
25179
- sourceKey: string(),
25180
- cap: string(),
25181
- fieldPath: string()
25182
- }),
25183
- object({
25184
- kind: literal("literal"),
25185
- value: union([
25186
- string(),
25187
- number(),
25188
- boolean(),
25189
- _null()
25190
- ])
25191
- }),
25192
- object({
25193
- kind: literal("global"),
25194
- sourceStableId: string(),
25195
- cap: string(),
25196
- fieldPath: string()
25197
- })
25198
- ]);
25199
- object({
25200
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
25201
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
25202
- }).superRefine((src, ctx) => {
25203
- const err = validateExpressionSource(src);
25204
- if (err !== null) ctx.addIssue({
25205
- code: "custom",
25206
- message: err,
25207
- path: ["expr"]
25208
- });
25209
- });
25210
25679
  Object.freeze({
25211
25680
  "accessories.setChildHidden": {
25212
25681
  capName: "accessories",
@@ -25982,6 +26451,18 @@ Object.freeze({
25982
26451
  addonId: null,
25983
26452
  access: "create"
25984
26453
  },
26454
+ "connectionTest.describeTest": {
26455
+ capName: "connection-test",
26456
+ capScope: "system",
26457
+ addonId: null,
26458
+ access: "view"
26459
+ },
26460
+ "connectionTest.testSettings": {
26461
+ capName: "connection-test",
26462
+ capScope: "system",
26463
+ addonId: null,
26464
+ access: "create"
26465
+ },
25985
26466
  "consumables.reset": {
25986
26467
  capName: "consumables",
25987
26468
  capScope: "device",
@@ -26372,6 +26853,12 @@ Object.freeze({
26372
26853
  addonId: null,
26373
26854
  access: "create"
26374
26855
  },
26856
+ "deviceManager.adoptionCancelJob": {
26857
+ capName: "device-manager",
26858
+ capScope: "system",
26859
+ addonId: null,
26860
+ access: "create"
26861
+ },
26375
26862
  "deviceManager.adoptionListCandidateFilters": {
26376
26863
  capName: "device-manager",
26377
26864
  capScope: "system",
@@ -26384,6 +26871,12 @@ Object.freeze({
26384
26871
  addonId: null,
26385
26872
  access: "view"
26386
26873
  },
26874
+ "deviceManager.adoptionListJobs": {
26875
+ capName: "device-manager",
26876
+ capScope: "system",
26877
+ addonId: null,
26878
+ access: "view"
26879
+ },
26387
26880
  "deviceManager.adoptionRefresh": {
26388
26881
  capName: "device-manager",
26389
26882
  capScope: "system",
@@ -26402,6 +26895,12 @@ Object.freeze({
26402
26895
  addonId: null,
26403
26896
  access: "create"
26404
26897
  },
26898
+ "deviceManager.adoptionStartJob": {
26899
+ capName: "device-manager",
26900
+ capScope: "system",
26901
+ addonId: null,
26902
+ access: "create"
26903
+ },
26405
26904
  "deviceManager.allocateDeviceId": {
26406
26905
  capName: "device-manager",
26407
26906
  capScope: "system",
@@ -29540,6 +30039,12 @@ Object.freeze({
29540
30039
  addonId: null,
29541
30040
  access: "view"
29542
30041
  },
30042
+ "snapshot.getSnapshotLinks": {
30043
+ capName: "snapshot",
30044
+ capScope: "device",
30045
+ addonId: null,
30046
+ access: "view"
30047
+ },
29543
30048
  "snapshot.getSnapshotOverview": {
29544
30049
  capName: "snapshot",
29545
30050
  capScope: "device",
@@ -32905,6 +33410,7 @@ function composeCameraStatus(input) {
32905
33410
  audio: mapAudio(input.audioResult),
32906
33411
  recording: mapRecording(input.recordingResult),
32907
33412
  switchedOff: input.switchedOff,
33413
+ degraded: input.degraded,
32908
33414
  fetchedAt: input.fetchedAt
32909
33415
  };
32910
33416
  }
@@ -32956,24 +33462,74 @@ var AUDIO_SILENCING_SWITCH_IDS = [
32956
33462
  "broker-audio",
32957
33463
  "audio-analysis"
32958
33464
  ];
33465
+ /**
33466
+ * Sentinel resolved by the per-stage timer. A unique symbol rather than `null`
33467
+ * so the race result can be narrowed WITHOUT a cast — and, more to the point,
33468
+ * so a stage that legitimately resolves `null` is never mistaken for one that
33469
+ * ran out of time.
33470
+ */
33471
+ var STAGE_TIMED_OUT = Symbol("camera-status-stage-timeout");
32959
33472
  var CameraStatusService = class {
32960
33473
  deps;
32961
33474
  constructor(deps) {
32962
33475
  this.deps = deps;
32963
33476
  }
32964
33477
  /**
32965
- * Races a promise against a timeout. Returns `null` on timeout OR rejection.
33478
+ * Record — and ANNOUNCE — a stage whose value could not be trusted.
33479
+ *
33480
+ * Both halves matter. The entry is what a client reads to tell a `null` that
33481
+ * means "we could not look" from a `null` that means "there is nothing
33482
+ * there"; the `warn` is what makes the next hang findable at all. The line
33483
+ * carries `tags.deviceId` with the numeric id because the question is always
33484
+ * per-camera ("why is 614 worse than 615?") and a line without the tag
33485
+ * cannot be grouped to answer it.
33486
+ */
33487
+ recordDegraded(sink, deviceId, stage, reason, elapsedMs, extraMeta = {}) {
33488
+ sink.entries.push({
33489
+ stage,
33490
+ reason,
33491
+ elapsedMs
33492
+ });
33493
+ this.deps.logger.warn("camera status stage could not be read", {
33494
+ tags: { deviceId },
33495
+ meta: {
33496
+ stage,
33497
+ reason,
33498
+ elapsedMs,
33499
+ ...extraMeta
33500
+ }
33501
+ });
33502
+ }
33503
+ /**
33504
+ * Races a promise against a timeout. Returns `null` on timeout OR rejection —
33505
+ * but the two are no longer the same event: whichever happened is pushed to
33506
+ * `sink` (and logged) under `stage`, so the `null` that reaches the payload
33507
+ * arrives with its cause attached.
33508
+ *
32966
33509
  * Never throws — individual stage failures become `null` in the aggregate.
32967
33510
  *
32968
- * @param p The stage fetch promise.
32969
- * @param ms Timeout in milliseconds.
33511
+ * @param p The stage fetch promise.
33512
+ * @param ms Timeout in milliseconds.
33513
+ * @param stage Which stage this is, as it appears in `CameraStatus.degraded`.
33514
+ * @param deviceId The camera — every line about it carries the tag.
33515
+ * @param sink Per-call collector for stages that could not be read.
32970
33516
  */
32971
- boundedStage(p, ms) {
33517
+ boundedStage(p, ms, stage, deviceId, sink) {
33518
+ const startedAt = Date.now();
32972
33519
  let timer;
32973
33520
  const timeout = new Promise((resolve) => {
32974
- timer = setTimeout(() => resolve(null), ms);
33521
+ timer = setTimeout(() => resolve(STAGE_TIMED_OUT), ms);
32975
33522
  });
32976
- return Promise.race([p, timeout]).catch(() => null).finally(() => {
33523
+ return Promise.race([p, timeout]).then((value) => {
33524
+ if (value === STAGE_TIMED_OUT) {
33525
+ this.recordDegraded(sink, deviceId, stage, "timeout", Date.now() - startedAt, { timeoutMs: ms });
33526
+ return null;
33527
+ }
33528
+ return value;
33529
+ }).catch((err) => {
33530
+ this.recordDegraded(sink, deviceId, stage, "error", Date.now() - startedAt, { error: err instanceof Error ? err.message : String(err) });
33531
+ return null;
33532
+ }).finally(() => {
32977
33533
  if (timer !== void 0) clearTimeout(timer);
32978
33534
  });
32979
33535
  }
@@ -32999,7 +33555,7 @@ var CameraStatusService = class {
32999
33555
  decoder: false,
33000
33556
  audio: audioPinned
33001
33557
  },
33002
- detectionReason: pipelineAssignment !== null ? pipelineAssignment.reason : this.deps.hasCameraConfig(deviceId) ? `pending:${this.deps.getPendingReason(deviceId) ?? "pending"}` : void 0,
33558
+ detectionReason: pipelineAssignment !== null ? pipelineAssignment.reason : this.deps.hasCameraConfig(deviceId) ? `pending:${this.deps.getPendingReason(deviceId) ?? "pending"}` : this.deps.isSessionCamera(deviceId) ? "armed:on-motion" : void 0,
33003
33559
  audioNodeId,
33004
33560
  audioPinned
33005
33561
  };
@@ -33010,7 +33566,7 @@ var CameraStatusService = class {
33010
33566
  * deviceId). `allSlotsFetch` is shared with the broker stage — fetched
33011
33567
  * ONCE by the caller — so this stage never issues its own round-trip.
33012
33568
  */
33013
- buildSourceStage(api, allSlotsFetch, deviceId) {
33569
+ buildSourceStage(api, allSlotsFetch, deviceId, sink) {
33014
33570
  if (!api || !allSlotsFetch) return Promise.resolve(null);
33015
33571
  return this.boundedStage(allSlotsFetch.then((slots) => {
33016
33572
  return { streams: slots.filter((s) => s.deviceId === deviceId).map((s) => ({
@@ -33021,7 +33577,7 @@ var CameraStatusService = class {
33021
33577
  fps: 0,
33022
33578
  kind: s.profile
33023
33579
  })) };
33024
- }), STAGE_TIMEOUT_MS);
33580
+ }), STAGE_TIMEOUT_MS, "source", deviceId, sink);
33025
33581
  }
33026
33582
  /**
33027
33583
  * Broker stage (per-profile slot stats + client counts). Reuses
@@ -33032,7 +33588,7 @@ var CameraStatusService = class {
33032
33588
  * broker's actual decode-session node into `liveDecoder` (T6) — the first
33033
33589
  * slot that reports one wins.
33034
33590
  */
33035
- buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder) {
33591
+ buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder, sink) {
33036
33592
  if (!api || !allSlotsFetch) return Promise.resolve(null);
33037
33593
  return this.boundedStage(allSlotsFetch.then(async (slots) => {
33038
33594
  const deviceSlots = slots.filter((s) => s.deviceId === deviceId);
@@ -33069,7 +33625,7 @@ var CameraStatusService = class {
33069
33625
  return e.brokerId.split("/")[0] === String(deviceId) && e.enabled;
33070
33626
  }) ?? false
33071
33627
  };
33072
- }), STAGE_TIMEOUT_MS);
33628
+ }), STAGE_TIMEOUT_MS, "broker", deviceId, sink);
33073
33629
  }
33074
33630
  /**
33075
33631
  * Decoder stage. decoder cap methods (listActiveSessions/getInfo/getShmStats)
@@ -33108,7 +33664,7 @@ var CameraStatusService = class {
33108
33664
  };
33109
33665
  }
33110
33666
  /** Detection stage (pipeline-executor + runner metrics). */
33111
- buildDetectionStage(api, detectionNodeId, deviceId) {
33667
+ buildDetectionStage(api, detectionNodeId, deviceId, sink) {
33112
33668
  if (!api || !detectionNodeId) return Promise.resolve(null);
33113
33669
  return this.boundedStage(Promise.all([api.pipelineExecutor.getEngineProvisioning.query({ nodeId: detectionNodeId }).catch(() => null), api.pipelineExecutor.getSelectedEngine.query({ nodeId: detectionNodeId }).catch(() => null)]).then(async ([provisioning, engine]) => {
33114
33670
  const metrics = await api.pipelineRunner.getCameraMetrics.query({
@@ -33138,7 +33694,7 @@ var CameraStatusService = class {
33138
33694
  ...provisioning?.error !== void 0 ? { error: provisioning.error } : {}
33139
33695
  }
33140
33696
  };
33141
- }), STAGE_TIMEOUT_MS);
33697
+ }), STAGE_TIMEOUT_MS, "detection", deviceId, sink);
33142
33698
  }
33143
33699
  /**
33144
33700
  * Audio stage. `nodeId` is orchestrator-local (the cached assignment);
@@ -33157,6 +33713,12 @@ var CameraStatusService = class {
33157
33713
  * analyzer with nothing, and reporting `enabled: true` for the other two
33158
33714
  * would reintroduce the exact hardcoded lie the previous paragraph is about
33159
33715
  * — one row further down the group.
33716
+ *
33717
+ * `enabled` is a boolean and cannot say "unknown", so when the switch read
33718
+ * was cut short or partial this block is OPTIMISTIC by construction. That is
33719
+ * survivable only because `CameraStatus.degraded` names `'switches'` in
33720
+ * exactly those cases — a surface drawing "audio is running" from this field
33721
+ * must consult it first.
33160
33722
  */
33161
33723
  buildAudioStage(audioNodeId, switchedOff) {
33162
33724
  if (audioNodeId === null) return null;
@@ -33171,8 +33733,13 @@ var CameraStatusService = class {
33171
33733
  * `{ deviceId }` and returns `RecordingStatus`, typed `unknown` in the
33172
33734
  * generated router (DEVICE_STATUS_METHOD) — narrowed via
33173
33735
  * {@link isRecordingStatus} instead of an unsafe cast.
33736
+ *
33737
+ * There is deliberately NO `.catch(() => null)` on the query any more: it
33738
+ * swallowed the rejection before `boundedStage` could see it, so a recording
33739
+ * source that was refusing every call produced the same anonymous `null` as
33740
+ * a camera that simply records nothing.
33174
33741
  */
33175
- buildRecordingStage(api, deviceId) {
33742
+ buildRecordingStage(api, deviceId, sink) {
33176
33743
  if (!api) return Promise.resolve(null);
33177
33744
  return this.boundedStage(api.recording.getStatus.query({ deviceId }).then((rawStatus) => {
33178
33745
  if (!isRecordingStatus(rawStatus)) return null;
@@ -33181,7 +33748,40 @@ var CameraStatusService = class {
33181
33748
  active: rawStatus.enabled && rawStatus.activeMode !== "off",
33182
33749
  storageBytes: rawStatus.storageBytes
33183
33750
  };
33184
- }).catch(() => null), STAGE_TIMEOUT_MS);
33751
+ }), STAGE_TIMEOUT_MS, "recording", deviceId, sink);
33752
+ }
33753
+ /**
33754
+ * The operator's function switches (D61/D62), bounded like every other stage.
33755
+ *
33756
+ * Three outcomes, and they must not collapse into one:
33757
+ *
33758
+ * - READ — the group came back whole. The derived id list is a total, and an
33759
+ * empty one really does mean the operator turned nothing off.
33760
+ * - PARTIAL — the group came back with at least one switch marked
33761
+ * `source-unreachable`. `CameraSwitchService` bounds each source
33762
+ * individually, so a slow source drops ITS switch and leaves the rest;
33763
+ * the derived list is then a FLOOR. Marked `'partial'`.
33764
+ * - NOT READ — the whole group timed out or rejected. Marked by
33765
+ * `boundedStage` itself.
33766
+ *
33767
+ * In the last two the returned list is `[]`, and `[]` was previously handed
33768
+ * to the payload as the positive claim "the operator has switched nothing
33769
+ * off". Measured on the live hub 2026-08-08: `getCameraStatus(614)` said
33770
+ * `switchedOff: []` at the very moment `getCameraSwitches(614)` reported
33771
+ * `stream-broker` AND `recording` switched off by the operator — the camera
33772
+ * read as BROKEN because it was switched OFF, which is exactly the
33773
+ * inversion D62 exists to forbid. The list stays empty (a guess would put a
33774
+ * permanent "off" badge on a working camera); what changed is that the
33775
+ * emptiness now travels with the reason it is empty.
33776
+ */
33777
+ buildSwitchStage(deviceId, sink) {
33778
+ const startedAt = Date.now();
33779
+ return this.boundedStage(this.deps.cameraSwitchesFor(deviceId), STAGE_TIMEOUT_MS, "switches", deviceId, sink).then((group) => {
33780
+ if (group === null) return [];
33781
+ const unreadable = group.switches.filter((s) => s.unavailableReason === "source-unreachable");
33782
+ if (unreadable.length > 0) this.recordDegraded(sink, deviceId, "switches", "partial", Date.now() - startedAt, { unreadableSwitches: unreadable.map((s) => s.id) });
33783
+ return switchedOffIds(group.switches);
33784
+ });
33185
33785
  }
33186
33786
  /**
33187
33787
  * Server-composed aggregated status for a single camera.
@@ -33189,20 +33789,23 @@ var CameraStatusService = class {
33189
33789
  * Fans out in parallel (bounded, per-stage graceful degradation) to
33190
33790
  * broker / decoder / motion / detection / audio / recording source caps
33191
33791
  * via `ctx.api`. A stage whose source errors or times out becomes `null`
33192
- * in the returned payload — one slow agent never breaks the whole call.
33792
+ * in the returned payload — one slow agent never breaks the whole call —
33793
+ * and is NAMED in `degraded`, so a consumer can tell that `null` from the
33794
+ * `null` of a camera that legitimately has no such stage.
33193
33795
  */
33194
33796
  async getCameraStatus(deviceId) {
33195
33797
  const api = this.deps.api();
33798
+ const degradations = { entries: [] };
33196
33799
  const { detectionNodeId, sourceNodeId, pinned, detectionReason, audioNodeId, audioPinned } = this.buildAssignmentContext(deviceId);
33197
33800
  const liveDecoder = { nodeId: null };
33198
33801
  const allSlotsFetch = api ? api.streamBroker.listAllProfileSlots.query(void 0, nodePin(sourceNodeId)) : null;
33199
- const sourceFetch = this.buildSourceStage(api, allSlotsFetch, deviceId);
33200
- const brokerFetch = this.buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder);
33802
+ const sourceFetch = this.buildSourceStage(api, allSlotsFetch, deviceId, degradations);
33803
+ const brokerFetch = this.buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder, degradations);
33201
33804
  const decoderFetch = this.buildDecoderStage(detectionNodeId);
33202
33805
  const motionResult = this.buildMotionStage(deviceId);
33203
- const detectionFetch = this.buildDetectionStage(api, detectionNodeId, deviceId);
33204
- const recordingFetch = this.buildRecordingStage(api, deviceId);
33205
- const switchesFetch = this.boundedStage(this.deps.switchedOffIdsFor(deviceId).catch(() => null), STAGE_TIMEOUT_MS).then((ids) => ids ?? []);
33806
+ const detectionFetch = this.buildDetectionStage(api, detectionNodeId, deviceId, degradations);
33807
+ const recordingFetch = this.buildRecordingStage(api, deviceId, degradations);
33808
+ const switchesFetch = this.buildSwitchStage(deviceId, degradations);
33206
33809
  const [sourceResult, brokerResult, decoderResult, detectionResult, recordingResult, switchedOff] = await Promise.all([
33207
33810
  sourceFetch,
33208
33811
  brokerFetch,
@@ -33235,7 +33838,8 @@ var CameraStatusService = class {
33235
33838
  detectionResult,
33236
33839
  audioResult,
33237
33840
  recordingResult,
33238
- switchedOff
33841
+ switchedOff,
33842
+ degraded: [...degradations.entries]
33239
33843
  });
33240
33844
  }
33241
33845
  /**
@@ -33303,6 +33907,25 @@ function readPrivacyMaskFacts(v) {
33303
33907
  enabled: typeof enabled === "boolean" ? enabled : null
33304
33908
  };
33305
33909
  }
33910
+ /**
33911
+ * How long ONE source of the gather may take before it is treated as
33912
+ * unreachable.
33913
+ *
33914
+ * The gather runs in two sequential stages, so an operator's worst case is
33915
+ * twice this — 2.4s — which has to stay under the 3s `STAGE_TIMEOUT_MS` that
33916
+ * `CameraStatusService` allows the whole switch read. That ordering is the
33917
+ * whole point of the number: if the gather can outlast its caller's budget,
33918
+ * the caller degrades to "nothing switched off" and a camera the operator
33919
+ * DISABLED renders as broken.
33920
+ *
33921
+ * Measured on the live hub, 2026-08-08: seven of the eight sources answered in
33922
+ * 5–15ms on every camera. The eighth, `privacyMask.getStatus`, probes the
33923
+ * camera itself and took 3.1–3.6s on device 614 on every single call (18s
33924
+ * under load) — so a bound generous enough to make the fast sources safe still
33925
+ * has to be willing to drop the slow one, because waiting for it costs the
33926
+ * other six switches.
33927
+ */
33928
+ var SOURCE_TIMEOUT_MS = 1200;
33306
33929
  var CameraSwitchService = class {
33307
33930
  deps;
33308
33931
  constructor(deps) {
@@ -33439,11 +34062,16 @@ var CameraSwitchService = class {
33439
34062
  wrapperAddonIdByCap: /* @__PURE__ */ new Map(),
33440
34063
  recordingConfig: null
33441
34064
  };
33442
- const devicePromise = api.deviceManager.getDevice.query({ deviceId }).then((d) => isDeviceShape(d) ? d : null).catch((err) => {
34065
+ const devicePromise = this.bounded(deviceId, "deviceManager.getDevice", api.deviceManager.getDevice.query({ deviceId }).then((d) => isDeviceShape(d) ? d : null).catch((err) => {
33443
34066
  this.warn(deviceId, "getDevice", err);
33444
34067
  return null;
33445
- });
33446
- const bindingsPromise = api.deviceManager.getBindings.query({ deviceId }).then((b) => {
34068
+ }), null);
34069
+ const unknownBindings = {
34070
+ activeWrapperCapNames: null,
34071
+ providerAddonIdByCap: /* @__PURE__ */ new Map(),
34072
+ allCapNames: null
34073
+ };
34074
+ const bindingsPromise = this.bounded(deviceId, "deviceManager.getBindings", api.deviceManager.getBindings.query({ deviceId }).then((b) => {
33447
34075
  const active = [];
33448
34076
  const all = [];
33449
34077
  const providers = /* @__PURE__ */ new Map();
@@ -33460,24 +34088,20 @@ var CameraSwitchService = class {
33460
34088
  };
33461
34089
  }).catch((err) => {
33462
34090
  this.warn(deviceId, "getBindings", err);
33463
- return {
33464
- activeWrapperCapNames: null,
33465
- providerAddonIdByCap: /* @__PURE__ */ new Map(),
33466
- allCapNames: null
33467
- };
33468
- });
33469
- const recordingPromise = api.recording.getDeviceConfig.query({ deviceId }).then((c) => isRecordingConfig(c) ? c : null).catch((err) => {
34091
+ return unknownBindings;
34092
+ }), unknownBindings);
34093
+ const recordingPromise = this.bounded(deviceId, "recording.getDeviceConfig", api.recording.getDeviceConfig.query({ deviceId }).then((c) => isRecordingConfig(c) ? c : null).catch((err) => {
33470
34094
  this.warn(deviceId, "recording.getDeviceConfig", err);
33471
34095
  return null;
33472
- });
33473
- const mutesPromise = api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
34096
+ }), null);
34097
+ const mutesPromise = this.bounded(deviceId, "notificationRules.listDeviceMutes", api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
33474
34098
  this.warn(deviceId, "notificationRules.listDeviceMutes", err);
33475
34099
  return null;
33476
- });
33477
- const brokerAudioPromise = api.streamBroker.getDeviceAudioMute.query({ deviceId }, nodePin(this.deps.assignSource(deviceId))).then((r) => ({ muted: r.muted })).catch((err) => {
34100
+ }), null);
34101
+ const brokerAudioPromise = this.bounded(deviceId, "streamBroker.getDeviceAudioMute", api.streamBroker.getDeviceAudioMute.query({ deviceId }, nodePin(this.deps.assignSource(deviceId))).then((r) => ({ muted: r.muted })).catch((err) => {
33478
34102
  this.warn(deviceId, "streamBroker.getDeviceAudioMute", err);
33479
34103
  return null;
33480
- });
34104
+ }), null);
33481
34105
  const [device, bindings, recordingConfig, mutedDeviceIds, brokerAudio] = await Promise.all([
33482
34106
  devicePromise,
33483
34107
  bindingsPromise,
@@ -33485,10 +34109,10 @@ var CameraSwitchService = class {
33485
34109
  mutesPromise,
33486
34110
  brokerAudioPromise
33487
34111
  ]);
33488
- const [bindable, privacy] = await Promise.all([device === null ? Promise.resolve(null) : api.deviceManager.listBindableCapsForDeviceType.query({ deviceType: device.type }).catch((err) => {
34112
+ const [bindable, privacy] = await Promise.all([device === null ? Promise.resolve(null) : this.bounded(deviceId, "deviceManager.listBindableCapsForDeviceType", api.deviceManager.listBindableCapsForDeviceType.query({ deviceType: device.type }).catch((err) => {
33489
34113
  this.warn(deviceId, "listBindableCapsForDeviceType", err);
33490
34114
  return null;
33491
- }), this.gatherPrivacy(api, deviceId, bindings.allCapNames)]);
34115
+ }), null), this.gatherPrivacy(api, deviceId, bindings.allCapNames)]);
33492
34116
  const wrapperAddonIdByCap = /* @__PURE__ */ new Map();
33493
34117
  for (const entry of bindable ?? []) {
33494
34118
  const first = entry.wrappers[0];
@@ -33544,13 +34168,13 @@ var CameraSwitchService = class {
33544
34168
  enabled: null
33545
34169
  }
33546
34170
  };
33547
- const [options, status] = await Promise.all([api.privacyMask.getOptions.query({ deviceId }).catch((err) => {
34171
+ const [options, status] = await Promise.all([this.bounded(deviceId, "privacyMask.getOptions", api.privacyMask.getOptions.query({ deviceId }).catch((err) => {
33548
34172
  this.warn(deviceId, "privacyMask.getOptions", err);
33549
34173
  return null;
33550
- }), api.privacyMask.getStatus.query({ deviceId }).catch((err) => {
34174
+ }), null), this.bounded(deviceId, "privacyMask.getStatus", api.privacyMask.getStatus.query({ deviceId }).catch((err) => {
33551
34175
  this.warn(deviceId, "privacyMask.getStatus", err);
33552
34176
  return null;
33553
- })]);
34177
+ }), null)]);
33554
34178
  if (options === null) return {
33555
34179
  deviceAudio: null,
33556
34180
  privacyMask: null
@@ -33574,6 +34198,41 @@ var CameraSwitchService = class {
33574
34198
  }
33575
34199
  };
33576
34200
  }
34201
+ /**
34202
+ * Bound ONE source. A source that has not answered within
34203
+ * {@link SOURCE_TIMEOUT_MS} yields `fallback` — the same value its `.catch`
34204
+ * yields — so a slow source and a broken one produce the identical group:
34205
+ * that switch is `available: false`, and every other switch is untouched.
34206
+ *
34207
+ * The bound is per SOURCE rather than around the whole gather on purpose.
34208
+ * One deadline over the fan-out would let the slowest camera probe consume
34209
+ * the budget of the seven sources that already answered, which is precisely
34210
+ * how `getCameraStatus` came to report `switchedOff: []` for a camera whose
34211
+ * `stream-broker` and `recording` switches the operator had turned off.
34212
+ *
34213
+ * @param deviceId The camera — every line about it carries the tag.
34214
+ * @param source The cap method, as named in the warn line.
34215
+ * @param p The source's promise. Already `.catch`-ed; never rejects.
34216
+ * @param fallback What the caller reads when the source did not answer.
34217
+ */
34218
+ bounded(deviceId, source, p, fallback) {
34219
+ let timer;
34220
+ const timeout = new Promise((resolve) => {
34221
+ timer = setTimeout(() => {
34222
+ this.deps.logger.warn("camera switch source TIMED OUT — its switch is not offered", {
34223
+ tags: { deviceId },
34224
+ meta: {
34225
+ source,
34226
+ timeoutMs: SOURCE_TIMEOUT_MS
34227
+ }
34228
+ });
34229
+ resolve(fallback);
34230
+ }, SOURCE_TIMEOUT_MS);
34231
+ });
34232
+ return Promise.race([p, timeout]).finally(() => {
34233
+ if (timer !== void 0) clearTimeout(timer);
34234
+ });
34235
+ }
33577
34236
  warn(deviceId, source, err) {
33578
34237
  this.deps.logger.warn("camera switch source unreachable — its switch is not offered", {
33579
34238
  tags: { deviceId },
@@ -39290,6 +39949,7 @@ async function buildOrchestratorControllers(deps) {
39290
39949
  });
39291
39950
  const cameraStatusService = new CameraStatusService({
39292
39951
  api: () => deps.ctx().api,
39952
+ logger: deps.ctx().logger,
39293
39953
  getAssignment: (deviceId) => ledger.getAssignment(deviceId),
39294
39954
  getAudioAssignment: (deviceId) => audio.getAssignment(deviceId),
39295
39955
  getCameraConfig: (deviceId) => ledger.getConfig(deviceId),
@@ -39297,7 +39957,8 @@ async function buildOrchestratorControllers(deps) {
39297
39957
  getPendingReason: (deviceId) => ledger.getPendingReason(deviceId),
39298
39958
  assignSource: (deviceId) => topology.assignSource(deviceId),
39299
39959
  listAssignedDeviceIds: () => ledger.listAssignedDeviceIds(),
39300
- switchedOffIdsFor: (deviceId) => cameraSwitchService.switchedOffIdsFor(deviceId)
39960
+ isSessionCamera: (deviceId) => deps.isSessionCamera(deviceId),
39961
+ cameraSwitchesFor: (deviceId) => cameraSwitchService.getCameraSwitches(deviceId)
39301
39962
  });
39302
39963
  const reconcile = new ReconcileController({
39303
39964
  api: () => deps.ctx().api ?? null,