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