@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.js CHANGED
@@ -7679,6 +7679,104 @@ object({
7679
7679
  })
7680
7680
  });
7681
7681
  /**
7682
+ * Adoption job — the background form of `device-adoption.adopt`.
7683
+ *
7684
+ * ## Why this exists
7685
+ *
7686
+ * `adopt({childNativeIds: [...]})` materialises one CamStack device per
7687
+ * candidate PLUS every accessory child, and the whole array shares ONE UDS
7688
+ * request deadline (60s). Measured on the live hub against Home Assistant:
7689
+ * each device the kernel creates costs ~450 ms — `devices.create` pre-seeds
7690
+ * meta with up to eleven SEQUENTIAL round trips (`setName`, `setType`,
7691
+ * `setRole`, … `persistConfig`) before the class is constructed — and an
7692
+ * accessory child costs the same as its parent. So the real unit of work is
7693
+ * the CHILD, not the candidate:
7694
+ *
7695
+ * - 25 candidates averaging 6 children → ~150 devices → **>60s, times out**
7696
+ * - ONE candidate with 217 children → ~217 devices → **>60s, times out**
7697
+ *
7698
+ * That second line is why this is a job and not a smaller batch. No chunking,
7699
+ * no bounded concurrency over candidates and no per-call tuning can fix a
7700
+ * shape where **N=1 already exceeds the deadline** — the count that blows the
7701
+ * budget is the source system's accessory fan-out, which the operator does not
7702
+ * choose and cannot see. A design that only works below some N is the same bug
7703
+ * deferred.
7704
+ *
7705
+ * ## What the timeout did NOT do
7706
+ *
7707
+ * It did not stop the work. The UDS deadline ends the CALLER's wait; the
7708
+ * provider's loop runs to completion. Measured: a 25-candidate adopt that
7709
+ * "failed" at 60s had adopted 17 by 87s and all 25 by ~130s. The operator saw
7710
+ * an error and had no way to learn that. Every field below exists so that
7711
+ * question has an answer.
7712
+ *
7713
+ * ## Idempotency
7714
+ *
7715
+ * Jobs are in-RAM; a restart forgets them. That is safe here because adoption
7716
+ * is keyed by a stable id (`ha:<broker>:dev:<nativeId>` and equivalents), so
7717
+ * re-running a job re-adopts nothing: an already-adopted candidate is SKIPPED
7718
+ * by the engine before any provider call and lands in `alreadyAdopted`. It is
7719
+ * never a duplicate device, and never an error the operator has to interpret.
7720
+ */
7721
+ var AdoptionJobStateSchema = _enum([
7722
+ "running",
7723
+ "done",
7724
+ "failed",
7725
+ "cancelled"
7726
+ ]);
7727
+ /**
7728
+ * Per-candidate result. Every candidate the job was asked to adopt ends in
7729
+ * exactly one of these buckets — there is no silent drop, and the operator can
7730
+ * always answer "which of my 25 landed?".
7731
+ *
7732
+ * - `adopted` — created now by this job.
7733
+ * - `already-adopted` — a device for this candidate existed before the job
7734
+ * reached it (a re-run, or a retry after a timeout). Not an error.
7735
+ * - `failed` — the provider threw; `error` carries the message.
7736
+ * - `cancelled` — the operator cancelled before this candidate was reached.
7737
+ */
7738
+ var AdoptionOutcomeSchema = _enum([
7739
+ "adopted",
7740
+ "already-adopted",
7741
+ "failed",
7742
+ "cancelled"
7743
+ ]);
7744
+ var AdoptionCandidateResultSchema = object({
7745
+ childNativeId: string(),
7746
+ outcome: AdoptionOutcomeSchema,
7747
+ /** The materialised parent device id — null for `failed` / `cancelled`. */
7748
+ parentDeviceId: number().int().nonnegative().nullable(),
7749
+ /** Accessory children created for this candidate. */
7750
+ accessoryCount: number().int().nonnegative(),
7751
+ /** Failure message; null unless `outcome === 'failed'`. */
7752
+ error: string().nullable()
7753
+ });
7754
+ var AdoptionJobSchema = object({
7755
+ jobId: string(),
7756
+ /** The integration provider this job adopts through (the `addonId` pin). */
7757
+ addonId: string(),
7758
+ integrationId: string(),
7759
+ state: AdoptionJobStateSchema,
7760
+ /** Candidates the job was asked to adopt. Known up front, so never null. */
7761
+ total: number().int().nonnegative(),
7762
+ /** Candidates that have reached a terminal bucket. */
7763
+ processed: number().int().nonnegative(),
7764
+ adopted: number().int().nonnegative(),
7765
+ alreadyAdopted: number().int().nonnegative(),
7766
+ failed: number().int().nonnegative(),
7767
+ /** Accessory child devices created across every candidate — the real unit
7768
+ * of work, surfaced so a slow job is legible rather than mysterious. */
7769
+ accessoriesCreated: number().int().nonnegative(),
7770
+ /** The candidate currently being adopted; null when idle or finished. */
7771
+ currentChildNativeId: string().nullable(),
7772
+ /** One entry per candidate, in the order they were processed. */
7773
+ results: array(AdoptionCandidateResultSchema).readonly(),
7774
+ startedAt: number(),
7775
+ finishedAt: number().nullable(),
7776
+ /** Set only when the job itself broke (not a per-candidate failure). */
7777
+ error: string().nullable()
7778
+ });
7779
+ /**
7682
7780
  * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7683
7781
  * pipeline functions an operator thinks in terms of.
7684
7782
  *
@@ -8761,441 +8859,1215 @@ var ConvertResultSchema = object({
8761
8859
  })).readonly()
8762
8860
  });
8763
8861
  /**
8764
- * `addon-pages` — system-scoped singleton aggregator cap. Public-facing
8765
- * surface that admin-ui consumes through `useAddonPagesListPages()`.
8862
+ * Error types for the safe expression engine. Two distinct classes so callers
8863
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
8864
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
8865
+ */
8866
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
8867
+ * the failure is anchored to a character (author-facing inline feedback). */
8868
+ var ExpressionParseError = class extends Error {
8869
+ position;
8870
+ constructor(message, position) {
8871
+ super(message);
8872
+ this.name = "ExpressionParseError";
8873
+ this.position = position;
8874
+ }
8875
+ };
8876
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
8877
+ * result, unknown builtin, step-budget exceeded). */
8878
+ var ExpressionEvalError = class extends Error {
8879
+ constructor(message) {
8880
+ super(message);
8881
+ this.name = "ExpressionEvalError";
8882
+ }
8883
+ };
8884
+ /**
8885
+ * Frozen, null-prototype builtin function table for the expression engine
8886
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
8887
+ * parser rejects any callee not in it, and the evaluator gates each call on an
8888
+ * own-property check against it.
8766
8889
  *
8767
- * The provider iterates every `addon-pages-source` (collection) provider
8768
- * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
8769
- * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
8770
- * filesystem `mtime` cache-buster lets the browser pick up addon
8771
- * rebuilds without manual reload.
8890
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
8891
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
8892
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
8893
+ * (there is no `Object.prototype` in the chain), so those names are not
8894
+ * callable — they are simply "unknown function" at parse time.
8772
8895
  *
8773
- * The hub-local builtin `addon-pages-aggregator` (see
8774
- * `@camstack/system/builtins/addon-pages-aggregator`) registers the
8775
- * provider. Splitting the public aggregator from the raw collection
8776
- * keeps both ends in codegen — there's no hand-written
8777
- * `addon-pages.router.ts` wrapper anymore.
8896
+ * Every numeric argument is validated as a finite number and every numeric
8897
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
8898
+ * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
8899
+ * closed rather than emitting a garbage value.
8778
8900
  */
8779
- var AddonPageDeclarationSchema$1 = object({
8780
- id: string(),
8781
- label: string(),
8782
- icon: string(),
8783
- path: string(),
8784
- remoteName: string(),
8785
- bundle: string(),
8786
- section: string().optional(),
8787
- sectionLabel: string().optional()
8788
- });
8789
- var AddonPageInfoSchema = object({
8790
- addonId: string(),
8791
- page: AddonPageDeclarationSchema$1,
8792
- bundleUrl: string()
8793
- });
8794
- method(_void(), array(AddonPageInfoSchema).readonly());
8901
+ function asFiniteNumber(value, name, index) {
8902
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
8903
+ return value;
8904
+ }
8905
+ function asString$1(value, name, index) {
8906
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
8907
+ return value;
8908
+ }
8909
+ function finiteResult(value, name) {
8910
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
8911
+ return value;
8912
+ }
8913
+ function allFiniteNumbers(args, name) {
8914
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
8915
+ }
8916
+ var INF = Number.POSITIVE_INFINITY;
8917
+ var table = {
8918
+ min: {
8919
+ minArgs: 1,
8920
+ maxArgs: INF,
8921
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
8922
+ },
8923
+ max: {
8924
+ minArgs: 1,
8925
+ maxArgs: INF,
8926
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
8927
+ },
8928
+ abs: {
8929
+ minArgs: 1,
8930
+ maxArgs: 1,
8931
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
8932
+ },
8933
+ floor: {
8934
+ minArgs: 1,
8935
+ maxArgs: 1,
8936
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
8937
+ },
8938
+ ceil: {
8939
+ minArgs: 1,
8940
+ maxArgs: 1,
8941
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
8942
+ },
8943
+ sqrt: {
8944
+ minArgs: 1,
8945
+ maxArgs: 1,
8946
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
8947
+ },
8948
+ round: {
8949
+ minArgs: 1,
8950
+ maxArgs: 2,
8951
+ apply: (args) => {
8952
+ const x = asFiniteNumber(args[0], "round", 0);
8953
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
8954
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
8955
+ const factor = 10 ** digits;
8956
+ return finiteResult(Math.round(x * factor) / factor, "round");
8957
+ }
8958
+ },
8959
+ pow: {
8960
+ minArgs: 2,
8961
+ maxArgs: 2,
8962
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
8963
+ },
8964
+ clamp: {
8965
+ minArgs: 3,
8966
+ maxArgs: 3,
8967
+ apply: (args) => {
8968
+ const x = asFiniteNumber(args[0], "clamp", 0);
8969
+ const lo = asFiniteNumber(args[1], "clamp", 1);
8970
+ const hi = asFiniteNumber(args[2], "clamp", 2);
8971
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
8972
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
8973
+ }
8974
+ },
8975
+ avg: {
8976
+ minArgs: 1,
8977
+ maxArgs: INF,
8978
+ apply: (args) => {
8979
+ const nums = allFiniteNumbers(args, "avg");
8980
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
8981
+ }
8982
+ },
8983
+ sum: {
8984
+ minArgs: 1,
8985
+ maxArgs: INF,
8986
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
8987
+ },
8988
+ coalesce: {
8989
+ minArgs: 1,
8990
+ maxArgs: INF,
8991
+ apply: (args) => {
8992
+ for (const a of args) if (a !== null) return a;
8993
+ return null;
8994
+ }
8995
+ },
8996
+ age: {
8997
+ minArgs: 2,
8998
+ maxArgs: 2,
8999
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
9000
+ },
9001
+ convert: {
9002
+ minArgs: 3,
9003
+ maxArgs: 3,
9004
+ apply: (args, hooks) => {
9005
+ const x = asFiniteNumber(args[0], "convert", 0);
9006
+ const from = asString$1(args[1], "convert", 1).trim();
9007
+ const to = asString$1(args[2], "convert", 2).trim();
9008
+ if (hooks.convert) {
9009
+ const out = hooks.convert(x, from, to);
9010
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
9011
+ return finiteResult(out, "convert");
9012
+ }
9013
+ if (from === to) return x;
9014
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
9015
+ }
9016
+ }
9017
+ };
9018
+ Object.freeze(Object.assign(Object.create(null), table));
9019
+ /** The set of valid builtin names — used by the parser to reject unknown
9020
+ * callees at parse time (immediate author feedback). */
9021
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
8795
9022
  /**
8796
- * `addon-pages-source` — collection cap exposing per-provider raw page
8797
- * declarations. Every addon that contributes a UI page registers a
8798
- * provider here. The hub-side singleton aggregator (`addon-pages` cap,
8799
- * see `addon-pages.cap.ts`) walks this collection, stamps versioned
8800
- * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
8801
- * that admin-ui consumes.
9023
+ * Resource-bound constants for the safe expression engine.
8802
9024
  *
8803
- * The split exists because the public listing has a different output
8804
- * shape than the per-provider raw declarations, and we want both ends
8805
- * to flow through codegen instead of relying on a hand-written wrapper.
9025
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
9026
+ * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
9027
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
9028
+ * work a single author-supplied expression can request, so a hostile or
9029
+ * accidental pathological string can never spend unbounded CPU/memory.
8806
9030
  */
