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