8807
- var AddonPageDeclarationSchema = object({
8808
- id: string(),
8809
- label: string(),
8810
- icon: string(),
8811
- path: string(),
8812
- /**
8813
- * Module Federation remote name — must match the `name` field on the
8814
- * page addon's `federation()` plugin config. Used by admin-ui's
8815
- * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
8816
- * Conventionally `addon_<id>_page` (snake_case; MF names cannot
8817
- * contain hyphens).
8818
- */
8819
- remoteName: string(),
8820
- /**
8821
- * Bundle filename inside the addon's `dist/` dir served at
8822
- * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
8823
- * is always `'remoteEntry.js'`; the value is kept on the metadata so
8824
- * the static-file route can compute an mtime-based cache-buster URL
8825
- * without a separate filesystem stat.
8826
- */
8827
- bundle: string(),
8828
- /**
8829
- * Sidebar section this page docks into. Well-known ids: `'detection'`,
8830
- * `'cluster'`, `'administration'` — the page renders inside that group.
8831
- * Any OTHER string creates (or joins) a custom section rendered after
8832
- * the built-in groups; its label comes from `sectionLabel` (first
8833
- * declaration wins), falling back to the id. Absent → the legacy
8834
- * "Addon Pages" group.
8835
- */
8836
- section: string().optional(),
8837
- /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
8838
- sectionLabel: string().optional()
8839
- });
8840
- method(_void(), array(AddonPageDeclarationSchema).readonly());
8841
- var AddonHttpRouteSchema = object({
8842
- method: _enum([
8843
- "GET",
8844
- "POST",
8845
- "PUT",
8846
- "DELETE",
8847
- "PATCH"
8848
- ]),
8849
- path: string(),
8850
- access: _enum([
8851
- "public",
8852
- "authenticated",
8853
- "admin"
8854
- ]).optional(),
8855
- description: string().optional()
8856
- });
8857
- /**
8858
- * Cross-process route invocation envelope. The hub captures the
8859
- * request as plain data, ships it to the worker via Moleculer, and
8860
- * the worker runs the local handler against a capturing reply. The
8861
- * envelope returned describes what the handler intended (status,
8862
- * headers, body, or a redirect) so the hub can translate it back to
8863
- * the Fastify reply that's actually wired to the socket.
8864
- */
8865
- var InvokeRequestSchema = object({
8866
- method: string(),
8867
- path: string(),
8868
- params: record(string(), string()),
8869
- query: record(string(), string()),
8870
- body: unknown(),
8871
- headers: record(string(), string()),
8872
- user: object({
8873
- id: string(),
8874
- username: string(),
8875
- isAdmin: boolean()
8876
- }).optional(),
8877
- scopedToken: unknown().optional()
8878
- });
8879
- var InvokeReplyEnvelopeSchema = object({
8880
- status: number().int(),
8881
- headers: record(string(), string()),
8882
- /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
8883
- * sending `body`. Status defaults to 302 when this is set unless
8884
- * the handler called `reply.code(...)` explicitly. */
8885
- redirectUrl: string().nullable(),
8886
- /** JSON-serializable body. `undefined` is treated as "no body". */
8887
- body: unknown().optional(),
8888
- /** Set when the handler called `reply.type(mime)`. */
8889
- contentType: string().optional()
8890
- });
8891
- method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
8892
- var ConfigTabDeclarationSchema = object({
8893
- id: string(),
8894
- label: string(),
8895
- icon: string(),
8896
- order: number().optional()
8897
- });
8898
- var ConfigSectionWithValuesSchema = object({
8899
- id: string(),
8900
- title: string(),
8901
- description: string().optional(),
8902
- style: _enum(["card", "accordion"]).optional(),
8903
- defaultCollapsed: boolean().optional(),
8904
- columns: union([
8905
- literal(1),
8906
- literal(2),
8907
- literal(3),
8908
- literal(4)
8909
- ]).optional(),
8910
- tab: string().optional(),
8911
- location: _enum(["settings", "top-tab"]).optional(),
8912
- order: number().optional(),
8913
- fields: array(any())
8914
- });
8915
- var SettingsSchemaWithValuesSchema = object({
8916
- tabs: array(ConfigTabDeclarationSchema).optional(),
8917
- sections: array(ConfigSectionWithValuesSchema)
8918
- });
8919
- /** Patch object — keys are field names, values are the new field values. */
8920
- var SettingsPatchSchema = record(string(), unknown());
8921
- /** Standard success response for update operations. */
8922
- var SettingsUpdateResultSchema = object({ success: literal(true) });
8923
- method(object({
8924
- addonId: string(),
8925
- nodeId: string().optional(),
8926
- overlay: record(string(), unknown()).optional(),
8927
- cap: string().optional()
8928
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8929
- addonId: string(),
8930
- nodeId: string().optional(),
8931
- patch: SettingsPatchSchema
8932
- }), SettingsUpdateResultSchema, {
8933
- kind: "mutation",
8934
- auth: "admin"
8935
- }), method(object({
8936
- addonId: string(),
8937
- deviceId: number(),
8938
- nodeId: string().optional()
8939
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8940
- addonId: string(),
8941
- deviceId: number(),
8942
- nodeId: string().optional(),
8943
- patch: SettingsPatchSchema
8944
- }), SettingsUpdateResultSchema, {
8945
- kind: "mutation",
8946
- auth: "admin"
8947
- });
8948
- /**
8949
- * `addon-widgets-source` — collection cap exposing per-addon raw widget
8950
- * declarations. Mirrors the addon-pages split: every addon shipping
8951
- * widgets registers a provider on this collection cap; the hub-local
8952
- * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
8953
- * collection, stamps versioned `bundleUrl`s onto each declaration, and
8954
- * exposes the public listing surface that admin-ui consumes.
8955
- *
8956
- * The split exists because the public listing has a different output
8957
- * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
8958
- * per-provider raw declarations. Both ends flow through codegen.
8959
- *
8960
- * Unified UI-contribution model (Task 10): a widget descriptor IS a
8961
- * `UiContribution` with `kind:'remote'`. The host renders it through the
8962
- * same `ContributionRenderer` / Module-Federation path as every other
8963
- * contributed UI surface — no bespoke widget-rendering path. The widget-
8964
- * only metadata (sizing hints, `requires`) lives as extra fields on the
8965
- * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
8966
- * `kind` / `remote`) carries identity + placement + the MF remote.
8967
- */
8968
- /** Where the widget makes sense to render — maps to a contribution `tab`. */
8969
- var WidgetHostEnum = _enum([
8970
- "device-tab",
8971
- "dashboard",
8972
- "integration-detail"
8973
- ]);
8974
- var WidgetSizeEnum = _enum([
8975
- "xs",
8976
- "sm",
8977
- "md",
8978
- "lg",
8979
- "xl"
9031
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
9032
+ * rejected without allocation. */
9033
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
9034
+ /** A legal binding / identifier name. */
9035
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
9036
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
9037
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
9038
+ var RESERVED_BINDING_NAMES = new Set([
9039
+ "now",
9040
+ "true",
9041
+ "false",
9042
+ "null"
8980
9043
  ]);
8981
9044
  /**
8982
- * MF remote descriptor — mirrors `UiContributionRemote` from
8983
- * `capability-definition.ts`. Widget remotes expose a single
8984
- * `'./widgets'` module whose default export is a
8985
- * `Record<componentKey, Component>` map; `componentKey` (the widget
8986
- * `stableId`) picks the entry the host mounts.
8987
- */
8988
- var WidgetRemoteSchema = object({
8989
- remoteName: string(),
8990
- exposedModule: string(),
8991
- componentKey: string().optional()
8992
- });
8993
- /**
8994
- * One widget declaration — a `UiContribution` (`kind:'remote'`) plus
8995
- * widget-only metadata. The `UiContribution` core fields:
8996
- *
8997
- * - `tab` — where the widget hosts. A widget that runs on the
8998
- * dashboard declares `tab:'dashboard'`; a device-tab
8999
- * widget declares the target device-detail tab id.
9000
- * - `subTab` — optional sub-tab within `tab`.
9001
- * - `label` — operator-facing label.
9002
- * - `order` — ordering within `(tab, subTab)`.
9003
- * - `kind` — always `'remote'` for widgets.
9004
- * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
9005
- *
9006
- * Widget-only fields retained alongside the contribution core:
9007
- *
9008
- * - `stableId` — stable identity within the addon (the MF
9009
- * `componentKey`; kept top-level so consumers have
9010
- * a stable key without reaching into `remote`).
9011
- * - `description` / `icon` — picker metadata.
9012
- * - `bundle` — entry filename inside the addon `dist/` dir; the
9013
- * aggregator stamps a versioned `bundleUrl` from it.
9014
- * - `hosts` — every host the widget supports (a widget can run
9015
- * both on the dashboard and a device tab). `tab`
9016
- * is the PRIMARY host; `hosts` is the full set the
9017
- * picker filters on.
9018
- * - `requires` — host-context requirements validated at mount.
9019
- * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
9020
- * — dashboard placement hints.
9045
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
9046
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
9047
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
9048
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
9049
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
9050
+ * is a parse error with a source position, so member access / assignment /
9051
+ * template literals are lexically impossible.
9021
9052
  */
9022
- var WidgetMetadataSchema = object({
9023
- /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
9024
- tab: string(),
9025
- /** Optional sub-tab within `tab`. */
9026
- subTab: string().optional(),
9027
- /** Operator-facing label. */
9028
- label: string(),
9029
- /** Ordering within `(tab, subTab)`, ascending. */
9030
- order: number().optional(),
9031
- /** Always `'remote'` — a widget is a Module Federation remote. */
9032
- kind: literal("remote"),
9033
- /** MF remote descriptor. */
9034
- remote: WidgetRemoteSchema,
9035
- /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
9036
- stableId: string(),
9037
- description: string().optional(),
9038
- icon: string().optional(),
9039
- /**
9040
- * Bundle filename inside the addon's `dist/` dir served at
9041
- * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
9042
- * this is always `'remoteEntry.js'` — the value is kept on the
9043
- * metadata so the static-file route can compute an mtime-based
9044
- * cache-buster URL without a separate filesystem stat.
9045
- */
9046
- bundle: string(),
9047
- /** Every host the widget supports. The picker filters on this set. */
9048
- hosts: array(WidgetHostEnum).readonly(),
9049
- /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
9050
- requires: object({
9051
- deviceContext: boolean().default(false),
9052
- integrationContext: boolean().default(false)
9053
- }),
9054
- /**
9055
- * Loadable BEFORE authentication. The normal widget registry listing
9056
- * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
9057
- * (the login page) cannot discover a widget through it. A widget that
9058
- * declares `preAuth: true` marks itself as safe to mount on a pre-auth
9059
- * screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
9060
- * login-method contribution channel (see `login-method.cap.ts`) rather
9061
- * than the authenticated registry, and its bundle is served by the
9062
- * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
9063
- */
9064
- preAuth: boolean().optional().default(false),
9065
- /** Dashboard placement HINTS (operator can override per instance). */
9066
- defaultSize: WidgetSizeEnum.default("md"),
9067
- allowedSizes: array(WidgetSizeEnum).readonly().default([
9068
- "sm",
9069
- "md",
9070
- "lg"
9071
- ]),
9072
- defaultColumns: number().int().min(1).max(12).default(6),
9073
- defaultRows: number().int().min(1).max(12).default(1)
9074
- });
9075
- var addonWidgetsSourceCapability = {
9076
- name: "addon-widgets-source",
9077
- scope: "system",
9078
- mode: "collection",
9079
- internal: true,
9080
- methods: { listWidgets: method(_void(), array(WidgetMetadataSchema).readonly()) }
9081
- };
9053
+ var KEYWORDS = new Set([
9054
+ "true",
9055
+ "false",
9056
+ "null"
9057
+ ]);
9058
+ function isDigit(ch) {
9059
+ return ch >= "0" && ch <= "9";
9060
+ }
9061
+ function isIdentStart(ch) {
9062
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
9063
+ }
9064
+ function isIdentPart(ch) {
9065
+ return isIdentStart(ch) || isDigit(ch);
9066
+ }
9067
+ function isWhitespace(ch) {
9068
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
9069
+ }
9070
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
9071
+ * Throws `ExpressionParseError` on any illegal character or unterminated
9072
+ * string. */
9073
+ function tokenize(source) {
9074
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
9075
+ const tokens = [];
9076
+ let i = 0;
9077
+ const n = source.length;
9078
+ while (i < n) {
9079
+ const ch = source[i];
9080
+ if (isWhitespace(ch)) {
9081
+ i += 1;
9082
+ continue;
9083
+ }
9084
+ if (isDigit(ch)) {
9085
+ const start = i;
9086
+ while (i < n && isDigit(source[i])) i += 1;
9087
+ if (i < n && source[i] === ".") {
9088
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
9089
+ i += 1;
9090
+ while (i < n && isDigit(source[i])) i += 1;
9091
+ }
9092
+ const text = source.slice(start, i);
9093
+ const value = Number(text);
9094
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
9095
+ tokens.push({
9096
+ type: "number",
9097
+ value,
9098
+ pos: start
9099
+ });
9100
+ continue;
9101
+ }
9102
+ if (ch === "'" || ch === "\"") {
9103
+ const quote = ch;
9104
+ const start = i;
9105
+ i += 1;
9106
+ let out = "";
9107
+ let closed = false;
9108
+ while (i < n) {
9109
+ const c = source[i];
9110
+ if (c === "\\") {
9111
+ const next = i + 1 < n ? source[i + 1] : "";
9112
+ if (next === "\\" || next === "'" || next === "\"") {
9113
+ out += next;
9114
+ i += 2;
9115
+ continue;
9116
+ }
9117
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
9118
+ }
9119
+ if (c === quote) {
9120
+ closed = true;
9121
+ i += 1;
9122
+ break;
9123
+ }
9124
+ out += c;
9125
+ i += 1;
9126
+ }
9127
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
9128
+ tokens.push({
9129
+ type: "string",
9130
+ value: out,
9131
+ pos: start
9132
+ });
9133
+ continue;
9134
+ }
9135
+ if (isIdentStart(ch)) {
9136
+ const start = i;
9137
+ while (i < n && isIdentPart(source[i])) i += 1;
9138
+ const text = source.slice(start, i);
9139
+ if (KEYWORDS.has(text)) tokens.push({
9140
+ type: "keyword",
9141
+ keyword: keywordOf(text),
9142
+ pos: start
9143
+ });
9144
+ else tokens.push({
9145
+ type: "identifier",
9146
+ name: text,
9147
+ pos: start
9148
+ });
9149
+ continue;
9150
+ }
9151
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
9152
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
9153
+ tokens.push({
9154
+ type: "punct",
9155
+ punct: two,
9156
+ pos: i
9157
+ });
9158
+ i += 2;
9159
+ continue;
9160
+ }
9161
+ if (isSinglePunct(ch)) {
9162
+ tokens.push({
9163
+ type: "punct",
9164
+ punct: ch,
9165
+ pos: i
9166
+ });
9167
+ i += 1;
9168
+ continue;
9169
+ }
9170
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
9171
+ }
9172
+ tokens.push({
9173
+ type: "eof",
9174
+ pos: n
9175
+ });
9176
+ return tokens;
9177
+ }
9178
+ function keywordOf(text) {
9179
+ if (text === "true") return "true";
9180
+ if (text === "false") return "false";
9181
+ return "null";
9182
+ }
9183
+ function isSinglePunct(ch) {
9184
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
9185
+ }
9082
9186
  /**
9083
- * `addon-widgets` — system-scoped singleton aggregator cap. Public-facing
9084
- * surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
9085
- *
9086
- * The provider iterates every `addon-widgets-source` (collection)
9087
- * provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
9088
- * `bundleUrl` strings pointing at
9089
- * `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
9090
- * `mtime` cache-buster lets the browser pick up addon rebuilds without
9091
- * manual reload — same scheme used by `addon-pages`.
9187
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
9092
9188
  *
9093
- * The hub-local builtin `addon-widgets-aggregator` (see
9094
- * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
9095
- * provider. Splitting the public aggregator from the raw collection
9096
- * keeps both ends in codegen — there's no hand-written wrapper.
9097
- */
9098
- var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
9099
- addonId: string(),
9100
- bundleUrl: string()
9101
- });
9102
- method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
9103
- /**
9104
- * Alerts capability — collection-based internal alert system.
9189
+ * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
9190
+ * → relational → additive → multiplicative → unary `! -` → call / primary.
9191
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
9192
+ * string validated against the builtin table at parse time, so an unknown
9193
+ * function is rejected immediately (author feedback) and a persisted expression
9194
+ * that references a since-removed builtin degrades at read.
9105
9195
  *
9106
- * Multiple providers can register. Each provider filters by EventBus category
9107
- * and creates/updates alerts. The built-in Alert Center addon persists alerts
9108
- * in the DB and serves them to the admin UI.
9109
- */
9110
- var AlertSeveritySchema = _enum([
9111
- "info",
9112
- "success",
9113
- "warning",
9114
- "error"
9115
- ]);
9116
- var AlertStatusSchema = _enum([
9117
- "active",
9118
- "in-progress",
9119
- "completed",
9120
- "failed",
9121
- "dismissed"
9122
- ]);
9123
- var AlertSourceSchema = object({
9124
- type: string(),
9125
- id: string()
9126
- });
9127
- var AlertSchema = object({
9128
- id: string(),
9129
- category: string(),
9130
- severity: AlertSeveritySchema,
9131
- title: string(),
9132
- message: string(),
9133
- status: AlertStatusSchema,
9134
- progress: number().optional(),
9135
- read: boolean(),
9136
- createdAt: number(),
9137
- updatedAt: number(),
9138
- source: AlertSourceSchema.optional(),
9139
- metadata: record(string(), unknown()).optional()
9140
- });
9141
- method(AlertSchema, _void(), { kind: "mutation" }), method(object({
9142
- alertId: string(),
9143
- patch: AlertSchema.partial()
9144
- }), _void(), { kind: "mutation" }), method(object({
9145
- unreadOnly: boolean().optional(),
9146
- limit: number().optional()
9147
- }).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" });
9148
- DeviceType.Camera, method(object({ deviceId: number() }), custom()), object({
9149
- deviceId: number(),
9150
- rms: number(),
9151
- dbfs: number()
9152
- });
9153
- /** Shared Zod schemas used across detection capabilities. */
9154
- /**
9155
- * Canonical frame-format enum mirrored on `FrameFormat` in
9156
- * `packages/types/src/types/io.ts`. Kept inline (vs imported) so the
9157
- * Zod runtime schema and TypeScript type stay in sync at the call site
9158
- * — adding a new format requires changing both this enum and the
9159
- * `FrameFormat` type alias together.
9196
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
9197
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
9160
9198
  */
9161
- var FrameFormatSchema = _enum([
9162
- "jpeg",
9163
- "rgb",
9164
- "bgr",
9165
- "yuv420",
9166
- "gray"
9167
- ]);
9168
- var FrameInputSchema = object({
9169
- data: custom(),
9170
- format: FrameFormatSchema,
9171
- width: number(),
9172
- height: number(),
9173
- timestamp: number()
9174
- });
9175
- var BoundingBoxSchema = object({
9176
- x: number(),
9177
- y: number(),
9178
- w: number(),
9179
- h: number()
9180
- });
9181
- object({
9182
- class: string(),
9183
- originalClass: string(),
9184
- score: number(),
9185
- bbox: BoundingBoxSchema
9186
- });
9187
- /**
9188
- * `data` carries the raw f32le bytes of the PCM samples (4 bytes per
9189
- * sample, little-endian IEEE 754). `Uint8Array` is the wire-safe choice
9190
- * because `@msgpack/msgpack` serialises it as a MsgPack `bin` type that
9191
- * round-trips losslessly over the UDS transport. `Float32Array` is NOT
9192
- * preserved — the encoder serialises it as `bin` (its raw bytes) but
9193
- * the decoder returns `Uint8Array`, so treating a `Float32Array` as the
9194
- * wire type causes receivers to read bytes as sample values, producing
9195
- * wildly wrong RMS/dBFS results (~+43 dBFS instead of ≤0).
9196
- *
9197
- * Callers that need float arithmetic reconstruct the view with:
9198
- * `new Float32Array(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength / 4)`
9199
+ /** Binary/logical operator precedence (higher binds tighter). */
9200
+ var BINARY_PRECEDENCE = {
9201
+ "||": 1,
9202
+ "&&": 2,
9203
+ "==": 3,
9204
+ "!=": 3,
9205
+ "<": 4,
9206
+ "<=": 4,
9207
+ ">": 4,
9208
+ ">=": 4,
9209
+ "+": 5,
9210
+ "-": 5,
9211
+ "*": 6,
9212
+ "/": 6,
9213
+ "%": 6
9214
+ };
9215
+ function isLogicalOp(op) {
9216
+ return op === "&&" || op === "||";
9217
+ }
9218
+ function isBinaryOp(op) {
9219
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
9220
+ }
9221
+ var Parser = class {
9222
+ tokens;
9223
+ pos = 0;
9224
+ nodeCount = 0;
9225
+ identifiers = /* @__PURE__ */ new Set();
9226
+ callees = /* @__PURE__ */ new Set();
9227
+ constructor(tokens) {
9228
+ this.tokens = tokens;
9229
+ }
9230
+ parse() {
9231
+ const ast = this.parseTernary();
9232
+ const tok = this.peek();
9233
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
9234
+ return {
9235
+ ast,
9236
+ identifiers: this.identifiers,
9237
+ callees: this.callees,
9238
+ nodeCount: this.nodeCount
9239
+ };
9240
+ }
9241
+ peek() {
9242
+ return this.tokens[this.pos];
9243
+ }
9244
+ next() {
9245
+ return this.tokens[this.pos++];
9246
+ }
9247
+ /** Consume a punctuator token, erroring if the next token isn't it. */
9248
+ expectPunct(punct) {
9249
+ const tok = this.peek();
9250
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
9251
+ this.pos += 1;
9252
+ }
9253
+ matchPunct(punct) {
9254
+ const tok = this.peek();
9255
+ if (tok.type === "punct" && tok.punct === punct) {
9256
+ this.pos += 1;
9257
+ return true;
9258
+ }
9259
+ return false;
9260
+ }
9261
+ countNode() {
9262
+ this.nodeCount += 1;
9263
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
9264
+ }
9265
+ parseTernary() {
9266
+ const test = this.parseBinary(1);
9267
+ if (this.matchPunct("?")) {
9268
+ const consequent = this.parseTernary();
9269
+ this.expectPunct(":");
9270
+ const alternate = this.parseTernary();
9271
+ this.countNode();
9272
+ return {
9273
+ kind: "conditional",
9274
+ test,
9275
+ consequent,
9276
+ alternate
9277
+ };
9278
+ }
9279
+ return test;
9280
+ }
9281
+ parseBinary(minPrec) {
9282
+ let left = this.parseUnary();
9283
+ for (;;) {
9284
+ const tok = this.peek();
9285
+ if (tok.type !== "punct") break;
9286
+ const prec = BINARY_PRECEDENCE[tok.punct];
9287
+ if (prec === void 0 || prec < minPrec) break;
9288
+ const op = tok.punct;
9289
+ this.pos += 1;
9290
+ const right = this.parseBinary(prec + 1);
9291
+ this.countNode();
9292
+ if (isLogicalOp(op)) left = {
9293
+ kind: "logical",
9294
+ op,
9295
+ left,
9296
+ right
9297
+ };
9298
+ else if (isBinaryOp(op)) left = {
9299
+ kind: "binary",
9300
+ op,
9301
+ left,
9302
+ right
9303
+ };
9304
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
9305
+ }
9306
+ return left;
9307
+ }
9308
+ parseUnary() {
9309
+ const tok = this.peek();
9310
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
9311
+ const op = tok.punct;
9312
+ this.pos += 1;
9313
+ const operand = this.parseUnary();
9314
+ this.countNode();
9315
+ return {
9316
+ kind: "unary",
9317
+ op,
9318
+ operand
9319
+ };
9320
+ }
9321
+ return this.parsePrimary();
9322
+ }
9323
+ parsePrimary() {
9324
+ const tok = this.next();
9325
+ switch (tok.type) {
9326
+ case "number":
9327
+ this.countNode();
9328
+ return {
9329
+ kind: "literal",
9330
+ value: tok.value
9331
+ };
9332
+ case "string":
9333
+ this.countNode();
9334
+ return {
9335
+ kind: "literal",
9336
+ value: tok.value
9337
+ };
9338
+ case "keyword":
9339
+ this.countNode();
9340
+ return {
9341
+ kind: "literal",
9342
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
9343
+ };
9344
+ case "identifier": {
9345
+ const nextTok = this.peek();
9346
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
9347
+ this.identifiers.add(tok.name);
9348
+ this.countNode();
9349
+ return {
9350
+ kind: "identifier",
9351
+ name: tok.name
9352
+ };
9353
+ }
9354
+ case "punct":
9355
+ if (tok.punct === "(") {
9356
+ const inner = this.parseTernary();
9357
+ this.expectPunct(")");
9358
+ return inner;
9359
+ }
9360
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
9361
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
9362
+ }
9363
+ }
9364
+ parseCall(callee, pos) {
9365
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
9366
+ this.expectPunct("(");
9367
+ const args = [];
9368
+ if (!this.matchPunct(")")) for (;;) {
9369
+ args.push(this.parseTernary());
9370
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
9371
+ if (this.matchPunct(",")) continue;
9372
+ this.expectPunct(")");
9373
+ break;
9374
+ }
9375
+ this.callees.add(callee);
9376
+ this.countNode();
9377
+ return {
9378
+ kind: "call",
9379
+ callee,
9380
+ args
9381
+ };
9382
+ }
9383
+ };
9384
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
9385
+ * `ExpressionParseError` on any lexical or grammatical failure. */
9386
+ function parseExpression(source) {
9387
+ return new Parser(tokenize(source)).parse();
9388
+ }
9389
+ /**
9390
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
9391
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
9392
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
9393
+ * one per read on a hot resolve path.
9394
+ *
9395
+ * The cache is a module-level singleton: entries are pure, content-addressed
9396
+ * ASTs keyed by the raw source string, so sharing one instance across all
9397
+ * callers is safe and maximises hit rate.
9398
+ */
9399
+ var cache = /* @__PURE__ */ new Map();
9400
+ function getCached(source) {
9401
+ const hit = cache.get(source);
9402
+ if (hit !== void 0) {
9403
+ cache.delete(source);
9404
+ cache.set(source, hit);
9405
+ return hit;
9406
+ }
9407
+ let result;
9408
+ try {
9409
+ result = {
9410
+ ok: true,
9411
+ parsed: parseExpression(source)
9412
+ };
9413
+ } catch (err) {
9414
+ result = {
9415
+ ok: false,
9416
+ error: err instanceof ExpressionParseError ? err.message : String(err)
9417
+ };
9418
+ }
9419
+ cache.set(source, result);
9420
+ if (cache.size > 256) {
9421
+ const oldest = cache.keys().next().value;
9422
+ if (oldest !== void 0) cache.delete(oldest);
9423
+ }
9424
+ return result;
9425
+ }
9426
+ /** Compile `source`, returning a discriminated result instead of throwing.
9427
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
9428
+ function compileExpressionSafe(source) {
9429
+ return getCached(source);
9430
+ }
9431
+ Object.freeze({});
9432
+ /**
9433
+ * Author-time validation. Returns `null` when the source is valid, else a
9434
+ * human-readable error message. Checks: the expression compiles; binding count
9435
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
9436
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
9437
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
9438
+ */
9439
+ function validateExpressionSource(src) {
9440
+ const names = Object.keys(src.bindings);
9441
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
9442
+ for (const name of names) {
9443
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
9444
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
9445
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
9446
+ }
9447
+ const compiled = compileExpressionSafe(src.expr);
9448
+ if (!compiled.ok) return compiled.error;
9449
+ const bound = new Set(names);
9450
+ for (const id of compiled.parsed.identifiers) {
9451
+ if (id === "now") continue;
9452
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
9453
+ }
9454
+ return null;
9455
+ }
9456
+ var ExpressionBindingSourceSchema = union([
9457
+ object({
9458
+ kind: literal("field").optional(),
9459
+ sourceKey: string(),
9460
+ cap: string(),
9461
+ fieldPath: string()
9462
+ }),
9463
+ object({
9464
+ kind: literal("literal"),
9465
+ value: union([
9466
+ string(),
9467
+ number(),
9468
+ boolean(),
9469
+ _null()
9470
+ ])
9471
+ }),
9472
+ object({
9473
+ kind: literal("global"),
9474
+ sourceStableId: string(),
9475
+ cap: string(),
9476
+ fieldPath: string()
9477
+ })
9478
+ ]);
9479
+ object({
9480
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
9481
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
9482
+ }).superRefine((src, ctx) => {
9483
+ const err = validateExpressionSource(src);
9484
+ if (err !== null) ctx.addIssue({
9485
+ code: "custom",
9486
+ message: err,
9487
+ path: ["expr"]
9488
+ });
9489
+ });
9490
+ /** How a leaf compares a device field to a value. Derived from the field's
9491
+ * `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
9492
+ var AutomationConditionOperatorSchema = _enum([
9493
+ "eq",
9494
+ "ne",
9495
+ "gt",
9496
+ "gte",
9497
+ "lt",
9498
+ "lte",
9499
+ "contains",
9500
+ "in"
9501
+ ]);
9502
+ var AutomationConditionLeafSchema = object({
9503
+ kind: literal("condition"),
9504
+ deviceId: number().int().nonnegative(),
9505
+ cap: string().min(1),
9506
+ fieldPath: string().min(1),
9507
+ operator: AutomationConditionOperatorSchema,
9508
+ value: union([
9509
+ string(),
9510
+ number(),
9511
+ boolean(),
9512
+ array(union([string(), number()]))
9513
+ ])
9514
+ });
9515
+ /**
9516
+ * The expression leaf, declared as a plain object rather than an intersection
9517
+ * with {@link ExpressionSourceSchema}: a discriminated union has to be able to
9518
+ * read `kind` off each option, and an intersection hides it. The author-time
9519
+ * validation is the SAME function `ExpressionSourceSchema` runs, so the two
9520
+ * cannot drift — an expression that one accepts, the other accepts.
9521
+ */
9522
+ var AutomationConditionExpressionSchema = object({
9523
+ kind: literal("expression"),
9524
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
9525
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
9526
+ }).superRefine((src, ctx) => {
9527
+ const err = validateExpressionSource(src);
9528
+ if (err !== null) ctx.addIssue({
9529
+ code: "custom",
9530
+ message: err,
9531
+ path: ["expr"]
9532
+ });
9533
+ });
9534
+ var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
9535
+ object({
9536
+ kind: literal("all"),
9537
+ children: array(AutomationConditionSchema)
9538
+ }),
9539
+ object({
9540
+ kind: literal("any"),
9541
+ children: array(AutomationConditionSchema)
9542
+ }),
9543
+ object({
9544
+ kind: literal("not"),
9545
+ child: AutomationConditionSchema
9546
+ }),
9547
+ AutomationConditionLeafSchema,
9548
+ AutomationConditionExpressionSchema
9549
+ ]));
9550
+ /**
9551
+ * What starts a run.
9552
+ *
9553
+ * D8 compliance, and it is the reason `device-state` is not merely an event
9554
+ * subscription: the trigger evaluates against the **state mirror**, which is
9555
+ * reconciled, and an event only WAKES the evaluation. A dropped event therefore
9556
+ * DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
9557
+ * already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
9558
+ * not.
9559
+ */
9560
+ var AutomationTriggerSchema = discriminatedUnion("kind", [
9561
+ object({
9562
+ kind: literal("device-state"),
9563
+ deviceId: number().int().nonnegative(),
9564
+ cap: string().min(1),
9565
+ fieldPath: string().min(1),
9566
+ /** Fire when the field takes this value. Omit to fire on any change. */
9567
+ becomes: union([
9568
+ string(),
9569
+ number(),
9570
+ boolean()
9571
+ ]).optional(),
9572
+ /** Only on a CHANGE of value, not on every re-report. */
9573
+ edge: boolean().optional(),
9574
+ /** The condition must hold this long before the run starts. */
9575
+ forMs: number().int().min(0).max(864e5).optional(),
9576
+ /** Collapse a burst into one run. */
9577
+ debounceMs: number().int().min(0).max(6e5).optional()
9578
+ }),
9579
+ object({
9580
+ kind: literal("device-event"),
9581
+ /** An `EventCategory` value. */
9582
+ category: string().min(1),
9583
+ deviceId: number().int().nonnegative().optional()
9584
+ }),
9585
+ object({
9586
+ kind: literal("schedule"),
9587
+ cron: string().min(1).max(120)
9588
+ }),
9589
+ object({ kind: literal("manual") })
9590
+ ]);
9591
+ /**
9592
+ * One action step.
9593
+ *
9594
+ * `wait` and `cap` are `NcRuleActionSchema`'s two members, kept structurally
9595
+ * identical so `NcRuleActionRunner` runs them unchanged — its device-scope
9596
+ * check, stop-at-first-failure and per-sequence throttle are the whole reason
9597
+ * to reuse it, and none of them are re-implemented here.
9598
+ *
9599
+ * **The one divergence, and it is forced.** `NcRuleActionSchema.cap.deviceId` is
9600
+ * a literal `z.number().int()`, and the NC runner's own `RunSequencesInput`
9601
+ * documents its subject device as *"for the log tag, never for routing"*. So an
9602
+ * NC action can never target the device that triggered it — which is fine for
9603
+ * the NC (its rules already scope to a device) and fatal for an automation
9604
+ * ("sound the siren of the camera that saw the person"). `deviceId` therefore
9605
+ * also accepts `{ $var }`, resolved from the run's `vars` bag BEFORE the runner
9606
+ * is called. The runner still receives a number and is untouched; the
9607
+ * resolution is the recipe's job, not the runner's.
9608
+ */
9609
+ var AutomationActionSchema = discriminatedUnion("kind", [
9610
+ object({
9611
+ kind: literal("wait"),
9612
+ seconds: number().min(0).max(300)
9613
+ }),
9614
+ object({
9615
+ kind: literal("cap"),
9616
+ deviceId: union([number().int(), object({ $var: string().min(1) })]),
9617
+ cap: string().min(1),
9618
+ method: string().min(1),
9619
+ /** Values may carry `{{vars.x}}` slots, which SUBSTITUTE and do not
9620
+ * evaluate (§3.2.3). Anything beyond substitution is the expression leaf. */
9621
+ args: record(string(), unknown()).optional()
9622
+ }),
9623
+ object({
9624
+ kind: literal("code"),
9625
+ /** Compiled into the automation's OWN block by esbuild — not a third
9626
+ * runtime, not a `vm`, and not dynamically evaluated. */
9627
+ code: string().min(1).max(2e4)
9628
+ })
9629
+ ]);
9630
+ object({
9631
+ triggers: array(AutomationTriggerSchema),
9632
+ conditions: AutomationConditionSchema.optional(),
9633
+ actions: array(AutomationActionSchema)
9634
+ });
9635
+ /**
9636
+ * `addon-pages` — system-scoped singleton aggregator cap. Public-facing
9637
+ * surface that admin-ui consumes through `useAddonPagesListPages()`.
9638
+ *
9639
+ * The provider iterates every `addon-pages-source` (collection) provider
9640
+ * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
9641
+ * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
9642
+ * filesystem `mtime` cache-buster lets the browser pick up addon
9643
+ * rebuilds without manual reload.
9644
+ *
9645
+ * The hub-local builtin `addon-pages-aggregator` (see
9646
+ * `@camstack/system/builtins/addon-pages-aggregator`) registers the
9647
+ * provider. Splitting the public aggregator from the raw collection
9648
+ * keeps both ends in codegen — there's no hand-written
9649
+ * `addon-pages.router.ts` wrapper anymore.
9650
+ */
9651
+ var AddonPageDeclarationSchema$1 = object({
9652
+ id: string(),
9653
+ label: string(),
9654
+ icon: string(),
9655
+ path: string(),
9656
+ remoteName: string(),
9657
+ bundle: string(),
9658
+ section: string().optional(),
9659
+ sectionLabel: string().optional()
9660
+ });
9661
+ var AddonPageInfoSchema = object({
9662
+ addonId: string(),
9663
+ page: AddonPageDeclarationSchema$1,
9664
+ bundleUrl: string()
9665
+ });
9666
+ method(_void(), array(AddonPageInfoSchema).readonly());
9667
+ /**
9668
+ * `addon-pages-source` — collection cap exposing per-provider raw page
9669
+ * declarations. Every addon that contributes a UI page registers a
9670
+ * provider here. The hub-side singleton aggregator (`addon-pages` cap,
9671
+ * see `addon-pages.cap.ts`) walks this collection, stamps versioned
9672
+ * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
9673
+ * that admin-ui consumes.
9674
+ *
9675
+ * The split exists because the public listing has a different output
9676
+ * shape than the per-provider raw declarations, and we want both ends
9677
+ * to flow through codegen instead of relying on a hand-written wrapper.
9678
+ */
9679
+ var AddonPageDeclarationSchema = object({
9680
+ id: string(),
9681
+ label: string(),
9682
+ icon: string(),
9683
+ path: string(),
9684
+ /**
9685
+ * Module Federation remote name — must match the `name` field on the
9686
+ * page addon's `federation()` plugin config. Used by admin-ui's
9687
+ * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
9688
+ * Conventionally `addon_<id>_page` (snake_case; MF names cannot
9689
+ * contain hyphens).
9690
+ */
9691
+ remoteName: string(),
9692
+ /**
9693
+ * Bundle filename inside the addon's `dist/` dir served at
9694
+ * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
9695
+ * is always `'remoteEntry.js'`; the value is kept on the metadata so
9696
+ * the static-file route can compute an mtime-based cache-buster URL
9697
+ * without a separate filesystem stat.
9698
+ */
9699
+ bundle: string(),
9700
+ /**
9701
+ * Sidebar section this page docks into. Well-known ids: `'detection'`,
9702
+ * `'cluster'`, `'administration'` — the page renders inside that group.
9703
+ * Any OTHER string creates (or joins) a custom section rendered after
9704
+ * the built-in groups; its label comes from `sectionLabel` (first
9705
+ * declaration wins), falling back to the id. Absent → the legacy
9706
+ * "Addon Pages" group.
9707
+ */
9708
+ section: string().optional(),
9709
+ /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
9710
+ sectionLabel: string().optional()
9711
+ });
9712
+ method(_void(), array(AddonPageDeclarationSchema).readonly());
9713
+ var AddonHttpRouteSchema = object({
9714
+ method: _enum([
9715
+ "GET",
9716
+ "POST",
9717
+ "PUT",
9718
+ "DELETE",
9719
+ "PATCH"
9720
+ ]),
9721
+ path: string(),
9722
+ access: _enum([
9723
+ "public",
9724
+ "authenticated",
9725
+ "admin"
9726
+ ]).optional(),
9727
+ description: string().optional()
9728
+ });
9729
+ /**
9730
+ * Cross-process route invocation envelope. The hub captures the
9731
+ * request as plain data, ships it to the worker via Moleculer, and
9732
+ * the worker runs the local handler against a capturing reply. The
9733
+ * envelope returned describes what the handler intended (status,
9734
+ * headers, body, or a redirect) so the hub can translate it back to
9735
+ * the Fastify reply that's actually wired to the socket.
9736
+ */
9737
+ var InvokeRequestSchema = object({
9738
+ method: string(),
9739
+ path: string(),
9740
+ params: record(string(), string()),
9741
+ query: record(string(), string()),
9742
+ body: unknown(),
9743
+ headers: record(string(), string()),
9744
+ user: object({
9745
+ id: string(),
9746
+ username: string(),
9747
+ isAdmin: boolean()
9748
+ }).optional(),
9749
+ scopedToken: unknown().optional()
9750
+ });
9751
+ var InvokeReplyEnvelopeSchema = object({
9752
+ status: number().int(),
9753
+ headers: record(string(), string()),
9754
+ /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
9755
+ * sending `body`. Status defaults to 302 when this is set unless
9756
+ * the handler called `reply.code(...)` explicitly. */
9757
+ redirectUrl: string().nullable(),
9758
+ /** JSON-serializable body. `undefined` is treated as "no body". */
9759
+ body: unknown().optional(),
9760
+ /** Set when the handler called `reply.type(mime)`. */
9761
+ contentType: string().optional()
9762
+ });
9763
+ method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
9764
+ var ConfigTabDeclarationSchema = object({
9765
+ id: string(),
9766
+ label: string(),
9767
+ icon: string(),
9768
+ order: number().optional()
9769
+ });
9770
+ var ConfigSectionWithValuesSchema = object({
9771
+ id: string(),
9772
+ title: string(),
9773
+ description: string().optional(),
9774
+ style: _enum(["card", "accordion"]).optional(),
9775
+ defaultCollapsed: boolean().optional(),
9776
+ columns: union([
9777
+ literal(1),
9778
+ literal(2),
9779
+ literal(3),
9780
+ literal(4)
9781
+ ]).optional(),
9782
+ tab: string().optional(),
9783
+ location: _enum(["settings", "top-tab"]).optional(),
9784
+ order: number().optional(),
9785
+ fields: array(any())
9786
+ });
9787
+ var SettingsSchemaWithValuesSchema = object({
9788
+ tabs: array(ConfigTabDeclarationSchema).optional(),
9789
+ sections: array(ConfigSectionWithValuesSchema)
9790
+ });
9791
+ /** Patch object — keys are field names, values are the new field values. */
9792
+ var SettingsPatchSchema = record(string(), unknown());
9793
+ /** Standard success response for update operations. */
9794
+ var SettingsUpdateResultSchema = object({ success: literal(true) });
9795
+ method(object({
9796
+ addonId: string(),
9797
+ nodeId: string().optional(),
9798
+ overlay: record(string(), unknown()).optional(),
9799
+ cap: string().optional()
9800
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9801
+ addonId: string(),
9802
+ nodeId: string().optional(),
9803
+ patch: SettingsPatchSchema
9804
+ }), SettingsUpdateResultSchema, {
9805
+ kind: "mutation",
9806
+ auth: "admin"
9807
+ }), method(object({
9808
+ addonId: string(),
9809
+ deviceId: number(),
9810
+ nodeId: string().optional()
9811
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
9812
+ addonId: string(),
9813
+ deviceId: number(),
9814
+ nodeId: string().optional(),
9815
+ patch: SettingsPatchSchema
9816
+ }), SettingsUpdateResultSchema, {
9817
+ kind: "mutation",
9818
+ auth: "admin"
9819
+ });
9820
+ /**
9821
+ * `addon-widgets-source` — collection cap exposing per-addon raw widget
9822
+ * declarations. Mirrors the addon-pages split: every addon shipping
9823
+ * widgets registers a provider on this collection cap; the hub-local
9824
+ * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
9825
+ * collection, stamps versioned `bundleUrl`s onto each declaration, and
9826
+ * exposes the public listing surface that admin-ui consumes.
9827
+ *
9828
+ * The split exists because the public listing has a different output
9829
+ * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
9830
+ * per-provider raw declarations. Both ends flow through codegen.
9831
+ *
9832
+ * Unified UI-contribution model (Task 10): a widget descriptor IS a
9833
+ * `UiContribution` with `kind:'remote'`. The host renders it through the
9834
+ * same `ContributionRenderer` / Module-Federation path as every other
9835
+ * contributed UI surface — no bespoke widget-rendering path. The widget-
9836
+ * only metadata (sizing hints, `requires`) lives as extra fields on the
9837
+ * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
9838
+ * `kind` / `remote`) carries identity + placement + the MF remote.
9839
+ */
9840
+ /** Where the widget makes sense to render — maps to a contribution `tab`. */
9841
+ var WidgetHostEnum = _enum([
9842
+ "device-tab",
9843
+ "dashboard",
9844
+ "integration-detail"
9845
+ ]);
9846
+ var WidgetSizeEnum = _enum([
9847
+ "xs",
9848
+ "sm",
9849
+ "md",
9850
+ "lg",
9851
+ "xl"
9852
+ ]);
9853
+ /**
9854
+ * MF remote descriptor — mirrors `UiContributionRemote` from
9855
+ * `capability-definition.ts`. Widget remotes expose a single
9856
+ * `'./widgets'` module whose default export is a
9857
+ * `Record<componentKey, Component>` map; `componentKey` (the widget
9858
+ * `stableId`) picks the entry the host mounts.
9859
+ */
9860
+ var WidgetRemoteSchema = object({
9861
+ remoteName: string(),
9862
+ exposedModule: string(),
9863
+ componentKey: string().optional()
9864
+ });
9865
+ /**
9866
+ * One widget declaration — a `UiContribution` (`kind:'remote'`) plus
9867
+ * widget-only metadata. The `UiContribution` core fields:
9868
+ *
9869
+ * - `tab` — where the widget hosts. A widget that runs on the
9870
+ * dashboard declares `tab:'dashboard'`; a device-tab
9871
+ * widget declares the target device-detail tab id.
9872
+ * - `subTab` — optional sub-tab within `tab`.
9873
+ * - `label` — operator-facing label.
9874
+ * - `order` — ordering within `(tab, subTab)`.
9875
+ * - `kind` — always `'remote'` for widgets.
9876
+ * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
9877
+ *
9878
+ * Widget-only fields retained alongside the contribution core:
9879
+ *
9880
+ * - `stableId` — stable identity within the addon (the MF
9881
+ * `componentKey`; kept top-level so consumers have
9882
+ * a stable key without reaching into `remote`).
9883
+ * - `description` / `icon` — picker metadata.
9884
+ * - `bundle` — entry filename inside the addon `dist/` dir; the
9885
+ * aggregator stamps a versioned `bundleUrl` from it.
9886
+ * - `hosts` — every host the widget supports (a widget can run
9887
+ * both on the dashboard and a device tab). `tab`
9888
+ * is the PRIMARY host; `hosts` is the full set the
9889
+ * picker filters on.
9890
+ * - `requires` — host-context requirements validated at mount.
9891
+ * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
9892
+ * — dashboard placement hints.
9893
+ */
9894
+ var WidgetMetadataSchema = object({
9895
+ /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
9896
+ tab: string(),
9897
+ /** Optional sub-tab within `tab`. */
9898
+ subTab: string().optional(),
9899
+ /** Operator-facing label. */
9900
+ label: string(),
9901
+ /** Ordering within `(tab, subTab)`, ascending. */
9902
+ order: number().optional(),
9903
+ /** Always `'remote'` — a widget is a Module Federation remote. */
9904
+ kind: literal("remote"),
9905
+ /** MF remote descriptor. */
9906
+ remote: WidgetRemoteSchema,
9907
+ /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
9908
+ stableId: string(),
9909
+ description: string().optional(),
9910
+ icon: string().optional(),
9911
+ /**
9912
+ * Bundle filename inside the addon's `dist/` dir served at
9913
+ * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
9914
+ * this is always `'remoteEntry.js'` — the value is kept on the
9915
+ * metadata so the static-file route can compute an mtime-based
9916
+ * cache-buster URL without a separate filesystem stat.
9917
+ */
9918
+ bundle: string(),
9919
+ /** Every host the widget supports. The picker filters on this set. */
9920
+ hosts: array(WidgetHostEnum).readonly(),
9921
+ /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
9922
+ requires: object({
9923
+ deviceContext: boolean().default(false),
9924
+ integrationContext: boolean().default(false)
9925
+ }),
9926
+ /**
9927
+ * Loadable BEFORE authentication. The normal widget registry listing
9928
+ * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
9929
+ * (the login page) cannot discover a widget through it. A widget that
9930
+ * declares `preAuth: true` marks itself as safe to mount on a pre-auth
9931
+ * screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
9932
+ * login-method contribution channel (see `login-method.cap.ts`) rather
9933
+ * than the authenticated registry, and its bundle is served by the
9934
+ * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
9935
+ */
9936
+ preAuth: boolean().optional().default(false),
9937
+ /** Dashboard placement HINTS (operator can override per instance). */
9938
+ defaultSize: WidgetSizeEnum.default("md"),
9939
+ allowedSizes: array(WidgetSizeEnum).readonly().default([
9940
+ "sm",
9941
+ "md",
9942
+ "lg"
9943
+ ]),
9944
+ defaultColumns: number().int().min(1).max(12).default(6),
9945
+ defaultRows: number().int().min(1).max(12).default(1)
9946
+ });
9947
+ var addonWidgetsSourceCapability = {
9948
+ name: "addon-widgets-source",
9949
+ scope: "system",
9950
+ mode: "collection",
9951
+ internal: true,
9952
+ methods: { listWidgets: method(_void(), array(WidgetMetadataSchema).readonly()) }
9953
+ };
9954
+ /**
9955
+ * `addon-widgets` — system-scoped singleton aggregator cap. Public-facing
9956
+ * surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
9957
+ *
9958
+ * The provider iterates every `addon-widgets-source` (collection)
9959
+ * provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
9960
+ * `bundleUrl` strings pointing at
9961
+ * `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
9962
+ * `mtime` cache-buster lets the browser pick up addon rebuilds without
9963
+ * manual reload — same scheme used by `addon-pages`.
9964
+ *
9965
+ * The hub-local builtin `addon-widgets-aggregator` (see
9966
+ * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
9967
+ * provider. Splitting the public aggregator from the raw collection
9968
+ * keeps both ends in codegen — there's no hand-written wrapper.
9969
+ */
9970
+ var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
9971
+ addonId: string(),
9972
+ bundleUrl: string()
9973
+ });
9974
+ method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
9975
+ /**
9976
+ * Alerts capability — collection-based internal alert system.
9977
+ *
9978
+ * Multiple providers can register. Each provider filters by EventBus category
9979
+ * and creates/updates alerts. The built-in Alert Center addon persists alerts
9980
+ * in the DB and serves them to the admin UI.
9981
+ */
9982
+ var AlertSeveritySchema = _enum([
9983
+ "info",
9984
+ "success",
9985
+ "warning",
9986
+ "error"
9987
+ ]);
9988
+ var AlertStatusSchema = _enum([
9989
+ "active",
9990
+ "in-progress",
9991
+ "completed",
9992
+ "failed",
9993
+ "dismissed"
9994
+ ]);
9995
+ var AlertSourceSchema = object({
9996
+ type: string(),
9997
+ id: string()
9998
+ });
9999
+ var AlertSchema = object({
10000
+ id: string(),
10001
+ category: string(),
10002
+ severity: AlertSeveritySchema,
10003
+ title: string(),
10004
+ message: string(),
10005
+ status: AlertStatusSchema,
10006
+ progress: number().optional(),
10007
+ read: boolean(),
10008
+ createdAt: number(),
10009
+ updatedAt: number(),
10010
+ source: AlertSourceSchema.optional(),
10011
+ metadata: record(string(), unknown()).optional()
10012
+ });
10013
+ method(AlertSchema, _void(), { kind: "mutation" }), method(object({
10014
+ alertId: string(),
10015
+ patch: AlertSchema.partial()
10016
+ }), _void(), { kind: "mutation" }), method(object({
10017
+ unreadOnly: boolean().optional(),
10018
+ limit: number().optional()
10019
+ }).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" });
10020
+ DeviceType.Camera, method(object({ deviceId: number() }), custom()), object({
10021
+ deviceId: number(),
10022
+ rms: number(),
10023
+ dbfs: number()
10024
+ });
10025
+ /** Shared Zod schemas used across detection capabilities. */
10026
+ /**
10027
+ * Canonical frame-format enum mirrored on `FrameFormat` in
10028
+ * `packages/types/src/types/io.ts`. Kept inline (vs imported) so the
10029
+ * Zod runtime schema and TypeScript type stay in sync at the call site
10030
+ * — adding a new format requires changing both this enum and the
10031
+ * `FrameFormat` type alias together.
10032
+ */
10033
+ var FrameFormatSchema = _enum([
10034
+ "jpeg",
10035
+ "rgb",
10036
+ "bgr",
10037
+ "yuv420",
10038
+ "gray"
10039
+ ]);
10040
+ var FrameInputSchema = object({
10041
+ data: custom(),
10042
+ format: FrameFormatSchema,
10043
+ width: number(),
10044
+ height: number(),
10045
+ timestamp: number()
10046
+ });
10047
+ var BoundingBoxSchema = object({
10048
+ x: number(),
10049
+ y: number(),
10050
+ w: number(),
10051
+ h: number()
10052
+ });
10053
+ object({
10054
+ class: string(),
10055
+ originalClass: string(),
10056
+ score: number(),
10057
+ bbox: BoundingBoxSchema
10058
+ });
10059
+ /**
10060
+ * `data` carries the raw f32le bytes of the PCM samples (4 bytes per
10061
+ * sample, little-endian IEEE 754). `Uint8Array` is the wire-safe choice
10062
+ * because `@msgpack/msgpack` serialises it as a MsgPack `bin` type that
10063
+ * round-trips losslessly over the UDS transport. `Float32Array` is NOT
10064
+ * preserved — the encoder serialises it as `bin` (its raw bytes) but
10065
+ * the decoder returns `Uint8Array`, so treating a `Float32Array` as the
10066
+ * wire type causes receivers to read bytes as sample values, producing
10067
+ * wildly wrong RMS/dBFS results (~+43 dBFS instead of ≤0).
10068
+ *
10069
+ * Callers that need float arithmetic reconstruct the view with:
10070
+ * `new Float32Array(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength / 4)`
9199
10071
  */
9200
10072
  var AudioChunkInputSchema = object({
9201
10073
  data: _instanceof(Uint8Array),
@@ -11900,6 +12772,15 @@ method(object({
11900
12772
  }), method(ReleaseInputSchema.extend({ addonId: string() }), _void(), {
11901
12773
  kind: "mutation",
11902
12774
  auth: "admin"
12775
+ }), method(AdoptInputSchema.extend({ addonId: string() }), object({ jobId: string() }), {
12776
+ kind: "mutation",
12777
+ auth: "admin"
12778
+ }), method(object({
12779
+ addonId: string(),
12780
+ integrationId: string().optional()
12781
+ }), array(AdoptionJobSchema).readonly(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
12782
+ kind: "mutation",
12783
+ auth: "admin"
11903
12784
  }), method(ResyncInputSchema, ResyncResultSchema, {
11904
12785
  kind: "mutation",
11905
12786
  auth: "admin"
@@ -12622,7 +13503,7 @@ DeviceType.Camera, method(object({
12622
13503
  * Why: pub/sub routing over the system event-bus loses fidelity
12623
13504
  * (callback shape, QoS guarantees, will/retain semantics) and adds
12624
13505
  * refcount bookkeeping that addons would rather own themselves. The
12625
- * canonical consumer (`addon-export-ha-mqtt`) needs raw `mqtt.js`
13506
+ * canonical consumer needs raw `mqtt.js`
12626
13507
  * features anyway — give it the connection config, get out of the way.
12627
13508
  *
12628
13509
  * Consumer flow:
@@ -14779,6 +15660,65 @@ object({
14779
15660
  * Each provider returns a static descriptor; the core enumerates them
14780
15661
  * to validate the `integration=` query param and resolve the consent
14781
15662
  * label + the scopes baked into the issued token.
15663
+ *
15664
+ * ## Declaring one
15665
+ *
15666
+ * An OAuth client is integration-specific knowledge — who the client is, what
15667
+ * it may ask for, where it may be sent — so it is declared by the ADDON that
15668
+ * owns the integration, never by the kernel and never as a branch inside
15669
+ * `oauth2-routes.ts` ([D101](../../../../docs/decisions/adr-0101.md)). Three
15670
+ * steps, no others:
15671
+ *
15672
+ * 1. Add `{ "name": "oauth-integration" }` to the addon's `camstack.addons[]`
15673
+ * manifest entry. This is also what tells the hub, at addon-LOAD time, that
15674
+ * a descriptor is owed — see "the boot window" below.
15675
+ * 2. Return a provider from `onInitialize()`:
15676
+ *
15677
+ * ```ts
15678
+ * const provider: IOauthIntegrationProvider = {
15679
+ * getDescriptor: async () => ({
15680
+ * integrationId: 'my-thing', // the `integration=` query param
15681
+ * displayName: 'My Thing',
15682
+ * requestedScopes: [ … ], // see below
15683
+ * allowedRedirectPrefixes: ['https://callback.example/'],
15684
+ * }),
15685
+ * }
15686
+ * return [{ capability: oauthIntegrationCapability, provider }]
15687
+ * ```
15688
+ *
15689
+ * The descriptor must be **static** — it is read on the authorize path, so
15690
+ * never put an await on network or disk behind it, and never register it
15691
+ * behind one either (a provider is registered only once `onInitialize`
15692
+ * RETURNS, so anything awaited before the return delays linking).
15693
+ * 3. Nothing else. There is no allow-list to join, no id to register with the
15694
+ * core, and no per-integration branch anywhere: `/api/oauth2/authorize` and
15695
+ * `/api/oauth2/integrations` are built from this collection alone.
15696
+ *
15697
+ * **Scopes.** `requestedScopes` is baked into every token this integration is
15698
+ * ever issued and the operator consents to it once. Derive it from the tRPC
15699
+ * paths the client calls **with that token**, against `METHOD_ACCESS_MAP`, and
15700
+ * prefer a narrow `capability:` scope to a `category:` one unless the client
15701
+ * genuinely needs a whole family. A category scope grants every future member
15702
+ * of that category too. `category:system [create]` has been rejected once and
15703
+ * should stay rejected: it hands `addons.installPackage` to an integration.
15704
+ *
15705
+ * What it does NOT cover: calls the ADDON makes over `ctx.api`, which run as
15706
+ * the addon and are not scope-checked. Alexa's descriptor is narrower than
15707
+ * Home Assistant's for exactly that reason — its Lambda posts directives and
15708
+ * the addon does the work, while the Home Assistant component calls tRPC
15709
+ * directly with the token. So `requestedScopes` describes the blast radius of
15710
+ * the GRANT, not the reach of the integration; do not widen one to describe the
15711
+ * other.
15712
+ *
15713
+ * **The boot window.** An addon registers its provider after its runner forks
15714
+ * and initialises, so between hub start and that moment this collection is
15715
+ * incomplete and an `integrationId` can be legitimately absent. The core does
15716
+ * not wait, poll or cache around this ([D3](../../../../docs/decisions/adr-0003.md)):
15717
+ * it compares the manifest declarers against the registered providers and
15718
+ * answers `503 temporarily_unavailable` (with `Retry-After` and the pending
15719
+ * addon ids) instead of `400 unknown integration`, and reports
15720
+ * `complete: false` on `GET /api/oauth2/integrations`. A client should retry
15721
+ * while the list is incomplete rather than conclude the hub cannot do OAuth.
14782
15722
  */
14783
15723
  var OauthIntegrationDescriptorSchema = object({
14784
15724
  /** Stable id used as the `integration=` query param, e.g. 'export-alexa'. */
@@ -15162,8 +16102,26 @@ var TrackSchema = object({
15162
16102
  /** Periodic snapshots at snapshotIntervalMs cadence (subject to
15163
16103
  * saveThumbnails policy). */
15164
16104
  snapshots: array(TrackSnapshotSchema).readonly(),
15165
- /** Deduplicated zones the track has entered at least once. */
16105
+ /** Deduplicated zones the track has entered at least once. Zone IDS. */
15166
16106
  zonesVisited: array(string()).readonly(),
16107
+ /**
16108
+ * Human NAMES for {@link zonesVisited}, resolved at READ time against the
16109
+ * `zones` capability.
16110
+ *
16111
+ * `zonesVisited` persists ids (`cfeec78c-8d69-…`), which no operator can type
16112
+ * and no card can render — so every free-text search surface was structurally
16113
+ * unable to answer "show me the tracks in Uscio", and did not fail loudly, it
16114
+ * just returned nothing. Resolving here rather than in each client keeps ONE
16115
+ * derivation and costs the clients no extra call (the `zones` cap is
16116
+ * per-device, so a client-side resolve would be a per-camera fan-out on a
16117
+ * surface built to avoid exactly that).
16118
+ *
16119
+ * Resolved, never invented: a zone deleted since the track was written has no
16120
+ * name and is DROPPED, so this array can be shorter than `zonesVisited` — the
16121
+ * two are not positionally aligned. Absent when the track visited no zone, or
16122
+ * when the zone catalogue could not be read.
16123
+ */
16124
+ zoneNames: array(string()).readonly().optional(),
15167
16125
  /** Deduplicated set of detector classes observed for this track over its
15168
16126
  * life (a track may be reclassified, e.g. person→vehicle). Absent on
15169
16127
  * legacy rows written before class accumulation shipped. */
@@ -17557,6 +18515,23 @@ var CameraRecordingStatusSchema = object({
17557
18515
  active: boolean(),
17558
18516
  storageBytes: number()
17559
18517
  });
18518
+ /** One stage of the fan-out that could NOT be read, and how long it cost. */
18519
+ var CameraStatusDegradationSchema = object({
18520
+ stage: _enum([
18521
+ "source",
18522
+ "broker",
18523
+ "detection",
18524
+ "recording",
18525
+ "switches"
18526
+ ]),
18527
+ reason: _enum([
18528
+ "timeout",
18529
+ "error",
18530
+ "partial"
18531
+ ]),
18532
+ /** Wall-clock ms spent on the stage before it was abandoned. */
18533
+ elapsedMs: number()
18534
+ });
17560
18535
  /**
17561
18536
  * Aggregated per-camera pipeline status — server-composed, single call.
17562
18537
  *
@@ -17587,9 +18562,28 @@ var CameraStatusSchema = object({
17587
18562
  * differently — a quiet camera that looks identical to a dead one is the
17588
18563
  * silence-reads-as-never-happened trap this repo keeps paying for.
17589
18564
  *
17590
- * Empty when nothing is off. Never contains a switch no provider offers.
18565
+ * Empty when nothing is off, and never contains a switch no provider offers
18566
+ * — but an empty list is only a POSITIVE claim when `degraded` does not name
18567
+ * `'switches'`. When it does, the switch set could not be read and nothing
18568
+ * here may be rendered as "the operator turned nothing off": that is the
18569
+ * D62 failure (a camera we could not read painted as broken) in the very
18570
+ * field that exists to prevent it.
17591
18571
  */
17592
18572
  switchedOff: array(CameraSwitchIdSchema).readonly(),
18573
+ /**
18574
+ * Stages of the bounded fan-out that were CUT SHORT — a timeout or a
18575
+ * rejection — and whose block is therefore `null` because we could not
18576
+ * READ it, not because there is nothing there.
18577
+ *
18578
+ * Without this, three different facts arrive as the same `null`: "the stage
18579
+ * timed out", "the stage failed", and "this camera legitimately has no
18580
+ * decoder / no recording". Every surface that draws a conclusion from a null
18581
+ * block (or from an empty `switchedOff`) must consult this first; a stage
18582
+ * named here supports no conclusion at all, only "unknown".
18583
+ *
18584
+ * Empty on a clean read — the overwhelmingly common case.
18585
+ */
18586
+ degraded: array(CameraStatusDegradationSchema).readonly(),
17593
18587
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
17594
18588
  fetchedAt: number()
17595
18589
  });
@@ -18399,6 +19393,37 @@ DeviceType.Camera, method(object({
18399
19393
  lastCapturedAt: number().nullable(),
18400
19394
  cacheAgeMs: number().nullable(),
18401
19395
  etag: string().nullable()
19396
+ }))), systemMethod(object({
19397
+ /** The tiles a surface is actually rendering. One entry per (device,
19398
+ * width) the caller will paint — the width is snapped to the server's
19399
+ * ladder and becomes part of the link's SIGNED identity. */
19400
+ targets: array(object({
19401
+ deviceId: number(),
19402
+ /** Target width in px. Omit for the frame as captured — correct
19403
+ * for a full-bleed surface, wrong (and expensive) for a grid. */
19404
+ width: number().int().positive().optional()
19405
+ })).min(1).max(200) }), array(object({
19406
+ deviceId: number(),
19407
+ /** Root-relative signed path, or null when the link plane is not
19408
+ * served (no data-plane facility). Present even for a device that has
19409
+ * never captured — the request is what triggers the first one (D94). */
19410
+ url: string().nullable(),
19411
+ /** Epoch ms of the frame this link serves. Null = never captured.
19412
+ * THE honest age: the tRPC path carried none before this. */
19413
+ capturedAt: number().nullable(),
19414
+ /** Age of that frame at the moment the answer was built. */
19415
+ ageMs: number().nullable(),
19416
+ /** Epoch ms after which `url` stops verifying. */
19417
+ expiresAt: number().nullable(),
19418
+ /** Ladder rung the bytes are at; null = the frame as captured. */
19419
+ width: number().nullable(),
19420
+ /** The device has never produced a frame. An empty state, not a
19421
+ * failure — and never a reason to withhold the link (D94). */
19422
+ neverCaptured: boolean(),
19423
+ /** A sleeping battery camera: the frame is deliberately stale and will
19424
+ * NOT refresh in the background. A surface should say so rather than
19425
+ * present it as current. */
19426
+ sleeping: boolean()
18402
19427
  })));
18403
19428
  /**
18404
19429
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
@@ -20149,6 +21174,37 @@ DeviceType.Light, method(object({
20149
21174
  mireds: number().int().optional(),
20150
21175
  lastChangedAt: number()
20151
21176
  });
21177
+ var ConnectionTestOutcomeSchema = discriminatedUnion("outcome", [
21178
+ object({
21179
+ outcome: literal("validated"),
21180
+ /** Round-trip of the sign-in, when the provider measured it. */
21181
+ latencyMs: number().nonnegative().optional(),
21182
+ /** Optional human detail worth showing next to the tick
21183
+ * ("3 devices visible on this account"). */
21184
+ detail: string().optional()
21185
+ }).strict(),
21186
+ object({
21187
+ outcome: literal("rejected"),
21188
+ error: string()
21189
+ }).strict(),
21190
+ object({
21191
+ outcome: literal("inconclusive"),
21192
+ error: string()
21193
+ }).strict()
21194
+ ]);
21195
+ var ConnectionTestInputSchema = object({
21196
+ /** Candidate integration settings, exactly as the create form collected them. */
21197
+ settings: record(string(), unknown()) });
21198
+ /**
21199
+ * What the provider's test actually DOES, so the UI can say it in words before
21200
+ * the operator presses the button ("Signs in to the Dreo cloud"). Purely
21201
+ * descriptive — it never changes routing.
21202
+ */
21203
+ var ConnectionTestDescriptorSchema = object({ label: string() });
21204
+ method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
21205
+ kind: "mutation",
21206
+ auth: "admin"
21207
+ }), method(_void(), ConnectionTestDescriptorSchema, { auth: "admin" });
20152
21208
  object({
20153
21209
  /** True when the upstream system considers the entity connected. */
20154
21210
  connected: boolean(),
@@ -21050,15 +22106,57 @@ var AvailableIntegrationTypeSchema = object({
21050
22106
  * flow can import (e.g. HA areas). Drives the adopt modal's "import
21051
22107
  * locations" checkbox. Provider-declared in the addon manifest. */
21052
22108
  supportsLocationImport: boolean(),
22109
+ /**
22110
+ * True when this integration DECLARES a pre-creation test (the
22111
+ * `connection-test` cap, or a broker whose settings it stores). Drives the
22112
+ * Test button: an integration that cannot be tested must say so up front
22113
+ * rather than offering a button that always answers the same nonsense.
22114
+ */
22115
+ canTest: boolean(),
21053
22116
  existingInstances: array(object({
21054
22117
  id: string(),
21055
22118
  name: string()
21056
22119
  })),
21057
22120
  canAdd: boolean()
21058
22121
  });
22122
+ /**
22123
+ * Why a test could not be answered as a plain boolean.
22124
+ *
22125
+ * `success` alone collapsed four different situations into one red box, and the
22126
+ * one that mattered most — "nobody ever asked the remote anything" — looked
22127
+ * exactly like "the remote said no". The status is the discriminator:
22128
+ *
22129
+ * - `validated` — a provider-declared test ran and the remote ACCEPTED.
22130
+ * - `rejected` — a provider-declared test ran and the remote REFUSED.
22131
+ * The only status that blocks `integrations.create`.
22132
+ * - `inconclusive` — a test IS declared but could not complete (timeout,
22133
+ * DNS, 5xx). Nothing was observed; not a failure.
22134
+ * - `unsupported` — this integration declares NO test. Nothing was
22135
+ * observed either; not a failure, and not a pass.
22136
+ *
22137
+ * `unsupported` and `inconclusive` both carry `success: false` so an older
22138
+ * client can never read them as a green tick, and both carry an `error` string
22139
+ * that SAYS the test did not run rather than inventing a failure.
22140
+ */
22141
+ var TestConnectionStatusEnum = _enum([
22142
+ "validated",
22143
+ "rejected",
22144
+ "inconclusive",
22145
+ "unsupported"
22146
+ ]);
21059
22147
  var TestConnectionResultSchema$1 = object({
22148
+ /** True ONLY for `validated`. Never true for a test that did not run. */
21060
22149
  success: boolean(),
21061
- error: string().optional()
22150
+ error: string().optional(),
22151
+ /** Optional for wire back-compat with clients built before the tri-state;
22152
+ * the server always sets it. */
22153
+ status: TestConnectionStatusEnum.optional(),
22154
+ /** Addon id whose declared test answered — `null` when none did. Lets the UI
22155
+ * attribute a result instead of blaming "the integration". */
22156
+ testedBy: string().nullable().optional(),
22157
+ latencyMs: number().nonnegative().optional(),
22158
+ /** Human detail from a `validated` result ("3 devices on this account"). */
22159
+ detail: string().optional()
21062
22160
  });
21063
22161
  var CreateIntegrationInputSchema = object({
21064
22162
  addonId: string(),
@@ -24059,1182 +25157,553 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
24059
25157
  }), method(object({
24060
25158
  username: string(),
24061
25159
  password: string()
24062
- }), UserSummarySchema.extend({ passwordHash: string() }).nullable(), {
24063
- kind: "mutation",
24064
- access: "view"
24065
- }), method(_void(), array(ApiKeySummarySchema), { auth: "admin" }), method(CreateApiKeyInputSchema, CreateApiKeyResultSchema, {
24066
- kind: "mutation",
24067
- auth: "admin",
24068
- access: "create"
24069
- }), method(object({ id: string() }), object({ success: literal(true) }), {
24070
- kind: "mutation",
24071
- auth: "admin",
24072
- access: "delete"
24073
- }), method(object({ token: string() }), ApiKeySummarySchema.nullable(), {
24074
- kind: "mutation",
24075
- access: "view"
24076
- }), method(CreateScopedTokenInputSchema, CreateScopedTokenResultSchema, {
24077
- kind: "mutation",
24078
- auth: "admin",
24079
- access: "create"
24080
- }), method(object({ id: string() }), object({ success: literal(true) }), {
24081
- kind: "mutation",
24082
- auth: "admin",
24083
- access: "delete"
24084
- }), method(object({ token: string() }), ScopedTokenSummarySchema.nullable(), { access: "view" }), method(object({ userId: string() }), array(ScopedTokenSummarySchema), { auth: "admin" }), method(object({ userId: string() }), TotpSetupResultSchema, {
24085
- kind: "mutation",
24086
- auth: "admin",
24087
- access: "create"
24088
- }), method(object({
24089
- userId: string(),
24090
- code: string()
24091
- }), object({ success: literal(true) }), {
24092
- kind: "mutation",
24093
- auth: "admin",
24094
- access: "create"
24095
- }), method(object({ userId: string() }), object({ success: literal(true) }), {
24096
- kind: "mutation",
24097
- auth: "admin",
24098
- access: "delete"
24099
- }), method(object({ userId: string() }), TotpStatusSchema, { auth: "admin" }), method(object({
24100
- userId: string(),
24101
- code: string()
24102
- }), object({ valid: boolean() }), {
24103
- kind: "mutation",
24104
- access: "view"
24105
- }), method(object({
24106
- integrationId: string(),
24107
- userId: string(),
24108
- username: string(),
24109
- scopes: array(TokenScopeSchema),
24110
- redirectUri: string(),
24111
- hubUrl: string(),
24112
- /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
24113
- * that carries one can ONLY be exchanged with the matching verifier. */
24114
- codeChallenge: string().optional()
24115
- }), object({ code: string() }), {
24116
- kind: "mutation",
24117
- access: "create"
24118
- }), method(object({
24119
- code: string(),
24120
- redirectUri: string(),
24121
- /** PKCE verifier. REQUIRED when the code carries a challenge. */
24122
- codeVerifier: string().optional()
24123
- }), object({
24124
- accessToken: string(),
24125
- refreshToken: string(),
24126
- expiresIn: number()
24127
- }).nullable(), {
24128
- kind: "mutation",
24129
- access: "view"
24130
- }), method(object({ refreshToken: string() }), object({
24131
- accessToken: string(),
24132
- refreshToken: string(),
24133
- expiresIn: number()
24134
- }).nullable(), {
24135
- kind: "mutation",
24136
- access: "view"
24137
- }), method(object({ token: string() }), object({
24138
- userId: string(),
24139
- username: string(),
24140
- scopes: array(TokenScopeSchema)
24141
- }).nullable(), { access: "view" }), method(_void(), array(OauthSessionSummarySchema), { auth: "admin" }), method(object({ id: string() }), object({ success: boolean() }), {
24142
- kind: "mutation",
24143
- auth: "admin",
24144
- access: "delete"
24145
- });
24146
- /**
24147
- * Robot-vacuum cap. Models HA `vacuum.*` entities — anything with a
24148
- * cleaning lifecycle plus a return-to-base / locate surface and an
24149
- * optional fan-speed selector.
24150
- *
24151
- * State follows HA's canonical vacuum lifecycle: `idle` / `cleaning` /
24152
- * `paused` / `returning` / `docked` / `error`. `batteryLevel`
24153
- * (0..100) is nullable — some vacuums don't report a battery
24154
- * percentage. `fanSpeed` is the current speed token (provider-verbatim,
24155
- * e.g. `'standard'` / `'turbo'`) and `availableFanSpeeds` lists the
24156
- * tokens the hardware accepts so the UI renders only supported choices.
24157
- *
24158
- * The `setFanSpeed` method takes the bare `speed` token — the provider
24159
- * validates it against the vacuum's own list. `locate` triggers the
24160
- * find-me chirp; `returnToBase` sends it home.
24161
- *
24162
- * Consumable / waste tanks: `cleanWater` / `dirtyWater` / `detergent` /
24163
- * `dustBin` each carry a nullable `{ level (0..100 %), status ('ok' |
24164
- * 'low' | 'full') }` reading. Native providers (e.g. Dreame, Roborock)
24165
- * SHOULD populate whichever tanks the hardware has — leave a field `null`
24166
- * only when the device has no such tank at all, and use a `TankStatus`
24167
- * with both inner fields `null` when the tank exists but its level is
24168
- * currently unknown. HA `vacuum.*` entities expose no per-tank telemetry,
24169
- * so the HA provider leaves all four `null`.
24170
- */
24171
- var VacuumStateSchema = _enum([
24172
- "idle",
24173
- "cleaning",
24174
- "paused",
24175
- "returning",
24176
- "docked",
24177
- "drying",
24178
- "error"
24179
- ]);
24180
- /**
24181
- * One consumable / waste tank on a robot vacuum (clean-water, dirty-water,
24182
- * detergent or dust-bin). A tank can report a numeric fill `level` (0..100),
24183
- * a discrete `status` (binary-style hardware), or both. Both `null` means the
24184
- * level is currently unknown; the OWNING field being `null` means the
24185
- * hardware has no such tank at all.
24186
- */
24187
- var TankStatusSchema = object({
24188
- /** Numeric fill 0..100 when the hardware reports a percentage; null otherwise. */
24189
- level: number().min(0).max(100).nullable(),
24190
- /** Discrete state when the hardware is binary-mode; null otherwise. */
24191
- status: _enum([
24192
- "ok",
24193
- "low",
24194
- "full"
24195
- ]).nullable()
24196
- });
24197
- object({
24198
- /** Lifecycle state of the vacuum. */
24199
- state: VacuumStateSchema,
24200
- /** 0..100 battery percentage. Null when the device has no battery
24201
- * reading. */
24202
- batteryLevel: number().min(0).max(100).nullable(),
24203
- /** Current fan-speed token (provider-verbatim). Null when unknown or
24204
- * the vacuum has no speed control. */
24205
- fanSpeed: string().nullable(),
24206
- /** Speed tokens the hardware accepts — drives the UI selector. */
24207
- availableFanSpeeds: array(string()),
24208
- /** Clean-water (mop) tank. Null when the hardware has no clean-water tank. */
24209
- cleanWater: TankStatusSchema.nullable(),
24210
- /** Dirty-water (recovery) tank. Null when the hardware has no dirty-water tank. */
24211
- dirtyWater: TankStatusSchema.nullable(),
24212
- /** Detergent tank. Null when the hardware has no detergent tank. */
24213
- detergent: TankStatusSchema.nullable(),
24214
- /** Dust bin. Null when the hardware has no dust bin. */
24215
- dustBin: TankStatusSchema.nullable(),
24216
- /** 0..100 cleaning-completion percentage of the current task, or null. */
24217
- progressPercent: number().min(0).max(100).nullable(),
24218
- /** Current error code (0 / null = no error). */
24219
- errorCode: number().nullable(),
24220
- /** Human label for {@link errorCode}, or null when none / undecodable. */
24221
- errorLabel: string().nullable(),
24222
- /** Ms epoch when the slice was last updated. */
24223
- lastChangedAt: number()
24224
- });
24225
- DeviceType.Vacuum, method(object({ deviceId: number().int().nonnegative() }), _void(), {
24226
- kind: "mutation",
24227
- auth: "admin"
24228
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
24229
- kind: "mutation",
24230
- auth: "admin"
24231
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
24232
- kind: "mutation",
24233
- auth: "admin"
24234
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
24235
- kind: "mutation",
24236
- auth: "admin"
24237
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
24238
- kind: "mutation",
24239
- auth: "admin"
24240
- }), method(object({
24241
- deviceId: number().int().nonnegative(),
24242
- speed: string().min(1)
24243
- }), _void(), {
24244
- kind: "mutation",
24245
- auth: "admin"
24246
- });
24247
- object({
24248
- /** Lifecycle state of the valve. */
24249
- state: _enum([
24250
- "open",
24251
- "opening",
24252
- "closing",
24253
- "closed",
24254
- "stopped"
24255
- ]),
24256
- /** 0 = fully closed, 100 = fully open. Null when the device has no
24257
- * intermediate position surface. */
24258
- position: number().min(0).max(100).nullable(),
24259
- /** Ms epoch when the slice was last updated. */
24260
- lastChangedAt: number()
24261
- });
24262
- DeviceType.Valve, method(object({ deviceId: number().int().nonnegative() }), _void(), {
25160
+ }), UserSummarySchema.extend({ passwordHash: string() }).nullable(), {
24263
25161
  kind: "mutation",
24264
- auth: "admin"
24265
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
25162
+ access: "view"
25163
+ }), method(_void(), array(ApiKeySummarySchema), { auth: "admin" }), method(CreateApiKeyInputSchema, CreateApiKeyResultSchema, {
24266
25164
  kind: "mutation",
24267
- auth: "admin"
24268
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
25165
+ auth: "admin",
25166
+ access: "create"
25167
+ }), method(object({ id: string() }), object({ success: literal(true) }), {
24269
25168
  kind: "mutation",
24270
- auth: "admin"
24271
- }), method(object({
24272
- deviceId: number().int().nonnegative(),
24273
- position: number().min(0).max(100)
24274
- }), _void(), {
25169
+ auth: "admin",
25170
+ access: "delete"
25171
+ }), method(object({ token: string() }), ApiKeySummarySchema.nullable(), {
24275
25172
  kind: "mutation",
24276
- auth: "admin"
24277
- });
24278
- object({
24279
- detected: boolean(),
24280
- /** Ms epoch of the last transition. 0 if never observed. */
24281
- lastChangedAt: number()
24282
- });
24283
- DeviceType.Sensor;
24284
- object({
24285
- /** Current measured temperature. Null when not reported. */
24286
- currentTemp: number().nullable(),
24287
- /** Target temperature setpoint. Null when no setpoint surface. */
24288
- targetTemp: number().nullable(),
24289
- /** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
24290
- * `heat_pump` / `high_demand` / `performance` / `off`). Null when the
24291
- * device reports an unknown state. */
24292
- operationMode: string().nullable(),
24293
- /** Available operation modes = HA `operation_list`. */
24294
- availableModes: array(string()),
24295
- /** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
24296
- * has no away surface. */
24297
- away: boolean().nullable(),
24298
- /** HA `min_temp` attribute. Null when not reported. */
24299
- minTemp: number().nullable(),
24300
- /** HA `max_temp` attribute. Null when not reported. */
24301
- maxTemp: number().nullable(),
24302
- /** Ms epoch when the slice was last updated. */
24303
- lastChangedAt: number()
24304
- });
24305
- DeviceType.WaterHeater, method(object({
24306
- deviceId: number().int().nonnegative(),
24307
- temp: number().finite()
24308
- }), _void(), {
25173
+ access: "view"
25174
+ }), method(CreateScopedTokenInputSchema, CreateScopedTokenResultSchema, {
24309
25175
  kind: "mutation",
24310
- auth: "admin"
25176
+ auth: "admin",
25177
+ access: "create"
25178
+ }), method(object({ id: string() }), object({ success: literal(true) }), {
25179
+ kind: "mutation",
25180
+ auth: "admin",
25181
+ access: "delete"
25182
+ }), method(object({ token: string() }), ScopedTokenSummarySchema.nullable(), { access: "view" }), method(object({ userId: string() }), array(ScopedTokenSummarySchema), { auth: "admin" }), method(object({ userId: string() }), TotpSetupResultSchema, {
25183
+ kind: "mutation",
25184
+ auth: "admin",
25185
+ access: "create"
24311
25186
  }), method(object({
24312
- deviceId: number().int().nonnegative(),
24313
- mode: string().min(1)
24314
- }), _void(), {
25187
+ userId: string(),
25188
+ code: string()
25189
+ }), object({ success: literal(true) }), {
24315
25190
  kind: "mutation",
24316
- auth: "admin"
25191
+ auth: "admin",
25192
+ access: "create"
25193
+ }), method(object({ userId: string() }), object({ success: literal(true) }), {
25194
+ kind: "mutation",
25195
+ auth: "admin",
25196
+ access: "delete"
25197
+ }), method(object({ userId: string() }), TotpStatusSchema, { auth: "admin" }), method(object({
25198
+ userId: string(),
25199
+ code: string()
25200
+ }), object({ valid: boolean() }), {
25201
+ kind: "mutation",
25202
+ access: "view"
24317
25203
  }), method(object({
24318
- deviceId: number().int().nonnegative(),
24319
- on: boolean()
24320
- }), _void(), {
25204
+ integrationId: string(),
25205
+ userId: string(),
25206
+ username: string(),
25207
+ scopes: array(TokenScopeSchema),
25208
+ redirectUri: string(),
25209
+ hubUrl: string(),
25210
+ /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
25211
+ * that carries one can ONLY be exchanged with the matching verifier. */
25212
+ codeChallenge: string().optional()
25213
+ }), object({ code: string() }), {
24321
25214
  kind: "mutation",
24322
- auth: "admin"
24323
- });
24324
- object({
24325
- /** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
24326
- * when no condition has been reported yet. */
24327
- condition: string().nullable(),
24328
- /** Current temperature in the reported unit. Null when not provided. */
24329
- temperature: number().nullable(),
24330
- /** Temperature unit string (e.g. `°C` / `°F`). Null when not provided. */
24331
- temperatureUnit: string().nullable(),
24332
- /** Relative humidity (0..100). Null when not provided. */
24333
- humidity: number().min(0).max(100).nullable(),
24334
- /** Barometric pressure in the reported unit. Null when not provided. */
24335
- pressure: number().nullable(),
24336
- /** Pressure unit string (e.g. `hPa` / `inHg`). Null when not provided. */
24337
- pressureUnit: string().nullable(),
24338
- /** Wind speed in the reported unit. Null when not provided. */
24339
- windSpeed: number().nullable(),
24340
- /** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
24341
- windSpeedUnit: string().nullable(),
24342
- /** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
24343
- windBearing: number().nullable(),
24344
- /** Ms epoch when the slice was last updated. */
24345
- lastFetchedAt: number()
24346
- });
24347
- DeviceType.Weather;
24348
- /**
24349
- * Per-zone occupancy aggregation produced by the analytics frame
24350
- * processor on every inference result. Covers the full combinatorial
24351
- * matrix the operator UI needs: total objects everywhere, total
24352
- * objects per zone, single class everywhere, single class per zone,
24353
- * objects outside any zone.
24354
- *
24355
- * Counts are derived from the analytics tracker (tracked detections
24356
- * with stable trackIds), not raw detector hits — this filters out
24357
- * one-off detector flickers and gives counts that match what the user
24358
- * sees on the live overlay.
24359
- *
24360
- * Overlap policy: a detection that intersects two zones counts in
24361
- * BOTH zones' `byClass` and `totalObjects` (count-in-each). The
24362
- * `frame` aggregate de-duplicates trivially since it's frame-wide.
24363
- * `unzoned` counts only detections that landed in zero zones.
24364
- */
24365
- var PerScopeBreakdownSchema = object({
24366
- /** Total tracked objects in this scope (frame / zone / unzoned). */
24367
- totalObjects: number().int().nonnegative(),
24368
- /** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
24369
- byClass: record(string(), number().int().nonnegative())
24370
- });
24371
- var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
24372
- zoneId: string(),
24373
- zoneName: string(),
24374
- /** TrackIds of objects currently inside this zone — for cross-reference
24375
- * with the per-track detail panel and live overlay. */
24376
- trackIds: array(string()).readonly()
24377
- });
24378
- /**
24379
- * A parked ("stationary") object surfaced alongside occupancy — an object that
24380
- * settled and stopped moving. It is NO LONGER a tracked object (the tracker was
24381
- * told to forget it so it stops re-spawning tracks/events), but it IS still
24382
- * physically present, so it keeps counting toward `frame` occupancy and is
24383
- * listed here so the UI can show it in a dedicated "Stationary" section instead
24384
- * of flooding the live event feed.
24385
- */
24386
- var StationaryObjectSchema = object({
24387
- id: string(),
24388
- className: string(),
24389
- bbox: object({
24390
- x: number(),
24391
- y: number(),
24392
- w: number(),
24393
- h: number()
24394
- }),
24395
- frameWidth: number().int().nonnegative(),
24396
- frameHeight: number().int().nonnegative(),
24397
- /** When the source track was first seen. */
24398
- firstSeenAt: number().int(),
24399
- /** When the object was recognised as parked (promotion time). */
24400
- becameStationaryAt: number().int(),
24401
- /** Last frame a detection confirmed the object is still there. */
24402
- lastConfirmedAt: number().int(),
24403
- /** Enrichment label carried from the source track (identity / plate). */
24404
- label: string().optional(),
24405
- /** Native-resolution key-frame media key for the parked object's best image. */
24406
- keyFrameMediaKey: string().optional()
24407
- });
24408
- var CameraOccupancySnapshotSchema = object({
24409
- /** Frame timestamp of the inference result that produced this snapshot. */
24410
- ts: number().int(),
24411
- /** Frame width/height in pixels — let the UI normalize bbox coords. */
24412
- frameWidth: number().int().nonnegative(),
24413
- frameHeight: number().int().nonnegative(),
24414
- /** Per-zone breakdown — one entry per defined zone (user + onboard). */
24415
- zones: array(ZoneScopeBreakdownSchema).readonly(),
24416
- /** Frame-wide aggregate (everywhere, regardless of zone membership).
24417
- * INCLUDES currently-confirmed stationary objects (they are still present). */
24418
- frame: PerScopeBreakdownSchema,
24419
- /** Detections that landed outside every zone. Empty when no zones defined. */
24420
- unzoned: PerScopeBreakdownSchema,
24421
- /** Parked objects on this camera (additive — absent on legacy snapshots).
24422
- * Surfaced separately so the UI shows them in a dedicated section rather
24423
- * than as repeated tracks/events. */
24424
- stationaryObjects: array(StationaryObjectSchema).readonly().optional()
24425
- });
24426
- /**
24427
- * Time-series resolution. The history methods return one bucket per
24428
- * step over the requested range. Smaller resolutions cost more
24429
- * memory + bandwidth; bound to discrete steps so caller cannot ask
24430
- * for arbitrary fractional buckets.
24431
- */
24432
- var HistoryResolutionEnum = _enum([
24433
- "minute",
24434
- "5min",
24435
- "hour"
24436
- ]);
24437
- var HistoryRangeSchema = object({
24438
- /** Range start (epoch ms, inclusive). */
24439
- from: number().int(),
24440
- /** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
24441
- to: number().int(),
24442
- resolution: HistoryResolutionEnum
24443
- });
24444
- var HistoryPointSchema = object({
24445
- /** Bucket midpoint (epoch ms). */
24446
- ts: number().int(),
24447
- /** Object count averaged over the bucket (rounded to nearest integer). */
24448
- count: number().int().nonnegative()
24449
- });
24450
- DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({
24451
- deviceId: number(),
24452
- zoneId: string(),
24453
- className: string().optional()
24454
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
24455
- deviceId: number(),
24456
- className: string().optional()
24457
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
24458
- deviceId: number(),
24459
- className: string().optional()
24460
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
24461
- /**
24462
- * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
24463
- * cap so a single CRUD surface backs every consumer; each stage has
24464
- * its own dev-state mirror slice (`motion-zone-rules`,
24465
- * `detection-zone-rules`, …) so consumer addons subscribe independently.
24466
- *
24467
- * Extend the enum here when a new gating consumer comes online (audio
24468
- * gating, alert filtering, …) — no other surface needs to change.
24469
- */
24470
- var ZoneRuleStageEnum = _enum([
24471
- "motion",
24472
- "detection",
24473
- "package"
24474
- ]);
25215
+ access: "create"
25216
+ }), method(object({
25217
+ code: string(),
25218
+ redirectUri: string(),
25219
+ /** PKCE verifier. REQUIRED when the code carries a challenge. */
25220
+ codeVerifier: string().optional()
25221
+ }), object({
25222
+ accessToken: string(),
25223
+ refreshToken: string(),
25224
+ expiresIn: number()
25225
+ }).nullable(), {
25226
+ kind: "mutation",
25227
+ access: "view"
25228
+ }), method(object({ refreshToken: string() }), object({
25229
+ accessToken: string(),
25230
+ refreshToken: string(),
25231
+ expiresIn: number()
25232
+ }).nullable(), {
25233
+ kind: "mutation",
25234
+ access: "view"
25235
+ }), method(object({ token: string() }), object({
25236
+ userId: string(),
25237
+ username: string(),
25238
+ scopes: array(TokenScopeSchema)
25239
+ }).nullable(), { access: "view" }), method(_void(), array(OauthSessionSummarySchema), { auth: "admin" }), method(object({ id: string() }), object({ success: boolean() }), {
25240
+ kind: "mutation",
25241
+ auth: "admin",
25242
+ access: "delete"
25243
+ });
24475
25244
  /**
24476
- * Zone rules capability — per-camera CRUD over the {@link ZoneRule}
24477
- * arrays that decide how each pipeline stage uses the polygon zones.
24478
- *
24479
- * Hosted by `addon-pipeline-orchestrator` alongside the zones provider
24480
- * so the operator has a single hub-side source of truth for both
24481
- * geometry and behaviour. Per-stage rules are stored under the
24482
- * `zoneRules.<stage>` key in the orchestrator's per-device store and
24483
- * mirrored to the device-state slice `<stage>-zone-rules` on every
24484
- * mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
24485
- * subscribe to that slice and refresh their gating without
24486
- * round-tripping the cap.
25245
+ * Robot-vacuum cap. Models HA `vacuum.*` entities — anything with a
25246
+ * cleaning lifecycle plus a return-to-base / locate surface and an
25247
+ * optional fan-speed selector.
24487
25248
  *
24488
- * Sets are bulk-replace — the operator UI sends the new rule list
24489
- * wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
24490
- * naturally in the rule editor without per-rule mutation chatter.
24491
- */
24492
- var zoneRulesCapability = {
24493
- name: "zone-rules",
24494
- scope: "device",
24495
- mode: "singleton",
24496
- deviceTypes: [DeviceType.Camera],
24497
- methods: {
24498
- /** Read the full rule list for a given stage (empty when no rules
24499
- * are defined yet). */
24500
- listRules: method(object({
24501
- deviceId: number(),
24502
- stage: ZoneRuleStageEnum
24503
- }), array(ZoneRuleSchema).readonly()),
24504
- /** Bulk-replace the rule list for one stage. The provider validates
24505
- * each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
24506
- * thresholds in range) and rejects the whole patch if any entry
24507
- * is invalid — partial writes are a configuration footgun. */
24508
- setRules: method(object({
24509
- deviceId: number(),
24510
- stage: ZoneRuleStageEnum,
24511
- rules: array(ZoneRuleSchema).readonly()
24512
- }), _void(), {
24513
- kind: "mutation",
24514
- auth: "admin"
24515
- })
24516
- },
24517
- /**
24518
- * Runtime-state slice — every stage mirrored together so consumers
24519
- * see one reactive handle (`device.state.zoneRules.value`) instead
24520
- * of one per stage. Bulk-replace mutations on any stage write the full
24521
- * `{motion, detection, package}` shape, so subscribers always get the
24522
- * complete current set. Consumers that only care about one stage
24523
- * just read the matching property.
24524
- *
24525
- * `package` backs the package-drop detector — a package zone is a
24526
- * `ZoneRule` on the `'package'` stage referencing drawn polygons
24527
- * (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
24528
- * The orchestrator provider writes this stage as a first-class slice
24529
- * (Phase 4): every mutation mirrors the full `{motion, detection,
24530
- * package}` shape, so consumers read the current package rules directly
24531
- * off `device.state.zoneRules.value.package`.
24532
- */
24533
- runtimeState: object({
24534
- motion: array(ZoneRuleSchema).readonly(),
24535
- detection: array(ZoneRuleSchema).readonly(),
24536
- package: array(ZoneRuleSchema).readonly()
24537
- })
24538
- };
24539
- /**
24540
- * Accessory device helpers — shared across drivers.
25249
+ * State follows HA's canonical vacuum lifecycle: `idle` / `cleaning` /
25250
+ * `paused` / `returning` / `docked` / `error`. `batteryLevel`
25251
+ * (0..100) is nullable — some vacuums don't report a battery
25252
+ * percentage. `fanSpeed` is the current speed token (provider-verbatim,
25253
+ * e.g. `'standard'` / `'turbo'`) and `availableFanSpeeds` lists the
25254
+ * tokens the hardware accepts so the UI renders only supported choices.
24541
25255
  *
24542
- * Many vendor-specific drivers register accessory child devices on
24543
- * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
24544
- * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
24545
- * driver picks the right `DeviceType` + `DeviceRole` explicitly when
24546
- * spawning, builds a name derived from the parent, and produces a
24547
- * stableId tied to the parent so boot-restore can reconstruct the
24548
- * relationship.
25256
+ * The `setFanSpeed` method takes the bare `speed` token — the provider
25257
+ * validates it against the vacuum's own list. `locate` triggers the
25258
+ * find-me chirp; `returnToBase` sends it home.
24549
25259
  *
24550
- * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
24551
- * drivers may reasonably disagree on the right type for an accessory
24552
- * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
24553
- * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
24554
- * one canonical mapping was over-prescriptive and added a layer of
24555
- * indirection without saving meaningful code at call sites — the
24556
- * driver knows its own hardware best.
24557
- */
24558
- /**
24559
- * Subset of `DeviceRole` values that drivers register as child
24560
- * accessories of a parent device. Sourced verbatim from `DeviceRole`
24561
- * — `AccessoryKind` is the alias drivers use when building accessory
24562
- * children, so the call site reads as
24563
- * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
24564
- * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
24565
- * any role works, including non-accessory ones like Doorbell).
24566
- */
24567
- var AccessoryKind = {
24568
- Siren: DeviceRole.Siren,
24569
- Floodlight: DeviceRole.Floodlight,
24570
- Spotlight: DeviceRole.Spotlight,
24571
- PirSensor: DeviceRole.PirSensor,
24572
- Chime: DeviceRole.Chime,
24573
- Autotrack: DeviceRole.Autotrack,
24574
- Nightvision: DeviceRole.Nightvision,
24575
- PrivacyMask: DeviceRole.PrivacyMask
24576
- };
24577
- AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
24578
- 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;
24579
- new Set(Object.values(DeviceType));
24580
- /**
24581
- * Heuristic check — `device.features?.includes(profile.when.hasFeature)`.
24582
- */
24583
- function deviceMatchesProfile(features, profile) {
24584
- if (!features || features.length === 0) return false;
24585
- return features.includes(profile.when.hasFeature);
24586
- }
24587
- /**
24588
- * Profile registry — order matters when multiple profiles match the
24589
- * same device (first match wins). Today there's only one entry.
24590
- */
24591
- var DEVICE_PROFILES = [{
24592
- id: "battery",
24593
- label: "Battery-operated camera",
24594
- when: { hasFeature: DeviceFeature.BatteryOperated },
24595
- defaults: {
24596
- audioMode: "disabled",
24597
- detectionMode: "on-motion"
24598
- },
24599
- settings: {}
24600
- }];
24601
- /**
24602
- * Resolve the profile that matches a device's features, or `null` when
24603
- * no profile matches. First-match-wins.
25260
+ * Consumable / waste tanks: `cleanWater` / `dirtyWater` / `detergent` /
25261
+ * `dustBin` each carry a nullable `{ level (0..100 %), status ('ok' |
25262
+ * 'low' | 'full') }` reading. Native providers (e.g. Dreame, Roborock)
25263
+ * SHOULD populate whichever tanks the hardware has — leave a field `null`
25264
+ * only when the device has no such tank at all, and use a `TankStatus`
25265
+ * with both inner fields `null` when the tank exists but its level is
25266
+ * currently unknown. HA `vacuum.*` entities expose no per-tank telemetry,
25267
+ * so the HA provider leaves all four `null`.
24604
25268
  */
24605
- function resolveDeviceProfile(features) {
24606
- for (const profile of DEVICE_PROFILES) if (deviceMatchesProfile(features, profile)) return profile;
24607
- return null;
24608
- }
25269
+ var VacuumStateSchema = _enum([
25270
+ "idle",
25271
+ "cleaning",
25272
+ "paused",
25273
+ "returning",
25274
+ "docked",
25275
+ "drying",
25276
+ "error"
25277
+ ]);
24609
25278
  /**
24610
- * Error types for the safe expression engine. Two distinct classes so callers
24611
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
24612
- * failure — both are non-fatal to the host: read paths degrade to "skip link".
25279
+ * One consumable / waste tank on a robot vacuum (clean-water, dirty-water,
25280
+ * detergent or dust-bin). A tank can report a numeric fill `level` (0..100),
25281
+ * a discrete `status` (binary-style hardware), or both. Both `null` means the
25282
+ * level is currently unknown; the OWNING field being `null` means the
25283
+ * hardware has no such tank at all.
24613
25284
  */
24614
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
24615
- * the failure is anchored to a character (author-facing inline feedback). */
24616
- var ExpressionParseError = class extends Error {
24617
- position;
24618
- constructor(message, position) {
24619
- super(message);
24620
- this.name = "ExpressionParseError";
24621
- this.position = position;
24622
- }
24623
- };
24624
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
24625
- * result, unknown builtin, step-budget exceeded). */
24626
- var ExpressionEvalError = class extends Error {
24627
- constructor(message) {
24628
- super(message);
24629
- this.name = "ExpressionEvalError";
24630
- }
24631
- };
25285
+ var TankStatusSchema = object({
25286
+ /** Numeric fill 0..100 when the hardware reports a percentage; null otherwise. */
25287
+ level: number().min(0).max(100).nullable(),
25288
+ /** Discrete state when the hardware is binary-mode; null otherwise. */
25289
+ status: _enum([
25290
+ "ok",
25291
+ "low",
25292
+ "full"
25293
+ ]).nullable()
25294
+ });
25295
+ object({
25296
+ /** Lifecycle state of the vacuum. */
25297
+ state: VacuumStateSchema,
25298
+ /** 0..100 battery percentage. Null when the device has no battery
25299
+ * reading. */
25300
+ batteryLevel: number().min(0).max(100).nullable(),
25301
+ /** Current fan-speed token (provider-verbatim). Null when unknown or
25302
+ * the vacuum has no speed control. */
25303
+ fanSpeed: string().nullable(),
25304
+ /** Speed tokens the hardware accepts — drives the UI selector. */
25305
+ availableFanSpeeds: array(string()),
25306
+ /** Clean-water (mop) tank. Null when the hardware has no clean-water tank. */
25307
+ cleanWater: TankStatusSchema.nullable(),
25308
+ /** Dirty-water (recovery) tank. Null when the hardware has no dirty-water tank. */
25309
+ dirtyWater: TankStatusSchema.nullable(),
25310
+ /** Detergent tank. Null when the hardware has no detergent tank. */
25311
+ detergent: TankStatusSchema.nullable(),
25312
+ /** Dust bin. Null when the hardware has no dust bin. */
25313
+ dustBin: TankStatusSchema.nullable(),
25314
+ /** 0..100 cleaning-completion percentage of the current task, or null. */
25315
+ progressPercent: number().min(0).max(100).nullable(),
25316
+ /** Current error code (0 / null = no error). */
25317
+ errorCode: number().nullable(),
25318
+ /** Human label for {@link errorCode}, or null when none / undecodable. */
25319
+ errorLabel: string().nullable(),
25320
+ /** Ms epoch when the slice was last updated. */
25321
+ lastChangedAt: number()
25322
+ });
25323
+ DeviceType.Vacuum, method(object({ deviceId: number().int().nonnegative() }), _void(), {
25324
+ kind: "mutation",
25325
+ auth: "admin"
25326
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
25327
+ kind: "mutation",
25328
+ auth: "admin"
25329
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
25330
+ kind: "mutation",
25331
+ auth: "admin"
25332
+ }), 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({
25339
+ deviceId: number().int().nonnegative(),
25340
+ speed: string().min(1)
25341
+ }), _void(), {
25342
+ kind: "mutation",
25343
+ auth: "admin"
25344
+ });
25345
+ object({
25346
+ /** Lifecycle state of the valve. */
25347
+ state: _enum([
25348
+ "open",
25349
+ "opening",
25350
+ "closing",
25351
+ "closed",
25352
+ "stopped"
25353
+ ]),
25354
+ /** 0 = fully closed, 100 = fully open. Null when the device has no
25355
+ * intermediate position surface. */
25356
+ position: number().min(0).max(100).nullable(),
25357
+ /** Ms epoch when the slice was last updated. */
25358
+ lastChangedAt: number()
25359
+ });
25360
+ DeviceType.Valve, method(object({ deviceId: number().int().nonnegative() }), _void(), {
25361
+ kind: "mutation",
25362
+ auth: "admin"
25363
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
25364
+ kind: "mutation",
25365
+ auth: "admin"
25366
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
25367
+ kind: "mutation",
25368
+ auth: "admin"
25369
+ }), method(object({
25370
+ deviceId: number().int().nonnegative(),
25371
+ position: number().min(0).max(100)
25372
+ }), _void(), {
25373
+ kind: "mutation",
25374
+ auth: "admin"
25375
+ });
25376
+ object({
25377
+ detected: boolean(),
25378
+ /** Ms epoch of the last transition. 0 if never observed. */
25379
+ lastChangedAt: number()
25380
+ });
25381
+ DeviceType.Sensor;
25382
+ object({
25383
+ /** Current measured temperature. Null when not reported. */
25384
+ currentTemp: number().nullable(),
25385
+ /** Target temperature setpoint. Null when no setpoint surface. */
25386
+ targetTemp: number().nullable(),
25387
+ /** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
25388
+ * `heat_pump` / `high_demand` / `performance` / `off`). Null when the
25389
+ * device reports an unknown state. */
25390
+ operationMode: string().nullable(),
25391
+ /** Available operation modes = HA `operation_list`. */
25392
+ availableModes: array(string()),
25393
+ /** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
25394
+ * has no away surface. */
25395
+ away: boolean().nullable(),
25396
+ /** HA `min_temp` attribute. Null when not reported. */
25397
+ minTemp: number().nullable(),
25398
+ /** HA `max_temp` attribute. Null when not reported. */
25399
+ maxTemp: number().nullable(),
25400
+ /** Ms epoch when the slice was last updated. */
25401
+ lastChangedAt: number()
25402
+ });
25403
+ DeviceType.WaterHeater, method(object({
25404
+ deviceId: number().int().nonnegative(),
25405
+ temp: number().finite()
25406
+ }), _void(), {
25407
+ kind: "mutation",
25408
+ auth: "admin"
25409
+ }), method(object({
25410
+ deviceId: number().int().nonnegative(),
25411
+ mode: string().min(1)
25412
+ }), _void(), {
25413
+ kind: "mutation",
25414
+ auth: "admin"
25415
+ }), method(object({
25416
+ deviceId: number().int().nonnegative(),
25417
+ on: boolean()
25418
+ }), _void(), {
25419
+ kind: "mutation",
25420
+ auth: "admin"
25421
+ });
25422
+ object({
25423
+ /** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
25424
+ * when no condition has been reported yet. */
25425
+ condition: string().nullable(),
25426
+ /** Current temperature in the reported unit. Null when not provided. */
25427
+ temperature: number().nullable(),
25428
+ /** Temperature unit string (e.g. `°C` / `°F`). Null when not provided. */
25429
+ temperatureUnit: string().nullable(),
25430
+ /** Relative humidity (0..100). Null when not provided. */
25431
+ humidity: number().min(0).max(100).nullable(),
25432
+ /** Barometric pressure in the reported unit. Null when not provided. */
25433
+ pressure: number().nullable(),
25434
+ /** Pressure unit string (e.g. `hPa` / `inHg`). Null when not provided. */
25435
+ pressureUnit: string().nullable(),
25436
+ /** Wind speed in the reported unit. Null when not provided. */
25437
+ windSpeed: number().nullable(),
25438
+ /** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
25439
+ windSpeedUnit: string().nullable(),
25440
+ /** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
25441
+ windBearing: number().nullable(),
25442
+ /** Ms epoch when the slice was last updated. */
25443
+ lastFetchedAt: number()
25444
+ });
25445
+ DeviceType.Weather;
24632
25446
  /**
24633
- * Frozen, null-prototype builtin function table for the expression engine
24634
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
24635
- * parser rejects any callee not in it, and the evaluator gates each call on an
24636
- * own-property check against it.
24637
- *
24638
- * Because the object has a NULL prototype AND is `Object.freeze`d:
24639
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
24640
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
24641
- * (there is no `Object.prototype` in the chain), so those names are not
24642
- * callable — they are simply "unknown function" at parse time.
25447
+ * Per-zone occupancy aggregation produced by the analytics frame
25448
+ * processor on every inference result. Covers the full combinatorial
25449
+ * matrix the operator UI needs: total objects everywhere, total
25450
+ * objects per zone, single class everywhere, single class per zone,
25451
+ * objects outside any zone.
24643
25452
  *
24644
- * Every numeric argument is validated as a finite number and every numeric
24645
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
24646
- * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
24647
- * closed rather than emitting a garbage value.
24648
- */
24649
- function asFiniteNumber(value, name, index) {
24650
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
24651
- return value;
24652
- }
24653
- function asString$1(value, name, index) {
24654
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
24655
- return value;
24656
- }
24657
- function finiteResult(value, name) {
24658
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
24659
- return value;
24660
- }
24661
- function allFiniteNumbers(args, name) {
24662
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
24663
- }
24664
- var INF = Number.POSITIVE_INFINITY;
24665
- var table = {
24666
- min: {
24667
- minArgs: 1,
24668
- maxArgs: INF,
24669
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
24670
- },
24671
- max: {
24672
- minArgs: 1,
24673
- maxArgs: INF,
24674
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
24675
- },
24676
- abs: {
24677
- minArgs: 1,
24678
- maxArgs: 1,
24679
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
24680
- },
24681
- floor: {
24682
- minArgs: 1,
24683
- maxArgs: 1,
24684
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
24685
- },
24686
- ceil: {
24687
- minArgs: 1,
24688
- maxArgs: 1,
24689
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
24690
- },
24691
- sqrt: {
24692
- minArgs: 1,
24693
- maxArgs: 1,
24694
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
24695
- },
24696
- round: {
24697
- minArgs: 1,
24698
- maxArgs: 2,
24699
- apply: (args) => {
24700
- const x = asFiniteNumber(args[0], "round", 0);
24701
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
24702
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
24703
- const factor = 10 ** digits;
24704
- return finiteResult(Math.round(x * factor) / factor, "round");
24705
- }
24706
- },
24707
- pow: {
24708
- minArgs: 2,
24709
- maxArgs: 2,
24710
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
24711
- },
24712
- clamp: {
24713
- minArgs: 3,
24714
- maxArgs: 3,
24715
- apply: (args) => {
24716
- const x = asFiniteNumber(args[0], "clamp", 0);
24717
- const lo = asFiniteNumber(args[1], "clamp", 1);
24718
- const hi = asFiniteNumber(args[2], "clamp", 2);
24719
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
24720
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
24721
- }
24722
- },
24723
- avg: {
24724
- minArgs: 1,
24725
- maxArgs: INF,
24726
- apply: (args) => {
24727
- const nums = allFiniteNumbers(args, "avg");
24728
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
24729
- }
24730
- },
24731
- sum: {
24732
- minArgs: 1,
24733
- maxArgs: INF,
24734
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
24735
- },
24736
- coalesce: {
24737
- minArgs: 1,
24738
- maxArgs: INF,
24739
- apply: (args) => {
24740
- for (const a of args) if (a !== null) return a;
24741
- return null;
24742
- }
24743
- },
24744
- age: {
24745
- minArgs: 2,
24746
- maxArgs: 2,
24747
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
24748
- },
24749
- convert: {
24750
- minArgs: 3,
24751
- maxArgs: 3,
24752
- apply: (args, hooks) => {
24753
- const x = asFiniteNumber(args[0], "convert", 0);
24754
- const from = asString$1(args[1], "convert", 1).trim();
24755
- const to = asString$1(args[2], "convert", 2).trim();
24756
- if (hooks.convert) {
24757
- const out = hooks.convert(x, from, to);
24758
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
24759
- return finiteResult(out, "convert");
24760
- }
24761
- if (from === to) return x;
24762
- throw new ExpressionEvalError("convert: unit conversion table not installed");
24763
- }
24764
- }
24765
- };
24766
- Object.freeze(Object.assign(Object.create(null), table));
24767
- /** The set of valid builtin names — used by the parser to reject unknown
24768
- * callees at parse time (immediate author feedback). */
24769
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
24770
- /**
24771
- * Resource-bound constants for the safe expression engine.
25453
+ * Counts are derived from the analytics tracker (tracked detections
25454
+ * with stable trackIds), not raw detector hits — this filters out
25455
+ * one-off detector flickers and gives counts that match what the user
25456
+ * sees on the live overlay.
24772
25457
  *
24773
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
24774
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
24775
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
24776
- * work a single author-supplied expression can request, so a hostile or
24777
- * accidental pathological string can never spend unbounded CPU/memory.
25458
+ * Overlap policy: a detection that intersects two zones counts in
25459
+ * BOTH zones' `byClass` and `totalObjects` (count-in-each). The
25460
+ * `frame` aggregate de-duplicates trivially since it's frame-wide.
25461
+ * `unzoned` counts only detections that landed in zero zones.
24778
25462
  */
24779
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
24780
- * rejected without allocation. */
24781
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
24782
- /** A legal binding / identifier name. */
24783
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
24784
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
24785
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
24786
- var RESERVED_BINDING_NAMES = new Set([
24787
- "now",
24788
- "true",
24789
- "false",
24790
- "null"
24791
- ]);
25463
+ var PerScopeBreakdownSchema = object({
25464
+ /** Total tracked objects in this scope (frame / zone / unzoned). */
25465
+ totalObjects: number().int().nonnegative(),
25466
+ /** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
25467
+ byClass: record(string(), number().int().nonnegative())
25468
+ });
25469
+ var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
25470
+ zoneId: string(),
25471
+ zoneName: string(),
25472
+ /** TrackIds of objects currently inside this zone — for cross-reference
25473
+ * with the per-track detail panel and live overlay. */
25474
+ trackIds: array(string()).readonly()
25475
+ });
24792
25476
  /**
24793
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
24794
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
24795
- * single/double-quoted strings with a tiny escape set, identifiers, the three
24796
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
24797
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
24798
- * is a parse error with a source position, so member access / assignment /
24799
- * template literals are lexically impossible.
25477
+ * A parked ("stationary") object surfaced alongside occupancy — an object that
25478
+ * settled and stopped moving. It is NO LONGER a tracked object (the tracker was
25479
+ * told to forget it so it stops re-spawning tracks/events), but it IS still
25480
+ * physically present, so it keeps counting toward `frame` occupancy and is
25481
+ * listed here so the UI can show it in a dedicated "Stationary" section instead
25482
+ * of flooding the live event feed.
24800
25483
  */
24801
- var KEYWORDS = new Set([
24802
- "true",
24803
- "false",
24804
- "null"
25484
+ var StationaryObjectSchema = object({
25485
+ id: string(),
25486
+ className: string(),
25487
+ bbox: object({
25488
+ x: number(),
25489
+ y: number(),
25490
+ w: number(),
25491
+ h: number()
25492
+ }),
25493
+ frameWidth: number().int().nonnegative(),
25494
+ frameHeight: number().int().nonnegative(),
25495
+ /** When the source track was first seen. */
25496
+ firstSeenAt: number().int(),
25497
+ /** When the object was recognised as parked (promotion time). */
25498
+ becameStationaryAt: number().int(),
25499
+ /** Last frame a detection confirmed the object is still there. */
25500
+ lastConfirmedAt: number().int(),
25501
+ /** Enrichment label carried from the source track (identity / plate). */
25502
+ label: string().optional(),
25503
+ /** Native-resolution key-frame media key for the parked object's best image. */
25504
+ keyFrameMediaKey: string().optional()
25505
+ });
25506
+ var CameraOccupancySnapshotSchema = object({
25507
+ /** Frame timestamp of the inference result that produced this snapshot. */
25508
+ ts: number().int(),
25509
+ /** Frame width/height in pixels — let the UI normalize bbox coords. */
25510
+ frameWidth: number().int().nonnegative(),
25511
+ frameHeight: number().int().nonnegative(),
25512
+ /** Per-zone breakdown — one entry per defined zone (user + onboard). */
25513
+ zones: array(ZoneScopeBreakdownSchema).readonly(),
25514
+ /** Frame-wide aggregate (everywhere, regardless of zone membership).
25515
+ * INCLUDES currently-confirmed stationary objects (they are still present). */
25516
+ frame: PerScopeBreakdownSchema,
25517
+ /** Detections that landed outside every zone. Empty when no zones defined. */
25518
+ unzoned: PerScopeBreakdownSchema,
25519
+ /** Parked objects on this camera (additive — absent on legacy snapshots).
25520
+ * Surfaced separately so the UI shows them in a dedicated section rather
25521
+ * than as repeated tracks/events. */
25522
+ stationaryObjects: array(StationaryObjectSchema).readonly().optional()
25523
+ });
25524
+ /**
25525
+ * Time-series resolution. The history methods return one bucket per
25526
+ * step over the requested range. Smaller resolutions cost more
25527
+ * memory + bandwidth; bound to discrete steps so caller cannot ask
25528
+ * for arbitrary fractional buckets.
25529
+ */
25530
+ var HistoryResolutionEnum = _enum([
25531
+ "minute",
25532
+ "5min",
25533
+ "hour"
24805
25534
  ]);
24806
- function isDigit(ch) {
24807
- return ch >= "0" && ch <= "9";
24808
- }
24809
- function isIdentStart(ch) {
24810
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
24811
- }
24812
- function isIdentPart(ch) {
24813
- return isIdentStart(ch) || isDigit(ch);
24814
- }
24815
- function isWhitespace(ch) {
24816
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
24817
- }
24818
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
24819
- * Throws `ExpressionParseError` on any illegal character or unterminated
24820
- * string. */
24821
- function tokenize(source) {
24822
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
24823
- const tokens = [];
24824
- let i = 0;
24825
- const n = source.length;
24826
- while (i < n) {
24827
- const ch = source[i];
24828
- if (isWhitespace(ch)) {
24829
- i += 1;
24830
- continue;
24831
- }
24832
- if (isDigit(ch)) {
24833
- const start = i;
24834
- while (i < n && isDigit(source[i])) i += 1;
24835
- if (i < n && source[i] === ".") {
24836
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
24837
- i += 1;
24838
- while (i < n && isDigit(source[i])) i += 1;
24839
- }
24840
- const text = source.slice(start, i);
24841
- const value = Number(text);
24842
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
24843
- tokens.push({
24844
- type: "number",
24845
- value,
24846
- pos: start
24847
- });
24848
- continue;
24849
- }
24850
- if (ch === "'" || ch === "\"") {
24851
- const quote = ch;
24852
- const start = i;
24853
- i += 1;
24854
- let out = "";
24855
- let closed = false;
24856
- while (i < n) {
24857
- const c = source[i];
24858
- if (c === "\\") {
24859
- const next = i + 1 < n ? source[i + 1] : "";
24860
- if (next === "\\" || next === "'" || next === "\"") {
24861
- out += next;
24862
- i += 2;
24863
- continue;
24864
- }
24865
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
24866
- }
24867
- if (c === quote) {
24868
- closed = true;
24869
- i += 1;
24870
- break;
24871
- }
24872
- out += c;
24873
- i += 1;
24874
- }
24875
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
24876
- tokens.push({
24877
- type: "string",
24878
- value: out,
24879
- pos: start
24880
- });
24881
- continue;
24882
- }
24883
- if (isIdentStart(ch)) {
24884
- const start = i;
24885
- while (i < n && isIdentPart(source[i])) i += 1;
24886
- const text = source.slice(start, i);
24887
- if (KEYWORDS.has(text)) tokens.push({
24888
- type: "keyword",
24889
- keyword: keywordOf(text),
24890
- pos: start
24891
- });
24892
- else tokens.push({
24893
- type: "identifier",
24894
- name: text,
24895
- pos: start
24896
- });
24897
- continue;
24898
- }
24899
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
24900
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
24901
- tokens.push({
24902
- type: "punct",
24903
- punct: two,
24904
- pos: i
24905
- });
24906
- i += 2;
24907
- continue;
24908
- }
24909
- if (isSinglePunct(ch)) {
24910
- tokens.push({
24911
- type: "punct",
24912
- punct: ch,
24913
- pos: i
24914
- });
24915
- i += 1;
24916
- continue;
24917
- }
24918
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
24919
- }
24920
- tokens.push({
24921
- type: "eof",
24922
- pos: n
24923
- });
24924
- return tokens;
24925
- }
24926
- function keywordOf(text) {
24927
- if (text === "true") return "true";
24928
- if (text === "false") return "false";
24929
- return "null";
24930
- }
24931
- function isSinglePunct(ch) {
24932
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
24933
- }
25535
+ var HistoryRangeSchema = object({
25536
+ /** Range start (epoch ms, inclusive). */
25537
+ from: number().int(),
25538
+ /** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
25539
+ to: number().int(),
25540
+ resolution: HistoryResolutionEnum
25541
+ });
25542
+ var HistoryPointSchema = object({
25543
+ /** Bucket midpoint (epoch ms). */
25544
+ ts: number().int(),
25545
+ /** Object count averaged over the bucket (rounded to nearest integer). */
25546
+ count: number().int().nonnegative()
25547
+ });
25548
+ DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({
25549
+ deviceId: number(),
25550
+ zoneId: string(),
25551
+ className: string().optional()
25552
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
25553
+ deviceId: number(),
25554
+ className: string().optional()
25555
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
25556
+ deviceId: number(),
25557
+ className: string().optional()
25558
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
24934
25559
  /**
24935
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
25560
+ * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
25561
+ * cap so a single CRUD surface backs every consumer; each stage has
25562
+ * its own dev-state mirror slice (`motion-zone-rules`,
25563
+ * `detection-zone-rules`, …) so consumer addons subscribe independently.
24936
25564
  *
24937
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
24938
- * → relational → additive → multiplicative → unary `! -` → call / primary.
24939
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
24940
- * string validated against the builtin table at parse time, so an unknown
24941
- * function is rejected immediately (author feedback) and a persisted expression
24942
- * that references a since-removed builtin degrades at read.
25565
+ * Extend the enum here when a new gating consumer comes online (audio
25566
+ * gating, alert filtering, …) — no other surface needs to change.
25567
+ */
25568
+ var ZoneRuleStageEnum = _enum([
25569
+ "motion",
25570
+ "detection",
25571
+ "package"
25572
+ ]);
25573
+ /**
25574
+ * Zone rules capability — per-camera CRUD over the {@link ZoneRule}
25575
+ * arrays that decide how each pipeline stage uses the polygon zones.
24943
25576
  *
24944
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
24945
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
25577
+ * Hosted by `addon-pipeline-orchestrator` alongside the zones provider
25578
+ * so the operator has a single hub-side source of truth for both
25579
+ * geometry and behaviour. Per-stage rules are stored under the
25580
+ * `zoneRules.<stage>` key in the orchestrator's per-device store and
25581
+ * mirrored to the device-state slice `<stage>-zone-rules` on every
25582
+ * mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
25583
+ * subscribe to that slice and refresh their gating without
25584
+ * round-tripping the cap.
25585
+ *
25586
+ * Sets are bulk-replace — the operator UI sends the new rule list
25587
+ * wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
25588
+ * naturally in the rule editor without per-rule mutation chatter.
24946
25589
  */
24947
- /** Binary/logical operator precedence (higher binds tighter). */
24948
- var BINARY_PRECEDENCE = {
24949
- "||": 1,
24950
- "&&": 2,
24951
- "==": 3,
24952
- "!=": 3,
24953
- "<": 4,
24954
- "<=": 4,
24955
- ">": 4,
24956
- ">=": 4,
24957
- "+": 5,
24958
- "-": 5,
24959
- "*": 6,
24960
- "/": 6,
24961
- "%": 6
24962
- };
24963
- function isLogicalOp(op) {
24964
- return op === "&&" || op === "||";
24965
- }
24966
- function isBinaryOp(op) {
24967
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
24968
- }
24969
- var Parser = class {
24970
- tokens;
24971
- pos = 0;
24972
- nodeCount = 0;
24973
- identifiers = /* @__PURE__ */ new Set();
24974
- callees = /* @__PURE__ */ new Set();
24975
- constructor(tokens) {
24976
- this.tokens = tokens;
24977
- }
24978
- parse() {
24979
- const ast = this.parseTernary();
24980
- const tok = this.peek();
24981
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
24982
- return {
24983
- ast,
24984
- identifiers: this.identifiers,
24985
- callees: this.callees,
24986
- nodeCount: this.nodeCount
24987
- };
24988
- }
24989
- peek() {
24990
- return this.tokens[this.pos];
24991
- }
24992
- next() {
24993
- return this.tokens[this.pos++];
24994
- }
24995
- /** Consume a punctuator token, erroring if the next token isn't it. */
24996
- expectPunct(punct) {
24997
- const tok = this.peek();
24998
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
24999
- this.pos += 1;
25000
- }
25001
- matchPunct(punct) {
25002
- const tok = this.peek();
25003
- if (tok.type === "punct" && tok.punct === punct) {
25004
- this.pos += 1;
25005
- return true;
25006
- }
25007
- return false;
25008
- }
25009
- countNode() {
25010
- this.nodeCount += 1;
25011
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
25012
- }
25013
- parseTernary() {
25014
- const test = this.parseBinary(1);
25015
- if (this.matchPunct("?")) {
25016
- const consequent = this.parseTernary();
25017
- this.expectPunct(":");
25018
- const alternate = this.parseTernary();
25019
- this.countNode();
25020
- return {
25021
- kind: "conditional",
25022
- test,
25023
- consequent,
25024
- alternate
25025
- };
25026
- }
25027
- return test;
25028
- }
25029
- parseBinary(minPrec) {
25030
- let left = this.parseUnary();
25031
- for (;;) {
25032
- const tok = this.peek();
25033
- if (tok.type !== "punct") break;
25034
- const prec = BINARY_PRECEDENCE[tok.punct];
25035
- if (prec === void 0 || prec < minPrec) break;
25036
- const op = tok.punct;
25037
- this.pos += 1;
25038
- const right = this.parseBinary(prec + 1);
25039
- this.countNode();
25040
- if (isLogicalOp(op)) left = {
25041
- kind: "logical",
25042
- op,
25043
- left,
25044
- right
25045
- };
25046
- else if (isBinaryOp(op)) left = {
25047
- kind: "binary",
25048
- op,
25049
- left,
25050
- right
25051
- };
25052
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
25053
- }
25054
- return left;
25055
- }
25056
- parseUnary() {
25057
- const tok = this.peek();
25058
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
25059
- const op = tok.punct;
25060
- this.pos += 1;
25061
- const operand = this.parseUnary();
25062
- this.countNode();
25063
- return {
25064
- kind: "unary",
25065
- op,
25066
- operand
25067
- };
25068
- }
25069
- return this.parsePrimary();
25070
- }
25071
- parsePrimary() {
25072
- const tok = this.next();
25073
- switch (tok.type) {
25074
- case "number":
25075
- this.countNode();
25076
- return {
25077
- kind: "literal",
25078
- value: tok.value
25079
- };
25080
- case "string":
25081
- this.countNode();
25082
- return {
25083
- kind: "literal",
25084
- value: tok.value
25085
- };
25086
- case "keyword":
25087
- this.countNode();
25088
- return {
25089
- kind: "literal",
25090
- value: tok.keyword === "null" ? null : tok.keyword === "true"
25091
- };
25092
- case "identifier": {
25093
- const nextTok = this.peek();
25094
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
25095
- this.identifiers.add(tok.name);
25096
- this.countNode();
25097
- return {
25098
- kind: "identifier",
25099
- name: tok.name
25100
- };
25101
- }
25102
- case "punct":
25103
- if (tok.punct === "(") {
25104
- const inner = this.parseTernary();
25105
- this.expectPunct(")");
25106
- return inner;
25107
- }
25108
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
25109
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
25110
- }
25111
- }
25112
- parseCall(callee, pos) {
25113
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
25114
- this.expectPunct("(");
25115
- const args = [];
25116
- if (!this.matchPunct(")")) for (;;) {
25117
- args.push(this.parseTernary());
25118
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
25119
- if (this.matchPunct(",")) continue;
25120
- this.expectPunct(")");
25121
- break;
25122
- }
25123
- this.callees.add(callee);
25124
- this.countNode();
25125
- return {
25126
- kind: "call",
25127
- callee,
25128
- args
25129
- };
25130
- }
25590
+ var zoneRulesCapability = {
25591
+ name: "zone-rules",
25592
+ scope: "device",
25593
+ mode: "singleton",
25594
+ deviceTypes: [DeviceType.Camera],
25595
+ methods: {
25596
+ /** Read the full rule list for a given stage (empty when no rules
25597
+ * are defined yet). */
25598
+ listRules: method(object({
25599
+ deviceId: number(),
25600
+ stage: ZoneRuleStageEnum
25601
+ }), array(ZoneRuleSchema).readonly()),
25602
+ /** Bulk-replace the rule list for one stage. The provider validates
25603
+ * each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
25604
+ * thresholds in range) and rejects the whole patch if any entry
25605
+ * is invalid — partial writes are a configuration footgun. */
25606
+ setRules: method(object({
25607
+ deviceId: number(),
25608
+ stage: ZoneRuleStageEnum,
25609
+ rules: array(ZoneRuleSchema).readonly()
25610
+ }), _void(), {
25611
+ kind: "mutation",
25612
+ auth: "admin"
25613
+ })
25614
+ },
25615
+ /**
25616
+ * Runtime-state slice — every stage mirrored together so consumers
25617
+ * see one reactive handle (`device.state.zoneRules.value`) instead
25618
+ * of one per stage. Bulk-replace mutations on any stage write the full
25619
+ * `{motion, detection, package}` shape, so subscribers always get the
25620
+ * complete current set. Consumers that only care about one stage
25621
+ * just read the matching property.
25622
+ *
25623
+ * `package` backs the package-drop detector — a package zone is a
25624
+ * `ZoneRule` on the `'package'` stage referencing drawn polygons
25625
+ * (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
25626
+ * The orchestrator provider writes this stage as a first-class slice
25627
+ * (Phase 4): every mutation mirrors the full `{motion, detection,
25628
+ * package}` shape, so consumers read the current package rules directly
25629
+ * off `device.state.zoneRules.value.package`.
25630
+ */
25631
+ runtimeState: object({
25632
+ motion: array(ZoneRuleSchema).readonly(),
25633
+ detection: array(ZoneRuleSchema).readonly(),
25634
+ package: array(ZoneRuleSchema).readonly()
25635
+ })
25131
25636
  };
25132
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
25133
- * `ExpressionParseError` on any lexical or grammatical failure. */
25134
- function parseExpression(source) {
25135
- return new Parser(tokenize(source)).parse();
25136
- }
25137
25637
  /**
25138
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
25139
- * by expr"). The cache stores BOTH successes and failures (negative caching),
25140
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
25141
- * one per read on a hot resolve path.
25638
+ * Accessory device helpers — shared across drivers.
25142
25639
  *
25143
- * The cache is a module-level singleton: entries are pure, content-addressed
25144
- * ASTs keyed by the raw source string, so sharing one instance across all
25145
- * callers is safe and maximises hit rate.
25640
+ * Many vendor-specific drivers register accessory child devices on
25641
+ * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
25642
+ * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
25643
+ * driver picks the right `DeviceType` + `DeviceRole` explicitly when
25644
+ * spawning, builds a name derived from the parent, and produces a
25645
+ * stableId tied to the parent so boot-restore can reconstruct the
25646
+ * relationship.
25647
+ *
25648
+ * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
25649
+ * drivers may reasonably disagree on the right type for an accessory
25650
+ * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
25651
+ * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
25652
+ * one canonical mapping was over-prescriptive and added a layer of
25653
+ * indirection without saving meaningful code at call sites — the
25654
+ * driver knows its own hardware best.
25146
25655
  */
25147
- var cache = /* @__PURE__ */ new Map();
25148
- function getCached(source) {
25149
- const hit = cache.get(source);
25150
- if (hit !== void 0) {
25151
- cache.delete(source);
25152
- cache.set(source, hit);
25153
- return hit;
25154
- }
25155
- let result;
25156
- try {
25157
- result = {
25158
- ok: true,
25159
- parsed: parseExpression(source)
25160
- };
25161
- } catch (err) {
25162
- result = {
25163
- ok: false,
25164
- error: err instanceof ExpressionParseError ? err.message : String(err)
25165
- };
25166
- }
25167
- cache.set(source, result);
25168
- if (cache.size > 256) {
25169
- const oldest = cache.keys().next().value;
25170
- if (oldest !== void 0) cache.delete(oldest);
25171
- }
25172
- return result;
25173
- }
25174
- /** Compile `source`, returning a discriminated result instead of throwing.
25175
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
25176
- function compileExpressionSafe(source) {
25177
- return getCached(source);
25656
+ /**
25657
+ * Subset of `DeviceRole` values that drivers register as child
25658
+ * accessories of a parent device. Sourced verbatim from `DeviceRole`
25659
+ * — `AccessoryKind` is the alias drivers use when building accessory
25660
+ * children, so the call site reads as
25661
+ * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
25662
+ * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
25663
+ * any role works, including non-accessory ones like Doorbell).
25664
+ */
25665
+ var AccessoryKind = {
25666
+ Siren: DeviceRole.Siren,
25667
+ Floodlight: DeviceRole.Floodlight,
25668
+ Spotlight: DeviceRole.Spotlight,
25669
+ PirSensor: DeviceRole.PirSensor,
25670
+ Chime: DeviceRole.Chime,
25671
+ Autotrack: DeviceRole.Autotrack,
25672
+ Nightvision: DeviceRole.Nightvision,
25673
+ PrivacyMask: DeviceRole.PrivacyMask
25674
+ };
25675
+ AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
25676
+ 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;
25677
+ new Set(Object.values(DeviceType));
25678
+ /**
25679
+ * Heuristic check — `device.features?.includes(profile.when.hasFeature)`.
25680
+ */
25681
+ function deviceMatchesProfile(features, profile) {
25682
+ if (!features || features.length === 0) return false;
25683
+ return features.includes(profile.when.hasFeature);
25178
25684
  }
25179
- Object.freeze({});
25180
25685
  /**
25181
- * Author-time validation. Returns `null` when the source is valid, else a
25182
- * human-readable error message. Checks: the expression compiles; binding count
25183
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
25184
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
25185
- * FREE identifier of the AST is covered by a binding or the injected `now`.
25686
+ * Profile registry — order matters when multiple profiles match the
25687
+ * same device (first match wins). Today there's only one entry.
25186
25688
  */
25187
- function validateExpressionSource(src) {
25188
- const names = Object.keys(src.bindings);
25189
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
25190
- for (const name of names) {
25191
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
25192
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
25193
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
25194
- }
25195
- const compiled = compileExpressionSafe(src.expr);
25196
- if (!compiled.ok) return compiled.error;
25197
- const bound = new Set(names);
25198
- for (const id of compiled.parsed.identifiers) {
25199
- if (id === "now") continue;
25200
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
25201
- }
25689
+ var DEVICE_PROFILES = [{
25690
+ id: "battery",
25691
+ label: "Battery-operated camera",
25692
+ when: { hasFeature: DeviceFeature.BatteryOperated },
25693
+ defaults: {
25694
+ audioMode: "disabled",
25695
+ detectionMode: "on-motion"
25696
+ },
25697
+ settings: {}
25698
+ }];
25699
+ /**
25700
+ * Resolve the profile that matches a device's features, or `null` when
25701
+ * no profile matches. First-match-wins.
25702
+ */
25703
+ function resolveDeviceProfile(features) {
25704
+ for (const profile of DEVICE_PROFILES) if (deviceMatchesProfile(features, profile)) return profile;
25202
25705
  return null;
25203
25706
  }
25204
- var ExpressionBindingSourceSchema = union([
25205
- object({
25206
- kind: literal("field").optional(),
25207
- sourceKey: string(),
25208
- cap: string(),
25209
- fieldPath: string()
25210
- }),
25211
- object({
25212
- kind: literal("literal"),
25213
- value: union([
25214
- string(),
25215
- number(),
25216
- boolean(),
25217
- _null()
25218
- ])
25219
- }),
25220
- object({
25221
- kind: literal("global"),
25222
- sourceStableId: string(),
25223
- cap: string(),
25224
- fieldPath: string()
25225
- })
25226
- ]);
25227
- object({
25228
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
25229
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
25230
- }).superRefine((src, ctx) => {
25231
- const err = validateExpressionSource(src);
25232
- if (err !== null) ctx.addIssue({
25233
- code: "custom",
25234
- message: err,
25235
- path: ["expr"]
25236
- });
25237
- });
25238
25707
  Object.freeze({
25239
25708
  "accessories.setChildHidden": {
25240
25709
  capName: "accessories",
@@ -26010,6 +26479,18 @@ Object.freeze({
26010
26479
  addonId: null,
26011
26480
  access: "create"
26012
26481
  },
26482
+ "connectionTest.describeTest": {
26483
+ capName: "connection-test",
26484
+ capScope: "system",
26485
+ addonId: null,
26486
+ access: "view"
26487
+ },
26488
+ "connectionTest.testSettings": {
26489
+ capName: "connection-test",
26490
+ capScope: "system",
26491
+ addonId: null,
26492
+ access: "create"
26493
+ },
26013
26494
  "consumables.reset": {
26014
26495
  capName: "consumables",
26015
26496
  capScope: "device",
@@ -26400,6 +26881,12 @@ Object.freeze({
26400
26881
  addonId: null,
26401
26882
  access: "create"
26402
26883
  },
26884
+ "deviceManager.adoptionCancelJob": {
26885
+ capName: "device-manager",
26886
+ capScope: "system",
26887
+ addonId: null,
26888
+ access: "create"
26889
+ },
26403
26890
  "deviceManager.adoptionListCandidateFilters": {
26404
26891
  capName: "device-manager",
26405
26892
  capScope: "system",
@@ -26412,6 +26899,12 @@ Object.freeze({
26412
26899
  addonId: null,
26413
26900
  access: "view"
26414
26901
  },
26902
+ "deviceManager.adoptionListJobs": {
26903
+ capName: "device-manager",
26904
+ capScope: "system",
26905
+ addonId: null,
26906
+ access: "view"
26907
+ },
26415
26908
  "deviceManager.adoptionRefresh": {
26416
26909
  capName: "device-manager",
26417
26910
  capScope: "system",
@@ -26430,6 +26923,12 @@ Object.freeze({
26430
26923
  addonId: null,
26431
26924
  access: "create"
26432
26925
  },
26926
+ "deviceManager.adoptionStartJob": {
26927
+ capName: "device-manager",
26928
+ capScope: "system",
26929
+ addonId: null,
26930
+ access: "create"
26931
+ },
26433
26932
  "deviceManager.allocateDeviceId": {
26434
26933
  capName: "device-manager",
26435
26934
  capScope: "system",
@@ -29568,6 +30067,12 @@ Object.freeze({
29568
30067
  addonId: null,
29569
30068
  access: "view"
29570
30069
  },
30070
+ "snapshot.getSnapshotLinks": {
30071
+ capName: "snapshot",
30072
+ capScope: "device",
30073
+ addonId: null,
30074
+ access: "view"
30075
+ },
29571
30076
  "snapshot.getSnapshotOverview": {
29572
30077
  capName: "snapshot",
29573
30078
  capScope: "device",
@@ -32933,6 +33438,7 @@ function composeCameraStatus(input) {
32933
33438
  audio: mapAudio(input.audioResult),
32934
33439
  recording: mapRecording(input.recordingResult),
32935
33440
  switchedOff: input.switchedOff,
33441
+ degraded: input.degraded,
32936
33442
  fetchedAt: input.fetchedAt
32937
33443
  };
32938
33444
  }
@@ -32984,24 +33490,74 @@ var AUDIO_SILENCING_SWITCH_IDS = [
32984
33490
  "broker-audio",
32985
33491
  "audio-analysis"
32986
33492
  ];
33493
+ /**
33494
+ * Sentinel resolved by the per-stage timer. A unique symbol rather than `null`
33495
+ * so the race result can be narrowed WITHOUT a cast — and, more to the point,
33496
+ * so a stage that legitimately resolves `null` is never mistaken for one that
33497
+ * ran out of time.
33498
+ */
33499
+ var STAGE_TIMED_OUT = Symbol("camera-status-stage-timeout");
32987
33500
  var CameraStatusService = class {
32988
33501
  deps;
32989
33502
  constructor(deps) {
32990
33503
  this.deps = deps;
32991
33504
  }
32992
33505
  /**
32993
- * Races a promise against a timeout. Returns `null` on timeout OR rejection.
33506
+ * Record — and ANNOUNCE — a stage whose value could not be trusted.
33507
+ *
33508
+ * Both halves matter. The entry is what a client reads to tell a `null` that
33509
+ * means "we could not look" from a `null` that means "there is nothing
33510
+ * there"; the `warn` is what makes the next hang findable at all. The line
33511
+ * carries `tags.deviceId` with the numeric id because the question is always
33512
+ * per-camera ("why is 614 worse than 615?") and a line without the tag
33513
+ * cannot be grouped to answer it.
33514
+ */
33515
+ recordDegraded(sink, deviceId, stage, reason, elapsedMs, extraMeta = {}) {
33516
+ sink.entries.push({
33517
+ stage,
33518
+ reason,
33519
+ elapsedMs
33520
+ });
33521
+ this.deps.logger.warn("camera status stage could not be read", {
33522
+ tags: { deviceId },
33523
+ meta: {
33524
+ stage,
33525
+ reason,
33526
+ elapsedMs,
33527
+ ...extraMeta
33528
+ }
33529
+ });
33530
+ }
33531
+ /**
33532
+ * Races a promise against a timeout. Returns `null` on timeout OR rejection —
33533
+ * but the two are no longer the same event: whichever happened is pushed to
33534
+ * `sink` (and logged) under `stage`, so the `null` that reaches the payload
33535
+ * arrives with its cause attached.
33536
+ *
32994
33537
  * Never throws — individual stage failures become `null` in the aggregate.
32995
33538
  *
32996
- * @param p The stage fetch promise.
32997
- * @param ms Timeout in milliseconds.
33539
+ * @param p The stage fetch promise.
33540
+ * @param ms Timeout in milliseconds.
33541
+ * @param stage Which stage this is, as it appears in `CameraStatus.degraded`.
33542
+ * @param deviceId The camera — every line about it carries the tag.
33543
+ * @param sink Per-call collector for stages that could not be read.
32998
33544
  */
32999
- boundedStage(p, ms) {
33545
+ boundedStage(p, ms, stage, deviceId, sink) {
33546
+ const startedAt = Date.now();
33000
33547
  let timer;
33001
33548
  const timeout = new Promise((resolve) => {
33002
- timer = setTimeout(() => resolve(null), ms);
33549
+ timer = setTimeout(() => resolve(STAGE_TIMED_OUT), ms);
33003
33550
  });
33004
- return Promise.race([p, timeout]).catch(() => null).finally(() => {
33551
+ return Promise.race([p, timeout]).then((value) => {
33552
+ if (value === STAGE_TIMED_OUT) {
33553
+ this.recordDegraded(sink, deviceId, stage, "timeout", Date.now() - startedAt, { timeoutMs: ms });
33554
+ return null;
33555
+ }
33556
+ return value;
33557
+ }).catch((err) => {
33558
+ this.recordDegraded(sink, deviceId, stage, "error", Date.now() - startedAt, { error: err instanceof Error ? err.message : String(err) });
33559
+ return null;
33560
+ }).finally(() => {
33005
33561
  if (timer !== void 0) clearTimeout(timer);
33006
33562
  });
33007
33563
  }
@@ -33027,7 +33583,7 @@ var CameraStatusService = class {
33027
33583
  decoder: false,
33028
33584
  audio: audioPinned
33029
33585
  },
33030
- detectionReason: pipelineAssignment !== null ? pipelineAssignment.reason : this.deps.hasCameraConfig(deviceId) ? `pending:${this.deps.getPendingReason(deviceId) ?? "pending"}` : void 0,
33586
+ detectionReason: pipelineAssignment !== null ? pipelineAssignment.reason : this.deps.hasCameraConfig(deviceId) ? `pending:${this.deps.getPendingReason(deviceId) ?? "pending"}` : this.deps.isSessionCamera(deviceId) ? "armed:on-motion" : void 0,
33031
33587
  audioNodeId,
33032
33588
  audioPinned
33033
33589
  };
@@ -33038,7 +33594,7 @@ var CameraStatusService = class {
33038
33594
  * deviceId). `allSlotsFetch` is shared with the broker stage — fetched
33039
33595
  * ONCE by the caller — so this stage never issues its own round-trip.
33040
33596
  */
33041
- buildSourceStage(api, allSlotsFetch, deviceId) {
33597
+ buildSourceStage(api, allSlotsFetch, deviceId, sink) {
33042
33598
  if (!api || !allSlotsFetch) return Promise.resolve(null);
33043
33599
  return this.boundedStage(allSlotsFetch.then((slots) => {
33044
33600
  return { streams: slots.filter((s) => s.deviceId === deviceId).map((s) => ({
@@ -33049,7 +33605,7 @@ var CameraStatusService = class {
33049
33605
  fps: 0,
33050
33606
  kind: s.profile
33051
33607
  })) };
33052
- }), STAGE_TIMEOUT_MS);
33608
+ }), STAGE_TIMEOUT_MS, "source", deviceId, sink);
33053
33609
  }
33054
33610
  /**
33055
33611
  * Broker stage (per-profile slot stats + client counts). Reuses
@@ -33060,7 +33616,7 @@ var CameraStatusService = class {
33060
33616
  * broker's actual decode-session node into `liveDecoder` (T6) — the first
33061
33617
  * slot that reports one wins.
33062
33618
  */
33063
- buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder) {
33619
+ buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder, sink) {
33064
33620
  if (!api || !allSlotsFetch) return Promise.resolve(null);
33065
33621
  return this.boundedStage(allSlotsFetch.then(async (slots) => {
33066
33622
  const deviceSlots = slots.filter((s) => s.deviceId === deviceId);
@@ -33097,7 +33653,7 @@ var CameraStatusService = class {
33097
33653
  return e.brokerId.split("/")[0] === String(deviceId) && e.enabled;
33098
33654
  }) ?? false
33099
33655
  };
33100
- }), STAGE_TIMEOUT_MS);
33656
+ }), STAGE_TIMEOUT_MS, "broker", deviceId, sink);
33101
33657
  }
33102
33658
  /**
33103
33659
  * Decoder stage. decoder cap methods (listActiveSessions/getInfo/getShmStats)
@@ -33136,7 +33692,7 @@ var CameraStatusService = class {
33136
33692
  };
33137
33693
  }
33138
33694
  /** Detection stage (pipeline-executor + runner metrics). */
33139
- buildDetectionStage(api, detectionNodeId, deviceId) {
33695
+ buildDetectionStage(api, detectionNodeId, deviceId, sink) {
33140
33696
  if (!api || !detectionNodeId) return Promise.resolve(null);
33141
33697
  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]) => {
33142
33698
  const metrics = await api.pipelineRunner.getCameraMetrics.query({
@@ -33166,7 +33722,7 @@ var CameraStatusService = class {
33166
33722
  ...provisioning?.error !== void 0 ? { error: provisioning.error } : {}
33167
33723
  }
33168
33724
  };
33169
- }), STAGE_TIMEOUT_MS);
33725
+ }), STAGE_TIMEOUT_MS, "detection", deviceId, sink);
33170
33726
  }
33171
33727
  /**
33172
33728
  * Audio stage. `nodeId` is orchestrator-local (the cached assignment);
@@ -33185,6 +33741,12 @@ var CameraStatusService = class {
33185
33741
  * analyzer with nothing, and reporting `enabled: true` for the other two
33186
33742
  * would reintroduce the exact hardcoded lie the previous paragraph is about
33187
33743
  * — one row further down the group.
33744
+ *
33745
+ * `enabled` is a boolean and cannot say "unknown", so when the switch read
33746
+ * was cut short or partial this block is OPTIMISTIC by construction. That is
33747
+ * survivable only because `CameraStatus.degraded` names `'switches'` in
33748
+ * exactly those cases — a surface drawing "audio is running" from this field
33749
+ * must consult it first.
33188
33750
  */
33189
33751
  buildAudioStage(audioNodeId, switchedOff) {
33190
33752
  if (audioNodeId === null) return null;
@@ -33199,8 +33761,13 @@ var CameraStatusService = class {
33199
33761
  * `{ deviceId }` and returns `RecordingStatus`, typed `unknown` in the
33200
33762
  * generated router (DEVICE_STATUS_METHOD) — narrowed via
33201
33763
  * {@link isRecordingStatus} instead of an unsafe cast.
33764
+ *
33765
+ * There is deliberately NO `.catch(() => null)` on the query any more: it
33766
+ * swallowed the rejection before `boundedStage` could see it, so a recording
33767
+ * source that was refusing every call produced the same anonymous `null` as
33768
+ * a camera that simply records nothing.
33202
33769
  */
33203
- buildRecordingStage(api, deviceId) {
33770
+ buildRecordingStage(api, deviceId, sink) {
33204
33771
  if (!api) return Promise.resolve(null);
33205
33772
  return this.boundedStage(api.recording.getStatus.query({ deviceId }).then((rawStatus) => {
33206
33773
  if (!isRecordingStatus(rawStatus)) return null;
@@ -33209,7 +33776,40 @@ var CameraStatusService = class {
33209
33776
  active: rawStatus.enabled && rawStatus.activeMode !== "off",
33210
33777
  storageBytes: rawStatus.storageBytes
33211
33778
  };
33212
- }).catch(() => null), STAGE_TIMEOUT_MS);
33779
+ }), STAGE_TIMEOUT_MS, "recording", deviceId, sink);
33780
+ }
33781
+ /**
33782
+ * The operator's function switches (D61/D62), bounded like every other stage.
33783
+ *
33784
+ * Three outcomes, and they must not collapse into one:
33785
+ *
33786
+ * - READ — the group came back whole. The derived id list is a total, and an
33787
+ * empty one really does mean the operator turned nothing off.
33788
+ * - PARTIAL — the group came back with at least one switch marked
33789
+ * `source-unreachable`. `CameraSwitchService` bounds each source
33790
+ * individually, so a slow source drops ITS switch and leaves the rest;
33791
+ * the derived list is then a FLOOR. Marked `'partial'`.
33792
+ * - NOT READ — the whole group timed out or rejected. Marked by
33793
+ * `boundedStage` itself.
33794
+ *
33795
+ * In the last two the returned list is `[]`, and `[]` was previously handed
33796
+ * to the payload as the positive claim "the operator has switched nothing
33797
+ * off". Measured on the live hub 2026-08-08: `getCameraStatus(614)` said
33798
+ * `switchedOff: []` at the very moment `getCameraSwitches(614)` reported
33799
+ * `stream-broker` AND `recording` switched off by the operator — the camera
33800
+ * read as BROKEN because it was switched OFF, which is exactly the
33801
+ * inversion D62 exists to forbid. The list stays empty (a guess would put a
33802
+ * permanent "off" badge on a working camera); what changed is that the
33803
+ * emptiness now travels with the reason it is empty.
33804
+ */
33805
+ buildSwitchStage(deviceId, sink) {
33806
+ const startedAt = Date.now();
33807
+ return this.boundedStage(this.deps.cameraSwitchesFor(deviceId), STAGE_TIMEOUT_MS, "switches", deviceId, sink).then((group) => {
33808
+ if (group === null) return [];
33809
+ const unreadable = group.switches.filter((s) => s.unavailableReason === "source-unreachable");
33810
+ if (unreadable.length > 0) this.recordDegraded(sink, deviceId, "switches", "partial", Date.now() - startedAt, { unreadableSwitches: unreadable.map((s) => s.id) });
33811
+ return switchedOffIds(group.switches);
33812
+ });
33213
33813
  }
33214
33814
  /**
33215
33815
  * Server-composed aggregated status for a single camera.
@@ -33217,20 +33817,23 @@ var CameraStatusService = class {
33217
33817
  * Fans out in parallel (bounded, per-stage graceful degradation) to
33218
33818
  * broker / decoder / motion / detection / audio / recording source caps
33219
33819
  * via `ctx.api`. A stage whose source errors or times out becomes `null`
33220
- * in the returned payload — one slow agent never breaks the whole call.
33820
+ * in the returned payload — one slow agent never breaks the whole call —
33821
+ * and is NAMED in `degraded`, so a consumer can tell that `null` from the
33822
+ * `null` of a camera that legitimately has no such stage.
33221
33823
  */
33222
33824
  async getCameraStatus(deviceId) {
33223
33825
  const api = this.deps.api();
33826
+ const degradations = { entries: [] };
33224
33827
  const { detectionNodeId, sourceNodeId, pinned, detectionReason, audioNodeId, audioPinned } = this.buildAssignmentContext(deviceId);
33225
33828
  const liveDecoder = { nodeId: null };
33226
33829
  const allSlotsFetch = api ? api.streamBroker.listAllProfileSlots.query(void 0, nodePin(sourceNodeId)) : null;
33227
- const sourceFetch = this.buildSourceStage(api, allSlotsFetch, deviceId);
33228
- const brokerFetch = this.buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder);
33830
+ const sourceFetch = this.buildSourceStage(api, allSlotsFetch, deviceId, degradations);
33831
+ const brokerFetch = this.buildBrokerStage(api, allSlotsFetch, deviceId, sourceNodeId, liveDecoder, degradations);
33229
33832
  const decoderFetch = this.buildDecoderStage(detectionNodeId);
33230
33833
  const motionResult = this.buildMotionStage(deviceId);
33231
- const detectionFetch = this.buildDetectionStage(api, detectionNodeId, deviceId);
33232
- const recordingFetch = this.buildRecordingStage(api, deviceId);
33233
- const switchesFetch = this.boundedStage(this.deps.switchedOffIdsFor(deviceId).catch(() => null), STAGE_TIMEOUT_MS).then((ids) => ids ?? []);
33834
+ const detectionFetch = this.buildDetectionStage(api, detectionNodeId, deviceId, degradations);
33835
+ const recordingFetch = this.buildRecordingStage(api, deviceId, degradations);
33836
+ const switchesFetch = this.buildSwitchStage(deviceId, degradations);
33234
33837
  const [sourceResult, brokerResult, decoderResult, detectionResult, recordingResult, switchedOff] = await Promise.all([
33235
33838
  sourceFetch,
33236
33839
  brokerFetch,
@@ -33263,7 +33866,8 @@ var CameraStatusService = class {
33263
33866
  detectionResult,
33264
33867
  audioResult,
33265
33868
  recordingResult,
33266
- switchedOff
33869
+ switchedOff,
33870
+ degraded: [...degradations.entries]
33267
33871
  });
33268
33872
  }
33269
33873
  /**
@@ -33331,6 +33935,25 @@ function readPrivacyMaskFacts(v) {
33331
33935
  enabled: typeof enabled === "boolean" ? enabled : null
33332
33936
  };
33333
33937
  }
33938
+ /**
33939
+ * How long ONE source of the gather may take before it is treated as
33940
+ * unreachable.
33941
+ *
33942
+ * The gather runs in two sequential stages, so an operator's worst case is
33943
+ * twice this — 2.4s — which has to stay under the 3s `STAGE_TIMEOUT_MS` that
33944
+ * `CameraStatusService` allows the whole switch read. That ordering is the
33945
+ * whole point of the number: if the gather can outlast its caller's budget,
33946
+ * the caller degrades to "nothing switched off" and a camera the operator
33947
+ * DISABLED renders as broken.
33948
+ *
33949
+ * Measured on the live hub, 2026-08-08: seven of the eight sources answered in
33950
+ * 5–15ms on every camera. The eighth, `privacyMask.getStatus`, probes the
33951
+ * camera itself and took 3.1–3.6s on device 614 on every single call (18s
33952
+ * under load) — so a bound generous enough to make the fast sources safe still
33953
+ * has to be willing to drop the slow one, because waiting for it costs the
33954
+ * other six switches.
33955
+ */
33956
+ var SOURCE_TIMEOUT_MS = 1200;
33334
33957
  var CameraSwitchService = class {
33335
33958
  deps;
33336
33959
  constructor(deps) {
@@ -33467,11 +34090,16 @@ var CameraSwitchService = class {
33467
34090
  wrapperAddonIdByCap: /* @__PURE__ */ new Map(),
33468
34091
  recordingConfig: null
33469
34092
  };
33470
- const devicePromise = api.deviceManager.getDevice.query({ deviceId }).then((d) => isDeviceShape(d) ? d : null).catch((err) => {
34093
+ const devicePromise = this.bounded(deviceId, "deviceManager.getDevice", api.deviceManager.getDevice.query({ deviceId }).then((d) => isDeviceShape(d) ? d : null).catch((err) => {
33471
34094
  this.warn(deviceId, "getDevice", err);
33472
34095
  return null;
33473
- });
33474
- const bindingsPromise = api.deviceManager.getBindings.query({ deviceId }).then((b) => {
34096
+ }), null);
34097
+ const unknownBindings = {
34098
+ activeWrapperCapNames: null,
34099
+ providerAddonIdByCap: /* @__PURE__ */ new Map(),
34100
+ allCapNames: null
34101
+ };
34102
+ const bindingsPromise = this.bounded(deviceId, "deviceManager.getBindings", api.deviceManager.getBindings.query({ deviceId }).then((b) => {
33475
34103
  const active = [];
33476
34104
  const all = [];
33477
34105
  const providers = /* @__PURE__ */ new Map();
@@ -33488,24 +34116,20 @@ var CameraSwitchService = class {
33488
34116
  };
33489
34117
  }).catch((err) => {
33490
34118
  this.warn(deviceId, "getBindings", err);
33491
- return {
33492
- activeWrapperCapNames: null,
33493
- providerAddonIdByCap: /* @__PURE__ */ new Map(),
33494
- allCapNames: null
33495
- };
33496
- });
33497
- const recordingPromise = api.recording.getDeviceConfig.query({ deviceId }).then((c) => isRecordingConfig(c) ? c : null).catch((err) => {
34119
+ return unknownBindings;
34120
+ }), unknownBindings);
34121
+ const recordingPromise = this.bounded(deviceId, "recording.getDeviceConfig", api.recording.getDeviceConfig.query({ deviceId }).then((c) => isRecordingConfig(c) ? c : null).catch((err) => {
33498
34122
  this.warn(deviceId, "recording.getDeviceConfig", err);
33499
34123
  return null;
33500
- });
33501
- const mutesPromise = api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
34124
+ }), null);
34125
+ const mutesPromise = this.bounded(deviceId, "notificationRules.listDeviceMutes", api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
33502
34126
  this.warn(deviceId, "notificationRules.listDeviceMutes", err);
33503
34127
  return null;
33504
- });
33505
- const brokerAudioPromise = api.streamBroker.getDeviceAudioMute.query({ deviceId }, nodePin(this.deps.assignSource(deviceId))).then((r) => ({ muted: r.muted })).catch((err) => {
34128
+ }), null);
34129
+ const brokerAudioPromise = this.bounded(deviceId, "streamBroker.getDeviceAudioMute", api.streamBroker.getDeviceAudioMute.query({ deviceId }, nodePin(this.deps.assignSource(deviceId))).then((r) => ({ muted: r.muted })).catch((err) => {
33506
34130
  this.warn(deviceId, "streamBroker.getDeviceAudioMute", err);
33507
34131
  return null;
33508
- });
34132
+ }), null);
33509
34133
  const [device, bindings, recordingConfig, mutedDeviceIds, brokerAudio] = await Promise.all([
33510
34134
  devicePromise,
33511
34135
  bindingsPromise,
@@ -33513,10 +34137,10 @@ var CameraSwitchService = class {
33513
34137
  mutesPromise,
33514
34138
  brokerAudioPromise
33515
34139
  ]);
33516
- const [bindable, privacy] = await Promise.all([device === null ? Promise.resolve(null) : api.deviceManager.listBindableCapsForDeviceType.query({ deviceType: device.type }).catch((err) => {
34140
+ 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) => {
33517
34141
  this.warn(deviceId, "listBindableCapsForDeviceType", err);
33518
34142
  return null;
33519
- }), this.gatherPrivacy(api, deviceId, bindings.allCapNames)]);
34143
+ }), null), this.gatherPrivacy(api, deviceId, bindings.allCapNames)]);
33520
34144
  const wrapperAddonIdByCap = /* @__PURE__ */ new Map();
33521
34145
  for (const entry of bindable ?? []) {
33522
34146
  const first = entry.wrappers[0];
@@ -33572,13 +34196,13 @@ var CameraSwitchService = class {
33572
34196
  enabled: null
33573
34197
  }
33574
34198
  };
33575
- const [options, status] = await Promise.all([api.privacyMask.getOptions.query({ deviceId }).catch((err) => {
34199
+ const [options, status] = await Promise.all([this.bounded(deviceId, "privacyMask.getOptions", api.privacyMask.getOptions.query({ deviceId }).catch((err) => {
33576
34200
  this.warn(deviceId, "privacyMask.getOptions", err);
33577
34201
  return null;
33578
- }), api.privacyMask.getStatus.query({ deviceId }).catch((err) => {
34202
+ }), null), this.bounded(deviceId, "privacyMask.getStatus", api.privacyMask.getStatus.query({ deviceId }).catch((err) => {
33579
34203
  this.warn(deviceId, "privacyMask.getStatus", err);
33580
34204
  return null;
33581
- })]);
34205
+ }), null)]);
33582
34206
  if (options === null) return {
33583
34207
  deviceAudio: null,
33584
34208
  privacyMask: null
@@ -33602,6 +34226,41 @@ var CameraSwitchService = class {
33602
34226
  }
33603
34227
  };
33604
34228
  }
34229
+ /**
34230
+ * Bound ONE source. A source that has not answered within
34231
+ * {@link SOURCE_TIMEOUT_MS} yields `fallback` — the same value its `.catch`
34232
+ * yields — so a slow source and a broken one produce the identical group:
34233
+ * that switch is `available: false`, and every other switch is untouched.
34234
+ *
34235
+ * The bound is per SOURCE rather than around the whole gather on purpose.
34236
+ * One deadline over the fan-out would let the slowest camera probe consume
34237
+ * the budget of the seven sources that already answered, which is precisely
34238
+ * how `getCameraStatus` came to report `switchedOff: []` for a camera whose
34239
+ * `stream-broker` and `recording` switches the operator had turned off.
34240
+ *
34241
+ * @param deviceId The camera — every line about it carries the tag.
34242
+ * @param source The cap method, as named in the warn line.
34243
+ * @param p The source's promise. Already `.catch`-ed; never rejects.
34244
+ * @param fallback What the caller reads when the source did not answer.
34245
+ */
34246
+ bounded(deviceId, source, p, fallback) {
34247
+ let timer;
34248
+ const timeout = new Promise((resolve) => {
34249
+ timer = setTimeout(() => {
34250
+ this.deps.logger.warn("camera switch source TIMED OUT — its switch is not offered", {
34251
+ tags: { deviceId },
34252
+ meta: {
34253
+ source,
34254
+ timeoutMs: SOURCE_TIMEOUT_MS
34255
+ }
34256
+ });
34257
+ resolve(fallback);
34258
+ }, SOURCE_TIMEOUT_MS);
34259
+ });
34260
+ return Promise.race([p, timeout]).finally(() => {
34261
+ if (timer !== void 0) clearTimeout(timer);
34262
+ });
34263
+ }
33605
34264
  warn(deviceId, source, err) {
33606
34265
  this.deps.logger.warn("camera switch source unreachable — its switch is not offered", {
33607
34266
  tags: { deviceId },
@@ -39318,6 +39977,7 @@ async function buildOrchestratorControllers(deps) {
39318
39977
  });
39319
39978
  const cameraStatusService = new CameraStatusService({
39320
39979
  api: () => deps.ctx().api,
39980
+ logger: deps.ctx().logger,
39321
39981
  getAssignment: (deviceId) => ledger.getAssignment(deviceId),
39322
39982
  getAudioAssignment: (deviceId) => audio.getAssignment(deviceId),
39323
39983
  getCameraConfig: (deviceId) => ledger.getConfig(deviceId),
@@ -39325,7 +39985,8 @@ async function buildOrchestratorControllers(deps) {
39325
39985
  getPendingReason: (deviceId) => ledger.getPendingReason(deviceId),
39326
39986
  assignSource: (deviceId) => topology.assignSource(deviceId),
39327
39987
  listAssignedDeviceIds: () => ledger.listAssignedDeviceIds(),
39328
- switchedOffIdsFor: (deviceId) => cameraSwitchService.switchedOffIdsFor(deviceId)
39988
+ isSessionCamera: (deviceId) => deps.isSessionCamera(deviceId),
39989
+ cameraSwitchesFor: (deviceId) => cameraSwitchService.getCameraSwitches(deviceId)
39329
39990
  });
39330
39991
  const reconcile = new ReconcileController({
39331
39992
  api: () => deps.ctx().api ?? null,