@camstack/addon-admin-ui 1.2.38 → 1.2.40

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 (105) hide show
  1. package/dist/assets/{AddLocationWizard-Dt7Vz9Le.js → AddLocationWizard-_UpnD2Rb.js} +1 -1
  2. package/dist/assets/AddonCollectionPage-DsqpERSx.js +1 -0
  3. package/dist/assets/Addons-Cbzvs2ne.js +1 -0
  4. package/dist/assets/{AdminTabs-Ye2mzpqs.js → AdminTabs-C1b21Umm.js} +1 -1
  5. package/dist/assets/{AiProfiles-DqPxH0J5.js → AiProfiles-CxmKPOGN.js} +1 -1
  6. package/dist/assets/{BrokerForm-D3fTTpv3.js → BrokerForm-BxVQ7-k2.js} +1 -1
  7. package/dist/assets/Build-eERcQhc5.js +32 -0
  8. package/dist/assets/{Capabilities-Ce97mPD2.js → Capabilities-CkTJoG8P.js} +1 -1
  9. package/dist/assets/{CapabilityBadges-DtkhlewS.js → CapabilityBadges-Pi0oFNyA.js} +1 -1
  10. package/dist/assets/{Cluster-BG2luAid.js → Cluster-D7MeMUz9.js} +1 -1
  11. package/dist/assets/{ClusterRecordingTab-Htot8l7Z.js → ClusterRecordingTab-g0pESRbc.js} +1 -1
  12. package/dist/assets/{Dashboard-mWuskBWk.js → Dashboard-BOdpB8Zi.js} +1 -1
  13. package/dist/assets/Data-D_lbeA81.js +1 -0
  14. package/dist/assets/DetectionIntelligence-Dsj8oMfR.js +1 -0
  15. package/dist/assets/{DeviceAggregateSection-bI7kL3uM.js → DeviceAggregateSection-UayHxH_7.js} +1 -1
  16. package/dist/assets/DeviceDetail-2vx05ko3.js +2 -0
  17. package/dist/assets/{Devices-jtpgjbMp.js → Devices-Ctgv5UD9.js} +1 -1
  18. package/dist/assets/{EmbedPlayerPage-DuZxiSdE.js → EmbedPlayerPage-BH6rYrQY.js} +1 -1
  19. package/dist/assets/Events-GU0DqH5U.js +1 -0
  20. package/dist/assets/{FormBuilder-BILjZtIe.js → FormBuilder-4y2vfIFX.js} +1 -1
  21. package/dist/assets/{Identity-Crk0vUgk.js → Identity-lIu4PKp0.js} +1 -1
  22. package/dist/assets/{IntegrationDetail-DJurXzOC.js → IntegrationDetail-C0Zcxbgi.js} +1 -1
  23. package/dist/assets/{Integrations-ySnIH1Sd.js → Integrations-BP2jk3NT.js} +1 -1
  24. package/dist/assets/Integrations-CtXQURhT.js +1 -0
  25. package/dist/assets/{Logs-Bb1TGrRD.js → Logs-CjKk3Pid.js} +1 -1
  26. package/dist/assets/{MotionZonesSettings-BWWbwGHM.js → MotionZonesSettings-Lydieqfe.js} +1 -1
  27. package/dist/assets/{MyAccess-C2PAPIAg.js → MyAccess-W28I5kd-.js} +1 -1
  28. package/dist/assets/Network-Cnq4bp10.js +1 -0
  29. package/dist/assets/{NodeAddonsSettingsPanel-JNSINmAv.js → NodeAddonsSettingsPanel-BI-P31wN.js} +1 -1
  30. package/dist/assets/{NodeInferenceDevices-Cht6qC4t.js → NodeInferenceDevices-BmhhZKpR.js} +1 -1
  31. package/dist/assets/Notifications-BGKlEsy-.js +1 -0
  32. package/dist/assets/Pipeline-6vY94dNS.js +1 -0
  33. package/dist/assets/{Plates-HrXlNj-Q.js → Plates-CXXsVO_i.js} +1 -1
  34. package/dist/assets/{PrivacyMaskSettings-DXEaUNBs.js → PrivacyMaskSettings-Cm6_Ayev.js} +1 -1
  35. package/dist/assets/{ProviderIcon-KgF3V3AC.js → ProviderIcon-NBKDrcT9.js} +1 -1
  36. package/dist/assets/{Recordings-BnrqoOrs.js → Recordings-fIC-_Plr.js} +1 -1
  37. package/dist/assets/{ScanNetworkModal-Dxx2BKAi.js → ScanNetworkModal-BEJE3rpa.js} +1 -1
  38. package/dist/assets/{Settings-ButH6GlA.js → Settings-2DUxyOI7.js} +1 -1
  39. package/dist/assets/{Showroom-DLHqovib.js → Showroom-DsDBDxPb.js} +1 -1
  40. package/dist/assets/{Terminal-CHE7uMQo.js → Terminal-DsDxn595.js} +1 -1
  41. package/dist/assets/{_virtual_mf-localSharedImportMap___mfe_internal__admin_ui_host-KB1NgTYv.js → _virtual_mf-localSharedImportMap___mfe_internal__admin_ui_host-DuoUM9go.js} +1 -1
  42. package/dist/assets/{boxes-DXbr65aS.js → boxes-byknuFCy.js} +1 -1
  43. package/dist/assets/{build-cluster-graph-C_OKGVaH.js → build-cluster-graph-gWLixa2m.js} +2 -2
  44. package/dist/assets/circle-check-big-D8Wjv0ct.js +1 -0
  45. package/dist/assets/clock-ISV-5mJ1.js +1 -0
  46. package/dist/assets/{copy-DRrD5vDH.js → copy-DtilJJvc.js} +1 -1
  47. package/dist/assets/{debounced-invalidate-D66PzHBd.js → debounced-invalidate-DHj6wyxg.js} +1 -1
  48. package/dist/assets/{dist-DJVIQtZJ.js → dist-CL9jy06y.js} +1 -1
  49. package/dist/assets/dist-DwLd0mpq.js +345 -0
  50. package/dist/assets/{download-D3-L-PWN.js → download-Dj8UGwXy.js} +1 -1
  51. package/dist/assets/{face-bbox-D2xVdmEv.js → face-bbox-BFrLPrmw.js} +1 -1
  52. package/dist/assets/{hostInit-DWq68ula.js → hostInit-dir45U0P.js} +1 -1
  53. package/dist/assets/index-BMoYnIQI.js +4 -0
  54. package/dist/assets/index-DW7B7QJD.css +2 -0
  55. package/dist/assets/{key-round-D5V99ce_.js → key-round-DmdC7g1C.js} +1 -1
  56. package/dist/assets/{mf-entry-bootstrap-0-93f87015.js → mf-entry-bootstrap-0-7417bcdb.js} +2 -2
  57. package/dist/assets/{pencil-DMAfyiaP.js → pencil-CM0OcKoQ.js} +1 -1
  58. package/dist/assets/play-CBwgjWMW.js +1 -0
  59. package/dist/assets/{player-overlays-DTb-cTgy.js → player-overlays-CM9K8NbB.js} +1 -1
  60. package/dist/assets/plus-CByuXPqh.js +1 -0
  61. package/dist/assets/{radio-CykuQXCK.js → radio-C1-CuToc.js} +1 -1
  62. package/dist/assets/{refresh-cw-B0NcvZHF.js → refresh-cw-BCQ0uqSo.js} +1 -1
  63. package/dist/assets/remoteEntry-BtIghGso.js +1 -0
  64. package/dist/assets/rotate-ccw-CiIETsd8.js +1 -0
  65. package/dist/assets/rotate-cw-Bo4UhVCj.js +1 -0
  66. package/dist/assets/search-m5N8S4EI.js +1 -0
  67. package/dist/assets/{send-CtqhPev6.js → send-85b3c2pF.js} +1 -1
  68. package/dist/assets/{server-BbRPd6PC.js → server-CqHxFx4j.js} +1 -1
  69. package/dist/assets/square-CwYcDkPi.js +1 -0
  70. package/dist/assets/{square-check-big-p7HpHDXe.js → square-check-big-BFcA-8wr.js} +1 -1
  71. package/dist/assets/src-BaYQ7lHC.js +345 -0
  72. package/dist/assets/src-s1tm1Ako.js +67 -0
  73. package/dist/assets/{undo-2-BWEA1U6J.js → undo-2-DDQAKjyU.js} +1 -1
  74. package/dist/assets/{upload-CmfKc09s.js → upload-D0e5f6HZ.js} +1 -1
  75. package/dist/assets/{useEventInvalidation-R75mI5og.js → useEventInvalidation-BnSiq41g.js} +1 -1
  76. package/dist/assets/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__admin_ui_host__remoteEntry-_hash_-CeDNF2vl.js → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__admin_ui_host__remoteEntry-_hash_-1o9kxso_.js} +2 -2
  77. package/dist/assets/{wifi-CTmFh8-0.js → wifi-CkMq-H2F.js} +1 -1
  78. package/dist/assets/{zap-Cf83BCyl.js → zap-C0CZz-JF.js} +1 -1
  79. package/dist/index.html +6 -6
  80. package/dist/server/addon.js +1918 -1719
  81. package/package.json +1 -1
  82. package/dist/assets/AddonCollectionPage-6Aw2YAGA.js +0 -1
  83. package/dist/assets/Addons-CjZHq6IY.js +0 -1
  84. package/dist/assets/Build-DwyWB5rK.js +0 -29
  85. package/dist/assets/Data-rCQoSzSV.js +0 -1
  86. package/dist/assets/DetectionIntelligence-DlGH85sl.js +0 -1
  87. package/dist/assets/DeviceDetail-CWLhoPV9.js +0 -2
  88. package/dist/assets/Events-DUxFB5Bn.js +0 -1
  89. package/dist/assets/Integrations-BHcc42O_.js +0 -1
  90. package/dist/assets/Network-Cy3RUcrg.js +0 -1
  91. package/dist/assets/Notifications-D27LJgTU.js +0 -1
  92. package/dist/assets/Pipeline-Cf3BtloJ.js +0 -1
  93. package/dist/assets/circle-check-big-CDvvzYtx.js +0 -1
  94. package/dist/assets/clock-MA7PvDC7.js +0 -1
  95. package/dist/assets/dist-CTF62Sfi.js +0 -7
  96. package/dist/assets/index-dqzu-f71.js +0 -4
  97. package/dist/assets/index-mdJi1UIz.css +0 -2
  98. package/dist/assets/plus-DBb9xrVk.js +0 -1
  99. package/dist/assets/remoteEntry-vNWEaSPf.js +0 -1
  100. package/dist/assets/rotate-ccw-CpixHLR5.js +0 -1
  101. package/dist/assets/rotate-cw-DiU5mosj.js +0 -1
  102. package/dist/assets/search-871hv572.js +0 -1
  103. package/dist/assets/square-DX2ha0JW.js +0 -1
  104. package/dist/assets/src-BxX2FkJ3.js +0 -67
  105. package/dist/assets/src-CV0JUB8F.js +0 -7
@@ -7913,1026 +7913,1800 @@ var ConvertResultSchema = object({
7913
7913
  })).readonly()
7914
7914
  });
7915
7915
  /**
7916
- * `addon-pages` system-scoped singleton aggregator cap. Public-facing
7917
- * surface that admin-ui consumes through `useAddonPagesListPages()`.
7916
+ * Error types for the safe expression engine. Two distinct classes so callers
7917
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
7918
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
7919
+ */
7920
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
7921
+ * the failure is anchored to a character (author-facing inline feedback). */
7922
+ var ExpressionParseError = class extends Error {
7923
+ position;
7924
+ constructor(message, position) {
7925
+ super(message);
7926
+ this.name = "ExpressionParseError";
7927
+ this.position = position;
7928
+ }
7929
+ };
7930
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
7931
+ * result, unknown builtin, step-budget exceeded). */
7932
+ var ExpressionEvalError = class extends Error {
7933
+ constructor(message) {
7934
+ super(message);
7935
+ this.name = "ExpressionEvalError";
7936
+ }
7937
+ };
7938
+ /**
7939
+ * Frozen, null-prototype builtin function table for the expression engine
7940
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
7941
+ * parser rejects any callee not in it, and the evaluator gates each call on an
7942
+ * own-property check against it.
7918
7943
  *
7919
- * The provider iterates every `addon-pages-source` (collection) provider
7920
- * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
7921
- * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
7922
- * filesystem `mtime` cache-buster lets the browser pick up addon
7923
- * rebuilds without manual reload.
7944
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
7945
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
7946
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
7947
+ * (there is no `Object.prototype` in the chain), so those names are not
7948
+ * callable they are simply "unknown function" at parse time.
7924
7949
  *
7925
- * The hub-local builtin `addon-pages-aggregator` (see
7926
- * `@camstack/system/builtins/addon-pages-aggregator`) registers the
7927
- * provider. Splitting the public aggregator from the raw collection
7928
- * keeps both ends in codegen there's no hand-written
7929
- * `addon-pages.router.ts` wrapper anymore.
7950
+ * Every numeric argument is validated as a finite number and every numeric
7951
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
7952
+ * (`pow(10,400)` Infinity) all raise `ExpressionEvalError` and fail the link
7953
+ * closed rather than emitting a garbage value.
7930
7954
  */
7931
- var AddonPageDeclarationSchema$1 = object({
7932
- id: string(),
7933
- label: string(),
7934
- icon: string(),
7935
- path: string(),
7936
- remoteName: string(),
7937
- bundle: string(),
7938
- section: string().optional(),
7939
- sectionLabel: string().optional()
7940
- });
7941
- var AddonPageInfoSchema = object({
7942
- addonId: string(),
7943
- page: AddonPageDeclarationSchema$1,
7944
- bundleUrl: string()
7945
- });
7946
- method(_void(), array(AddonPageInfoSchema).readonly());
7955
+ function asFiniteNumber(value, name, index) {
7956
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
7957
+ return value;
7958
+ }
7959
+ function asString$1(value, name, index) {
7960
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
7961
+ return value;
7962
+ }
7963
+ function finiteResult(value, name) {
7964
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
7965
+ return value;
7966
+ }
7967
+ function allFiniteNumbers(args, name) {
7968
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
7969
+ }
7970
+ var INF = Number.POSITIVE_INFINITY;
7971
+ var table = {
7972
+ min: {
7973
+ minArgs: 1,
7974
+ maxArgs: INF,
7975
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
7976
+ },
7977
+ max: {
7978
+ minArgs: 1,
7979
+ maxArgs: INF,
7980
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
7981
+ },
7982
+ abs: {
7983
+ minArgs: 1,
7984
+ maxArgs: 1,
7985
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
7986
+ },
7987
+ floor: {
7988
+ minArgs: 1,
7989
+ maxArgs: 1,
7990
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
7991
+ },
7992
+ ceil: {
7993
+ minArgs: 1,
7994
+ maxArgs: 1,
7995
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
7996
+ },
7997
+ sqrt: {
7998
+ minArgs: 1,
7999
+ maxArgs: 1,
8000
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
8001
+ },
8002
+ round: {
8003
+ minArgs: 1,
8004
+ maxArgs: 2,
8005
+ apply: (args) => {
8006
+ const x = asFiniteNumber(args[0], "round", 0);
8007
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
8008
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
8009
+ const factor = 10 ** digits;
8010
+ return finiteResult(Math.round(x * factor) / factor, "round");
8011
+ }
8012
+ },
8013
+ pow: {
8014
+ minArgs: 2,
8015
+ maxArgs: 2,
8016
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
8017
+ },
8018
+ clamp: {
8019
+ minArgs: 3,
8020
+ maxArgs: 3,
8021
+ apply: (args) => {
8022
+ const x = asFiniteNumber(args[0], "clamp", 0);
8023
+ const lo = asFiniteNumber(args[1], "clamp", 1);
8024
+ const hi = asFiniteNumber(args[2], "clamp", 2);
8025
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
8026
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
8027
+ }
8028
+ },
8029
+ avg: {
8030
+ minArgs: 1,
8031
+ maxArgs: INF,
8032
+ apply: (args) => {
8033
+ const nums = allFiniteNumbers(args, "avg");
8034
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
8035
+ }
8036
+ },
8037
+ sum: {
8038
+ minArgs: 1,
8039
+ maxArgs: INF,
8040
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
8041
+ },
8042
+ coalesce: {
8043
+ minArgs: 1,
8044
+ maxArgs: INF,
8045
+ apply: (args) => {
8046
+ for (const a of args) if (a !== null) return a;
8047
+ return null;
8048
+ }
8049
+ },
8050
+ age: {
8051
+ minArgs: 2,
8052
+ maxArgs: 2,
8053
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
8054
+ },
8055
+ convert: {
8056
+ minArgs: 3,
8057
+ maxArgs: 3,
8058
+ apply: (args, hooks) => {
8059
+ const x = asFiniteNumber(args[0], "convert", 0);
8060
+ const from = asString$1(args[1], "convert", 1).trim();
8061
+ const to = asString$1(args[2], "convert", 2).trim();
8062
+ if (hooks.convert) {
8063
+ const out = hooks.convert(x, from, to);
8064
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
8065
+ return finiteResult(out, "convert");
8066
+ }
8067
+ if (from === to) return x;
8068
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
8069
+ }
8070
+ }
8071
+ };
8072
+ Object.freeze(Object.assign(Object.create(null), table));
8073
+ /** The set of valid builtin names — used by the parser to reject unknown
8074
+ * callees at parse time (immediate author feedback). */
8075
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
7947
8076
  /**
7948
- * `addon-pages-source` collection cap exposing per-provider raw page
7949
- * declarations. Every addon that contributes a UI page registers a
7950
- * provider here. The hub-side singleton aggregator (`addon-pages` cap,
7951
- * see `addon-pages.cap.ts`) walks this collection, stamps versioned
7952
- * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
7953
- * that admin-ui consumes.
8077
+ * Resource-bound constants for the safe expression engine.
7954
8078
  *
7955
- * The split exists because the public listing has a different output
7956
- * shape than the per-provider raw declarations, and we want both ends
7957
- * to flow through codegen instead of relying on a hand-written wrapper.
8079
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
8080
+ * loops, recursion, lambdas or member access see `ast.ts`), so evaluation is
8081
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
8082
+ * work a single author-supplied expression can request, so a hostile or
8083
+ * accidental pathological string can never spend unbounded CPU/memory.
7958
8084
  */
7959
- var AddonPageDeclarationSchema = object({
7960
- id: string(),
7961
- label: string(),
7962
- icon: string(),
7963
- path: string(),
7964
- /**
7965
- * Module Federation remote name must match the `name` field on the
7966
- * page addon's `federation()` plugin config. Used by admin-ui's
7967
- * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
7968
- * Conventionally `addon_<id>_page` (snake_case; MF names cannot
7969
- * contain hyphens).
7970
- */
7971
- remoteName: string(),
7972
- /**
7973
- * Bundle filename inside the addon's `dist/` dir served at
7974
- * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
7975
- * is always `'remoteEntry.js'`; the value is kept on the metadata so
7976
- * the static-file route can compute an mtime-based cache-buster URL
7977
- * without a separate filesystem stat.
7978
- */
7979
- bundle: string(),
7980
- /**
7981
- * Sidebar section this page docks into. Well-known ids: `'detection'`,
7982
- * `'cluster'`, `'administration'` — the page renders inside that group.
7983
- * Any OTHER string creates (or joins) a custom section rendered after
7984
- * the built-in groups; its label comes from `sectionLabel` (first
7985
- * declaration wins), falling back to the id. Absent → the legacy
7986
- * "Addon Pages" group.
7987
- */
7988
- section: string().optional(),
7989
- /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
7990
- sectionLabel: string().optional()
7991
- });
7992
- method(_void(), array(AddonPageDeclarationSchema).readonly());
7993
- var AddonHttpRouteSchema = object({
7994
- method: _enum([
7995
- "GET",
7996
- "POST",
7997
- "PUT",
7998
- "DELETE",
7999
- "PATCH"
8000
- ]),
8001
- path: string(),
8002
- access: _enum([
8003
- "public",
8004
- "authenticated",
8005
- "admin"
8006
- ]).optional(),
8007
- description: string().optional()
8008
- });
8009
- /**
8010
- * Cross-process route invocation envelope. The hub captures the
8011
- * request as plain data, ships it to the worker via Moleculer, and
8012
- * the worker runs the local handler against a capturing reply. The
8013
- * envelope returned describes what the handler intended (status,
8014
- * headers, body, or a redirect) so the hub can translate it back to
8015
- * the Fastify reply that's actually wired to the socket.
8085
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
8086
+ * rejected without allocation. */
8087
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
8088
+ /** A legal binding / identifier name. */
8089
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
8090
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
8091
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
8092
+ var RESERVED_BINDING_NAMES = new Set([
8093
+ "now",
8094
+ "true",
8095
+ "false",
8096
+ "null"
8097
+ ]);
8098
+ /**
8099
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
8100
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
8101
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
8102
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
8103
+ * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
8104
+ * is a parse error with a source position, so member access / assignment /
8105
+ * template literals are lexically impossible.
8016
8106
  */
8017
- var InvokeRequestSchema = object({
8018
- method: string(),
8019
- path: string(),
8020
- params: record(string(), string()),
8021
- query: record(string(), string()),
8022
- body: unknown(),
8023
- headers: record(string(), string()),
8024
- user: object({
8025
- id: string(),
8026
- username: string(),
8027
- isAdmin: boolean()
8028
- }).optional(),
8029
- scopedToken: unknown().optional()
8030
- });
8031
- var InvokeReplyEnvelopeSchema = object({
8032
- status: number().int(),
8033
- headers: record(string(), string()),
8034
- /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
8035
- * sending `body`. Status defaults to 302 when this is set unless
8036
- * the handler called `reply.code(...)` explicitly. */
8037
- redirectUrl: string().nullable(),
8038
- /** JSON-serializable body. `undefined` is treated as "no body". */
8039
- body: unknown().optional(),
8040
- /** Set when the handler called `reply.type(mime)`. */
8041
- contentType: string().optional()
8042
- });
8043
- method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
8044
- var ConfigTabDeclarationSchema = object({
8045
- id: string(),
8046
- label: string(),
8047
- icon: string(),
8048
- order: number().optional()
8049
- });
8050
- var ConfigSectionWithValuesSchema = object({
8051
- id: string(),
8052
- title: string(),
8053
- description: string().optional(),
8054
- style: _enum(["card", "accordion"]).optional(),
8055
- defaultCollapsed: boolean().optional(),
8056
- columns: union([
8057
- literal(1),
8058
- literal(2),
8059
- literal(3),
8060
- literal(4)
8061
- ]).optional(),
8062
- tab: string().optional(),
8063
- location: _enum(["settings", "top-tab"]).optional(),
8064
- order: number().optional(),
8065
- fields: array(any())
8066
- });
8067
- var SettingsSchemaWithValuesSchema = object({
8068
- tabs: array(ConfigTabDeclarationSchema).optional(),
8069
- sections: array(ConfigSectionWithValuesSchema)
8070
- });
8071
- /** Patch object — keys are field names, values are the new field values. */
8072
- var SettingsPatchSchema = record(string(), unknown());
8073
- /** Standard success response for update operations. */
8074
- var SettingsUpdateResultSchema = object({ success: literal(true) });
8075
- method(object({
8076
- addonId: string(),
8077
- nodeId: string().optional(),
8078
- overlay: record(string(), unknown()).optional(),
8079
- cap: string().optional()
8080
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8081
- addonId: string(),
8082
- nodeId: string().optional(),
8083
- patch: SettingsPatchSchema
8084
- }), SettingsUpdateResultSchema, {
8085
- kind: "mutation",
8086
- auth: "admin"
8087
- }), method(object({
8088
- addonId: string(),
8089
- deviceId: number(),
8090
- nodeId: string().optional()
8091
- }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8092
- addonId: string(),
8093
- deviceId: number(),
8094
- nodeId: string().optional(),
8095
- patch: SettingsPatchSchema
8096
- }), SettingsUpdateResultSchema, {
8097
- kind: "mutation",
8098
- auth: "admin"
8099
- });
8107
+ var KEYWORDS = new Set([
8108
+ "true",
8109
+ "false",
8110
+ "null"
8111
+ ]);
8112
+ function isDigit(ch) {
8113
+ return ch >= "0" && ch <= "9";
8114
+ }
8115
+ function isIdentStart(ch) {
8116
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
8117
+ }
8118
+ function isIdentPart(ch) {
8119
+ return isIdentStart(ch) || isDigit(ch);
8120
+ }
8121
+ function isWhitespace(ch) {
8122
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
8123
+ }
8124
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
8125
+ * Throws `ExpressionParseError` on any illegal character or unterminated
8126
+ * string. */
8127
+ function tokenize(source) {
8128
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
8129
+ const tokens = [];
8130
+ let i = 0;
8131
+ const n = source.length;
8132
+ while (i < n) {
8133
+ const ch = source[i];
8134
+ if (isWhitespace(ch)) {
8135
+ i += 1;
8136
+ continue;
8137
+ }
8138
+ if (isDigit(ch)) {
8139
+ const start = i;
8140
+ while (i < n && isDigit(source[i])) i += 1;
8141
+ if (i < n && source[i] === ".") {
8142
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
8143
+ i += 1;
8144
+ while (i < n && isDigit(source[i])) i += 1;
8145
+ }
8146
+ const text = source.slice(start, i);
8147
+ const value = Number(text);
8148
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
8149
+ tokens.push({
8150
+ type: "number",
8151
+ value,
8152
+ pos: start
8153
+ });
8154
+ continue;
8155
+ }
8156
+ if (ch === "'" || ch === "\"") {
8157
+ const quote = ch;
8158
+ const start = i;
8159
+ i += 1;
8160
+ let out = "";
8161
+ let closed = false;
8162
+ while (i < n) {
8163
+ const c = source[i];
8164
+ if (c === "\\") {
8165
+ const next = i + 1 < n ? source[i + 1] : "";
8166
+ if (next === "\\" || next === "'" || next === "\"") {
8167
+ out += next;
8168
+ i += 2;
8169
+ continue;
8170
+ }
8171
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
8172
+ }
8173
+ if (c === quote) {
8174
+ closed = true;
8175
+ i += 1;
8176
+ break;
8177
+ }
8178
+ out += c;
8179
+ i += 1;
8180
+ }
8181
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
8182
+ tokens.push({
8183
+ type: "string",
8184
+ value: out,
8185
+ pos: start
8186
+ });
8187
+ continue;
8188
+ }
8189
+ if (isIdentStart(ch)) {
8190
+ const start = i;
8191
+ while (i < n && isIdentPart(source[i])) i += 1;
8192
+ const text = source.slice(start, i);
8193
+ if (KEYWORDS.has(text)) tokens.push({
8194
+ type: "keyword",
8195
+ keyword: keywordOf(text),
8196
+ pos: start
8197
+ });
8198
+ else tokens.push({
8199
+ type: "identifier",
8200
+ name: text,
8201
+ pos: start
8202
+ });
8203
+ continue;
8204
+ }
8205
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
8206
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
8207
+ tokens.push({
8208
+ type: "punct",
8209
+ punct: two,
8210
+ pos: i
8211
+ });
8212
+ i += 2;
8213
+ continue;
8214
+ }
8215
+ if (isSinglePunct(ch)) {
8216
+ tokens.push({
8217
+ type: "punct",
8218
+ punct: ch,
8219
+ pos: i
8220
+ });
8221
+ i += 1;
8222
+ continue;
8223
+ }
8224
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
8225
+ }
8226
+ tokens.push({
8227
+ type: "eof",
8228
+ pos: n
8229
+ });
8230
+ return tokens;
8231
+ }
8232
+ function keywordOf(text) {
8233
+ if (text === "true") return "true";
8234
+ if (text === "false") return "false";
8235
+ return "null";
8236
+ }
8237
+ function isSinglePunct(ch) {
8238
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
8239
+ }
8100
8240
  /**
8101
- * `addon-widgets-source` collection cap exposing per-addon raw widget
8102
- * declarations. Mirrors the addon-pages split: every addon shipping
8103
- * widgets registers a provider on this collection cap; the hub-local
8104
- * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
8105
- * collection, stamps versioned `bundleUrl`s onto each declaration, and
8106
- * exposes the public listing surface that admin-ui consumes.
8241
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
8107
8242
  *
8108
- * The split exists because the public listing has a different output
8109
- * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
8110
- * per-provider raw declarations. Both ends flow through codegen.
8243
+ * Precedence (low high): ternary `?:` (right-assoc) `||` `&&` → equality
8244
+ * relational additive multiplicative unary `! -` → call / primary.
8245
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
8246
+ * string validated against the builtin table at parse time, so an unknown
8247
+ * function is rejected immediately (author feedback) and a persisted expression
8248
+ * that references a since-removed builtin degrades at read.
8111
8249
  *
8112
- * Unified UI-contribution model (Task 10): a widget descriptor IS a
8113
- * `UiContribution` with `kind:'remote'`. The host renders it through the
8114
- * same `ContributionRenderer` / Module-Federation path as every other
8115
- * contributed UI surface — no bespoke widget-rendering path. The widget-
8116
- * only metadata (sizing hints, `requires`) lives as extra fields on the
8117
- * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
8118
- * `kind` / `remote`) carries identity + placement + the MF remote.
8119
- */
8120
- /** Where the widget makes sense to render — maps to a contribution `tab`. */
8121
- var WidgetHostEnum = _enum([
8122
- "device-tab",
8123
- "dashboard",
8124
- "integration-detail"
8125
- ]);
8126
- var WidgetSizeEnum = _enum([
8127
- "xs",
8128
- "sm",
8129
- "md",
8130
- "lg",
8131
- "xl"
8132
- ]);
8133
- /**
8134
- * MF remote descriptor — mirrors `UiContributionRemote` from
8135
- * `capability-definition.ts`. Widget remotes expose a single
8136
- * `'./widgets'` module whose default export is a
8137
- * `Record<componentKey, Component>` map; `componentKey` (the widget
8138
- * `stableId`) picks the entry the host mounts.
8250
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
8251
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) both raise `ExpressionParseError`.
8139
8252
  */
8140
- var WidgetRemoteSchema = object({
8141
- remoteName: string(),
8142
- exposedModule: string(),
8143
- componentKey: string().optional()
8144
- });
8253
+ /** Binary/logical operator precedence (higher binds tighter). */
8254
+ var BINARY_PRECEDENCE = {
8255
+ "||": 1,
8256
+ "&&": 2,
8257
+ "==": 3,
8258
+ "!=": 3,
8259
+ "<": 4,
8260
+ "<=": 4,
8261
+ ">": 4,
8262
+ ">=": 4,
8263
+ "+": 5,
8264
+ "-": 5,
8265
+ "*": 6,
8266
+ "/": 6,
8267
+ "%": 6
8268
+ };
8269
+ function isLogicalOp(op) {
8270
+ return op === "&&" || op === "||";
8271
+ }
8272
+ function isBinaryOp(op) {
8273
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
8274
+ }
8275
+ var Parser = class {
8276
+ tokens;
8277
+ pos = 0;
8278
+ nodeCount = 0;
8279
+ identifiers = /* @__PURE__ */ new Set();
8280
+ callees = /* @__PURE__ */ new Set();
8281
+ constructor(tokens) {
8282
+ this.tokens = tokens;
8283
+ }
8284
+ parse() {
8285
+ const ast = this.parseTernary();
8286
+ const tok = this.peek();
8287
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
8288
+ return {
8289
+ ast,
8290
+ identifiers: this.identifiers,
8291
+ callees: this.callees,
8292
+ nodeCount: this.nodeCount
8293
+ };
8294
+ }
8295
+ peek() {
8296
+ return this.tokens[this.pos];
8297
+ }
8298
+ next() {
8299
+ return this.tokens[this.pos++];
8300
+ }
8301
+ /** Consume a punctuator token, erroring if the next token isn't it. */
8302
+ expectPunct(punct) {
8303
+ const tok = this.peek();
8304
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
8305
+ this.pos += 1;
8306
+ }
8307
+ matchPunct(punct) {
8308
+ const tok = this.peek();
8309
+ if (tok.type === "punct" && tok.punct === punct) {
8310
+ this.pos += 1;
8311
+ return true;
8312
+ }
8313
+ return false;
8314
+ }
8315
+ countNode() {
8316
+ this.nodeCount += 1;
8317
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
8318
+ }
8319
+ parseTernary() {
8320
+ const test = this.parseBinary(1);
8321
+ if (this.matchPunct("?")) {
8322
+ const consequent = this.parseTernary();
8323
+ this.expectPunct(":");
8324
+ const alternate = this.parseTernary();
8325
+ this.countNode();
8326
+ return {
8327
+ kind: "conditional",
8328
+ test,
8329
+ consequent,
8330
+ alternate
8331
+ };
8332
+ }
8333
+ return test;
8334
+ }
8335
+ parseBinary(minPrec) {
8336
+ let left = this.parseUnary();
8337
+ for (;;) {
8338
+ const tok = this.peek();
8339
+ if (tok.type !== "punct") break;
8340
+ const prec = BINARY_PRECEDENCE[tok.punct];
8341
+ if (prec === void 0 || prec < minPrec) break;
8342
+ const op = tok.punct;
8343
+ this.pos += 1;
8344
+ const right = this.parseBinary(prec + 1);
8345
+ this.countNode();
8346
+ if (isLogicalOp(op)) left = {
8347
+ kind: "logical",
8348
+ op,
8349
+ left,
8350
+ right
8351
+ };
8352
+ else if (isBinaryOp(op)) left = {
8353
+ kind: "binary",
8354
+ op,
8355
+ left,
8356
+ right
8357
+ };
8358
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
8359
+ }
8360
+ return left;
8361
+ }
8362
+ parseUnary() {
8363
+ const tok = this.peek();
8364
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
8365
+ const op = tok.punct;
8366
+ this.pos += 1;
8367
+ const operand = this.parseUnary();
8368
+ this.countNode();
8369
+ return {
8370
+ kind: "unary",
8371
+ op,
8372
+ operand
8373
+ };
8374
+ }
8375
+ return this.parsePrimary();
8376
+ }
8377
+ parsePrimary() {
8378
+ const tok = this.next();
8379
+ switch (tok.type) {
8380
+ case "number":
8381
+ this.countNode();
8382
+ return {
8383
+ kind: "literal",
8384
+ value: tok.value
8385
+ };
8386
+ case "string":
8387
+ this.countNode();
8388
+ return {
8389
+ kind: "literal",
8390
+ value: tok.value
8391
+ };
8392
+ case "keyword":
8393
+ this.countNode();
8394
+ return {
8395
+ kind: "literal",
8396
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
8397
+ };
8398
+ case "identifier": {
8399
+ const nextTok = this.peek();
8400
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
8401
+ this.identifiers.add(tok.name);
8402
+ this.countNode();
8403
+ return {
8404
+ kind: "identifier",
8405
+ name: tok.name
8406
+ };
8407
+ }
8408
+ case "punct":
8409
+ if (tok.punct === "(") {
8410
+ const inner = this.parseTernary();
8411
+ this.expectPunct(")");
8412
+ return inner;
8413
+ }
8414
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
8415
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
8416
+ }
8417
+ }
8418
+ parseCall(callee, pos) {
8419
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
8420
+ this.expectPunct("(");
8421
+ const args = [];
8422
+ if (!this.matchPunct(")")) for (;;) {
8423
+ args.push(this.parseTernary());
8424
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
8425
+ if (this.matchPunct(",")) continue;
8426
+ this.expectPunct(")");
8427
+ break;
8428
+ }
8429
+ this.callees.add(callee);
8430
+ this.countNode();
8431
+ return {
8432
+ kind: "call",
8433
+ callee,
8434
+ args
8435
+ };
8436
+ }
8437
+ };
8438
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
8439
+ * `ExpressionParseError` on any lexical or grammatical failure. */
8440
+ function parseExpression(source) {
8441
+ return new Parser(tokenize(source)).parse();
8442
+ }
8145
8443
  /**
8146
- * One widget declaration a `UiContribution` (`kind:'remote'`) plus
8147
- * widget-only metadata. The `UiContribution` core fields:
8148
- *
8149
- * - `tab` — where the widget hosts. A widget that runs on the
8150
- * dashboard declares `tab:'dashboard'`; a device-tab
8151
- * widget declares the target device-detail tab id.
8152
- * - `subTab` — optional sub-tab within `tab`.
8153
- * - `label` — operator-facing label.
8154
- * - `order` — ordering within `(tab, subTab)`.
8155
- * - `kind` — always `'remote'` for widgets.
8156
- * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
8157
- *
8158
- * Widget-only fields retained alongside the contribution core:
8444
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
8445
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
8446
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
8447
+ * one per read on a hot resolve path.
8159
8448
  *
8160
- * - `stableId` — stable identity within the addon (the MF
8161
- * `componentKey`; kept top-level so consumers have
8162
- * a stable key without reaching into `remote`).
8163
- * - `description` / `icon` — picker metadata.
8164
- * - `bundle` — entry filename inside the addon `dist/` dir; the
8165
- * aggregator stamps a versioned `bundleUrl` from it.
8166
- * - `hosts` — every host the widget supports (a widget can run
8167
- * both on the dashboard and a device tab). `tab`
8168
- * is the PRIMARY host; `hosts` is the full set the
8169
- * picker filters on.
8170
- * - `requires` — host-context requirements validated at mount.
8171
- * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
8172
- * — dashboard placement hints.
8449
+ * The cache is a module-level singleton: entries are pure, content-addressed
8450
+ * ASTs keyed by the raw source string, so sharing one instance across all
8451
+ * callers is safe and maximises hit rate.
8173
8452
  */
8174
- var WidgetMetadataSchema = object({
8175
- /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
8176
- tab: string(),
8177
- /** Optional sub-tab within `tab`. */
8178
- subTab: string().optional(),
8179
- /** Operator-facing label. */
8180
- label: string(),
8181
- /** Ordering within `(tab, subTab)`, ascending. */
8182
- order: number().optional(),
8183
- /** Always `'remote'` — a widget is a Module Federation remote. */
8184
- kind: literal("remote"),
8185
- /** MF remote descriptor. */
8186
- remote: WidgetRemoteSchema,
8187
- /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
8188
- stableId: string(),
8189
- description: string().optional(),
8190
- icon: string().optional(),
8191
- /**
8192
- * Bundle filename inside the addon's `dist/` dir served at
8193
- * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
8194
- * this is always `'remoteEntry.js'` — the value is kept on the
8195
- * metadata so the static-file route can compute an mtime-based
8196
- * cache-buster URL without a separate filesystem stat.
8197
- */
8198
- bundle: string(),
8199
- /** Every host the widget supports. The picker filters on this set. */
8200
- hosts: array(WidgetHostEnum).readonly(),
8201
- /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
8202
- requires: object({
8203
- deviceContext: boolean().default(false),
8204
- integrationContext: boolean().default(false)
8453
+ var cache = /* @__PURE__ */ new Map();
8454
+ function getCached(source) {
8455
+ const hit = cache.get(source);
8456
+ if (hit !== void 0) {
8457
+ cache.delete(source);
8458
+ cache.set(source, hit);
8459
+ return hit;
8460
+ }
8461
+ let result;
8462
+ try {
8463
+ result = {
8464
+ ok: true,
8465
+ parsed: parseExpression(source)
8466
+ };
8467
+ } catch (err) {
8468
+ result = {
8469
+ ok: false,
8470
+ error: err instanceof ExpressionParseError ? err.message : String(err)
8471
+ };
8472
+ }
8473
+ cache.set(source, result);
8474
+ if (cache.size > 256) {
8475
+ const oldest = cache.keys().next().value;
8476
+ if (oldest !== void 0) cache.delete(oldest);
8477
+ }
8478
+ return result;
8479
+ }
8480
+ /** Compile `source`, returning a discriminated result instead of throwing.
8481
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
8482
+ function compileExpressionSafe(source) {
8483
+ return getCached(source);
8484
+ }
8485
+ Object.freeze({});
8486
+ /**
8487
+ * Author-time validation. Returns `null` when the source is valid, else a
8488
+ * human-readable error message. Checks: the expression compiles; binding count
8489
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
8490
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
8491
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
8492
+ */
8493
+ function validateExpressionSource(src) {
8494
+ const names = Object.keys(src.bindings);
8495
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
8496
+ for (const name of names) {
8497
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
8498
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
8499
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
8500
+ }
8501
+ const compiled = compileExpressionSafe(src.expr);
8502
+ if (!compiled.ok) return compiled.error;
8503
+ const bound = new Set(names);
8504
+ for (const id of compiled.parsed.identifiers) {
8505
+ if (id === "now") continue;
8506
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
8507
+ }
8508
+ return null;
8509
+ }
8510
+ var ExpressionBindingSourceSchema = union([
8511
+ object({
8512
+ kind: literal("field").optional(),
8513
+ sourceKey: string(),
8514
+ cap: string(),
8515
+ fieldPath: string()
8205
8516
  }),
8206
- /**
8207
- * Loadable BEFORE authentication. The normal widget registry listing
8208
- * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
8209
- * (the login page) cannot discover a widget through it. A widget that
8210
- * declares `preAuth: true` marks itself as safe to mount on a pre-auth
8211
- * screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
8212
- * login-method contribution channel (see `login-method.cap.ts`) rather
8213
- * than the authenticated registry, and its bundle is served by the
8214
- * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
8215
- */
8216
- preAuth: boolean().optional().default(false),
8217
- /** Dashboard placement HINTS (operator can override per instance). */
8218
- defaultSize: WidgetSizeEnum.default("md"),
8219
- allowedSizes: array(WidgetSizeEnum).readonly().default([
8220
- "sm",
8221
- "md",
8222
- "lg"
8223
- ]),
8224
- defaultColumns: number().int().min(1).max(12).default(6),
8225
- defaultRows: number().int().min(1).max(12).default(1)
8517
+ object({
8518
+ kind: literal("literal"),
8519
+ value: union([
8520
+ string(),
8521
+ number(),
8522
+ boolean(),
8523
+ _null()
8524
+ ])
8525
+ }),
8526
+ object({
8527
+ kind: literal("global"),
8528
+ sourceStableId: string(),
8529
+ cap: string(),
8530
+ fieldPath: string()
8531
+ })
8532
+ ]);
8533
+ object({
8534
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
8535
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
8536
+ }).superRefine((src, ctx) => {
8537
+ const err = validateExpressionSource(src);
8538
+ if (err !== null) ctx.addIssue({
8539
+ code: "custom",
8540
+ message: err,
8541
+ path: ["expr"]
8542
+ });
8543
+ });
8544
+ /** How a leaf compares a device field to a value. Derived from the field's
8545
+ * `kind` in `deviceManager.getWireableFields`, never hand-maintained. */
8546
+ var AutomationConditionOperatorSchema = _enum([
8547
+ "eq",
8548
+ "ne",
8549
+ "gt",
8550
+ "gte",
8551
+ "lt",
8552
+ "lte",
8553
+ "contains",
8554
+ "in"
8555
+ ]);
8556
+ var AutomationConditionLeafSchema = object({
8557
+ kind: literal("condition"),
8558
+ deviceId: number().int().nonnegative(),
8559
+ cap: string().min(1),
8560
+ fieldPath: string().min(1),
8561
+ operator: AutomationConditionOperatorSchema,
8562
+ value: union([
8563
+ string(),
8564
+ number(),
8565
+ boolean(),
8566
+ array(union([string(), number()]))
8567
+ ])
8226
8568
  });
8227
- method(_void(), array(WidgetMetadataSchema).readonly());
8228
8569
  /**
8229
- * `addon-widgets` system-scoped singleton aggregator cap. Public-facing
8230
- * surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
8231
- *
8232
- * The provider iterates every `addon-widgets-source` (collection)
8233
- * provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
8234
- * `bundleUrl` strings pointing at
8235
- * `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
8236
- * `mtime` cache-buster lets the browser pick up addon rebuilds without
8237
- * manual reload — same scheme used by `addon-pages`.
8238
- *
8239
- * The hub-local builtin `addon-widgets-aggregator` (see
8240
- * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
8241
- * provider. Splitting the public aggregator from the raw collection
8242
- * keeps both ends in codegen — there's no hand-written wrapper.
8570
+ * The expression leaf, declared as a plain object rather than an intersection
8571
+ * with {@link ExpressionSourceSchema}: a discriminated union has to be able to
8572
+ * read `kind` off each option, and an intersection hides it. The author-time
8573
+ * validation is the SAME function `ExpressionSourceSchema` runs, so the two
8574
+ * cannot drift an expression that one accepts, the other accepts.
8243
8575
  */
8244
- var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
8245
- addonId: string(),
8246
- bundleUrl: string()
8576
+ var AutomationConditionExpressionSchema = object({
8577
+ kind: literal("expression"),
8578
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
8579
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
8580
+ }).superRefine((src, ctx) => {
8581
+ const err = validateExpressionSource(src);
8582
+ if (err !== null) ctx.addIssue({
8583
+ code: "custom",
8584
+ message: err,
8585
+ path: ["expr"]
8586
+ });
8247
8587
  });
8248
- method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
8588
+ var AutomationConditionSchema = lazy(() => discriminatedUnion("kind", [
8589
+ object({
8590
+ kind: literal("all"),
8591
+ children: array(AutomationConditionSchema)
8592
+ }),
8593
+ object({
8594
+ kind: literal("any"),
8595
+ children: array(AutomationConditionSchema)
8596
+ }),
8597
+ object({
8598
+ kind: literal("not"),
8599
+ child: AutomationConditionSchema
8600
+ }),
8601
+ AutomationConditionLeafSchema,
8602
+ AutomationConditionExpressionSchema
8603
+ ]));
8249
8604
  /**
8250
- * Alerts capability collection-based internal alert system.
8605
+ * What starts a run.
8251
8606
  *
8252
- * Multiple providers can register. Each provider filters by EventBus category
8253
- * and creates/updates alerts. The built-in Alert Center addon persists alerts
8254
- * in the DB and serves them to the admin UI.
8607
+ * D8 compliance, and it is the reason `device-state` is not merely an event
8608
+ * subscription: the trigger evaluates against the **state mirror**, which is
8609
+ * reconciled, and an event only WAKES the evaluation. A dropped event therefore
8610
+ * DELAYS a trigger; it does not lose it. `schedule` uses `croner` — the one
8611
+ * already in the repo — because `setInterval(24h)` drifts and "at 23:30" does
8612
+ * not.
8255
8613
  */
8256
- var AlertSeveritySchema = _enum([
8257
- "info",
8258
- "success",
8259
- "warning",
8260
- "error"
8614
+ var AutomationTriggerSchema = discriminatedUnion("kind", [
8615
+ object({
8616
+ kind: literal("device-state"),
8617
+ deviceId: number().int().nonnegative(),
8618
+ cap: string().min(1),
8619
+ fieldPath: string().min(1),
8620
+ /** Fire when the field takes this value. Omit to fire on any change. */
8621
+ becomes: union([
8622
+ string(),
8623
+ number(),
8624
+ boolean()
8625
+ ]).optional(),
8626
+ /** Only on a CHANGE of value, not on every re-report. */
8627
+ edge: boolean().optional(),
8628
+ /** The condition must hold this long before the run starts. */
8629
+ forMs: number().int().min(0).max(864e5).optional(),
8630
+ /** Collapse a burst into one run. */
8631
+ debounceMs: number().int().min(0).max(6e5).optional()
8632
+ }),
8633
+ object({
8634
+ kind: literal("device-event"),
8635
+ /** An `EventCategory` value. */
8636
+ category: string().min(1),
8637
+ deviceId: number().int().nonnegative().optional()
8638
+ }),
8639
+ object({
8640
+ kind: literal("schedule"),
8641
+ cron: string().min(1).max(120)
8642
+ }),
8643
+ object({ kind: literal("manual") })
8261
8644
  ]);
8262
- var AlertStatusSchema = _enum([
8263
- "active",
8264
- "in-progress",
8265
- "completed",
8266
- "failed",
8267
- "dismissed"
8645
+ /**
8646
+ * One action step.
8647
+ *
8648
+ * `wait` and `cap` are `NcRuleActionSchema`'s two members, kept structurally
8649
+ * identical so `NcRuleActionRunner` runs them unchanged — its device-scope
8650
+ * check, stop-at-first-failure and per-sequence throttle are the whole reason
8651
+ * to reuse it, and none of them are re-implemented here.
8652
+ *
8653
+ * **The one divergence, and it is forced.** `NcRuleActionSchema.cap.deviceId` is
8654
+ * a literal `z.number().int()`, and the NC runner's own `RunSequencesInput`
8655
+ * documents its subject device as *"for the log tag, never for routing"*. So an
8656
+ * NC action can never target the device that triggered it — which is fine for
8657
+ * the NC (its rules already scope to a device) and fatal for an automation
8658
+ * ("sound the siren of the camera that saw the person"). `deviceId` therefore
8659
+ * also accepts `{ $var }`, resolved from the run's `vars` bag BEFORE the runner
8660
+ * is called. The runner still receives a number and is untouched; the
8661
+ * resolution is the recipe's job, not the runner's.
8662
+ */
8663
+ var AutomationActionSchema = discriminatedUnion("kind", [
8664
+ object({
8665
+ kind: literal("wait"),
8666
+ seconds: number().min(0).max(300)
8667
+ }),
8668
+ object({
8669
+ kind: literal("cap"),
8670
+ deviceId: union([number().int(), object({ $var: string().min(1) })]),
8671
+ cap: string().min(1),
8672
+ method: string().min(1),
8673
+ /** Values may carry `{{vars.x}}` slots, which SUBSTITUTE and do not
8674
+ * evaluate (§3.2.3). Anything beyond substitution is the expression leaf. */
8675
+ args: record(string(), unknown()).optional()
8676
+ }),
8677
+ object({
8678
+ kind: literal("code"),
8679
+ /** Compiled into the automation's OWN block by esbuild — not a third
8680
+ * runtime, not a `vm`, and not dynamically evaluated. */
8681
+ code: string().min(1).max(2e4)
8682
+ })
8268
8683
  ]);
8269
- var AlertSourceSchema = object({
8270
- type: string(),
8271
- id: string()
8684
+ object({
8685
+ triggers: array(AutomationTriggerSchema),
8686
+ conditions: AutomationConditionSchema.optional(),
8687
+ actions: array(AutomationActionSchema)
8272
8688
  });
8273
- var AlertSchema = object({
8689
+ /**
8690
+ * `addon-pages` — system-scoped singleton aggregator cap. Public-facing
8691
+ * surface that admin-ui consumes through `useAddonPagesListPages()`.
8692
+ *
8693
+ * The provider iterates every `addon-pages-source` (collection) provider
8694
+ * and emits `AddonPageInfo[]` enriched with versioned `bundleUrl` strings
8695
+ * pointing at `/api/addon-pages/<addonId>/<bundle>?v=<mtime>`. The
8696
+ * filesystem `mtime` cache-buster lets the browser pick up addon
8697
+ * rebuilds without manual reload.
8698
+ *
8699
+ * The hub-local builtin `addon-pages-aggregator` (see
8700
+ * `@camstack/system/builtins/addon-pages-aggregator`) registers the
8701
+ * provider. Splitting the public aggregator from the raw collection
8702
+ * keeps both ends in codegen — there's no hand-written
8703
+ * `addon-pages.router.ts` wrapper anymore.
8704
+ */
8705
+ var AddonPageDeclarationSchema$1 = object({
8274
8706
  id: string(),
8275
- category: string(),
8276
- severity: AlertSeveritySchema,
8277
- title: string(),
8278
- message: string(),
8279
- status: AlertStatusSchema,
8280
- progress: number().optional(),
8281
- read: boolean(),
8282
- createdAt: number(),
8283
- updatedAt: number(),
8284
- source: AlertSourceSchema.optional(),
8285
- metadata: record(string(), unknown()).optional()
8707
+ label: string(),
8708
+ icon: string(),
8709
+ path: string(),
8710
+ remoteName: string(),
8711
+ bundle: string(),
8712
+ section: string().optional(),
8713
+ sectionLabel: string().optional()
8286
8714
  });
8287
- method(AlertSchema, _void(), { kind: "mutation" }), method(object({
8288
- alertId: string(),
8289
- patch: AlertSchema.partial()
8290
- }), _void(), { kind: "mutation" }), method(object({
8291
- unreadOnly: boolean().optional(),
8292
- limit: number().optional()
8293
- }).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" });
8294
- DeviceType.Camera, method(object({ deviceId: number() }), custom()), object({
8295
- deviceId: number(),
8296
- rms: number(),
8297
- dbfs: number()
8298
- });
8299
- /** Shared Zod schemas used across detection capabilities. */
8300
- /**
8301
- * Canonical frame-format enum mirrored on `FrameFormat` in
8302
- * `packages/types/src/types/io.ts`. Kept inline (vs imported) so the
8303
- * Zod runtime schema and TypeScript type stay in sync at the call site
8304
- * — adding a new format requires changing both this enum and the
8305
- * `FrameFormat` type alias together.
8306
- */
8307
- var FrameFormatSchema = _enum([
8308
- "jpeg",
8309
- "rgb",
8310
- "bgr",
8311
- "yuv420",
8312
- "gray"
8313
- ]);
8314
- var FrameInputSchema = object({
8315
- data: custom(),
8316
- format: FrameFormatSchema,
8317
- width: number(),
8318
- height: number(),
8319
- timestamp: number()
8320
- });
8321
- var BoundingBoxSchema = object({
8322
- x: number(),
8323
- y: number(),
8324
- w: number(),
8325
- h: number()
8326
- });
8327
- object({
8328
- class: string(),
8329
- originalClass: string(),
8330
- score: number(),
8331
- bbox: BoundingBoxSchema
8715
+ var AddonPageInfoSchema = object({
8716
+ addonId: string(),
8717
+ page: AddonPageDeclarationSchema$1,
8718
+ bundleUrl: string()
8332
8719
  });
8720
+ method(_void(), array(AddonPageInfoSchema).readonly());
8333
8721
  /**
8334
- * `data` carries the raw f32le bytes of the PCM samples (4 bytes per
8335
- * sample, little-endian IEEE 754). `Uint8Array` is the wire-safe choice
8336
- * because `@msgpack/msgpack` serialises it as a MsgPack `bin` type that
8337
- * round-trips losslessly over the UDS transport. `Float32Array` is NOT
8338
- * preserved the encoder serialises it as `bin` (its raw bytes) but
8339
- * the decoder returns `Uint8Array`, so treating a `Float32Array` as the
8340
- * wire type causes receivers to read bytes as sample values, producing
8341
- * wildly wrong RMS/dBFS results (~+43 dBFS instead of ≤0).
8722
+ * `addon-pages-source` collection cap exposing per-provider raw page
8723
+ * declarations. Every addon that contributes a UI page registers a
8724
+ * provider here. The hub-side singleton aggregator (`addon-pages` cap,
8725
+ * see `addon-pages.cap.ts`) walks this collection, stamps versioned
8726
+ * `bundleUrl` values, and returns the enriched `AddonPageInfo[]` list
8727
+ * that admin-ui consumes.
8342
8728
  *
8343
- * Callers that need float arithmetic reconstruct the view with:
8344
- * `new Float32Array(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength / 4)`
8729
+ * The split exists because the public listing has a different output
8730
+ * shape than the per-provider raw declarations, and we want both ends
8731
+ * to flow through codegen instead of relying on a hand-written wrapper.
8345
8732
  */
8346
- var AudioChunkInputSchema = object({
8347
- data: _instanceof(Uint8Array),
8348
- sampleRate: number(),
8349
- channels: number(),
8350
- timestamp: number(),
8351
- /** Originating device id — used by the classifier for per-camera concurrency tracking. */
8352
- deviceId: number().optional()
8353
- });
8354
- var AudioLevelSchema = object({
8355
- rms: number(),
8356
- dbfs: number()
8357
- });
8358
- var AudioClassificationLabelSchema = object({
8733
+ var AddonPageDeclarationSchema = object({
8734
+ id: string(),
8735
+ label: string(),
8736
+ icon: string(),
8737
+ path: string(),
8359
8738
  /**
8360
- * Primary display class. Depending on how the label was produced
8361
- * this is either the macro category (e.g. `dog`) or the raw
8362
- * backend label (e.g. `Dog bark`). Mirrors `class` on
8363
- * `SpatialDetection`.
8739
+ * Module Federation remote name must match the `name` field on the
8740
+ * page addon's `federation()` plugin config. Used by admin-ui's
8741
+ * `<AddonPageLoader>` to call `loadRemote('<remoteName>/page')`.
8742
+ * Conventionally `addon_<id>_page` (snake_case; MF names cannot
8743
+ * contain hyphens).
8364
8744
  */
8365
- className: string(),
8745
+ remoteName: string(),
8366
8746
  /**
8367
- * Raw backend-native label the classifier actually emitted (e.g.
8368
- * `Dog bark` for YAMNet, `dog_bark` for Apple SoundAnalysis). For
8369
- * macro-aggregated entries this is the top raw contributor. Mirrors
8370
- * `originalClass` on `SpatialDetection`.
8747
+ * Bundle filename inside the addon's `dist/` dir served at
8748
+ * `/api/addon-pages/<addonId>/<bundle>`. With Module Federation this
8749
+ * is always `'remoteEntry.js'`; the value is kept on the metadata so
8750
+ * the static-file route can compute an mtime-based cache-buster URL
8751
+ * without a separate filesystem stat.
8371
8752
  */
8372
- originalClass: string().optional(),
8373
- score: number()
8753
+ bundle: string(),
8754
+ /**
8755
+ * Sidebar section this page docks into. Well-known ids: `'detection'`,
8756
+ * `'cluster'`, `'administration'` — the page renders inside that group.
8757
+ * Any OTHER string creates (or joins) a custom section rendered after
8758
+ * the built-in groups; its label comes from `sectionLabel` (first
8759
+ * declaration wins), falling back to the id. Absent → the legacy
8760
+ * "Addon Pages" group.
8761
+ */
8762
+ section: string().optional(),
8763
+ /** Display label for a CUSTOM `section` id (ignored for well-known ids). */
8764
+ sectionLabel: string().optional()
8374
8765
  });
8375
- var AudioAnalysisResultSchema = object({
8376
- level: AudioLevelSchema,
8377
- classification: object({
8378
- labels: array(AudioClassificationLabelSchema).readonly(),
8379
- inferenceMs: number()
8766
+ method(_void(), array(AddonPageDeclarationSchema).readonly());
8767
+ var AddonHttpRouteSchema = object({
8768
+ method: _enum([
8769
+ "GET",
8770
+ "POST",
8771
+ "PUT",
8772
+ "DELETE",
8773
+ "PATCH"
8774
+ ]),
8775
+ path: string(),
8776
+ access: _enum([
8777
+ "public",
8778
+ "authenticated",
8779
+ "admin"
8780
+ ]).optional(),
8781
+ description: string().optional()
8782
+ });
8783
+ /**
8784
+ * Cross-process route invocation envelope. The hub captures the
8785
+ * request as plain data, ships it to the worker via Moleculer, and
8786
+ * the worker runs the local handler against a capturing reply. The
8787
+ * envelope returned describes what the handler intended (status,
8788
+ * headers, body, or a redirect) so the hub can translate it back to
8789
+ * the Fastify reply that's actually wired to the socket.
8790
+ */
8791
+ var InvokeRequestSchema = object({
8792
+ method: string(),
8793
+ path: string(),
8794
+ params: record(string(), string()),
8795
+ query: record(string(), string()),
8796
+ body: unknown(),
8797
+ headers: record(string(), string()),
8798
+ user: object({
8799
+ id: string(),
8800
+ username: string(),
8801
+ isAdmin: boolean()
8380
8802
  }).optional(),
8381
- timestamp: number()
8803
+ scopedToken: unknown().optional()
8382
8804
  });
8383
- var AudioAnalysisSettingsSchema = object({
8384
- minConfidence: number().min(0).max(1).default(.3),
8385
- allowedClasses: array(string()).default([])
8805
+ var InvokeReplyEnvelopeSchema = object({
8806
+ status: number().int(),
8807
+ headers: record(string(), string()),
8808
+ /** When set, the hub MUST `reply.redirect(redirectUrl)` instead of
8809
+ * sending `body`. Status defaults to 302 when this is set unless
8810
+ * the handler called `reply.code(...)` explicitly. */
8811
+ redirectUrl: string().nullable(),
8812
+ /** JSON-serializable body. `undefined` is treated as "no body". */
8813
+ body: unknown().optional(),
8814
+ /** Set when the handler called `reply.type(mime)`. */
8815
+ contentType: string().optional()
8386
8816
  });
8387
- var AudioClassificationResultSchema = object({
8388
- labels: array(AudioClassificationLabelSchema).readonly(),
8389
- rawLabels: array(AudioClassificationLabelSchema).readonly().optional(),
8390
- inferenceMs: number()
8817
+ method(_void(), array(AddonHttpRouteSchema)), method(InvokeRequestSchema, InvokeReplyEnvelopeSchema, { kind: "mutation" });
8818
+ var ConfigTabDeclarationSchema = object({
8819
+ id: string(),
8820
+ label: string(),
8821
+ icon: string(),
8822
+ order: number().optional()
8823
+ });
8824
+ var ConfigSectionWithValuesSchema = object({
8825
+ id: string(),
8826
+ title: string(),
8827
+ description: string().optional(),
8828
+ style: _enum(["card", "accordion"]).optional(),
8829
+ defaultCollapsed: boolean().optional(),
8830
+ columns: union([
8831
+ literal(1),
8832
+ literal(2),
8833
+ literal(3),
8834
+ literal(4)
8835
+ ]).optional(),
8836
+ tab: string().optional(),
8837
+ location: _enum(["settings", "top-tab"]).optional(),
8838
+ order: number().optional(),
8839
+ fields: array(any())
8840
+ });
8841
+ var SettingsSchemaWithValuesSchema = object({
8842
+ tabs: array(ConfigTabDeclarationSchema).optional(),
8843
+ sections: array(ConfigSectionWithValuesSchema)
8391
8844
  });
8845
+ /** Patch object — keys are field names, values are the new field values. */
8846
+ var SettingsPatchSchema = record(string(), unknown());
8847
+ /** Standard success response for update operations. */
8848
+ var SettingsUpdateResultSchema = object({ success: literal(true) });
8392
8849
  method(object({
8393
- chunk: AudioChunkInputSchema,
8394
- settings: AudioAnalysisSettingsSchema
8395
- }), AudioAnalysisResultSchema.nullable(), { kind: "mutation" }), method(AudioChunkInputSchema, AudioClassificationResultSchema, { timeoutMs: 3e4 }), method(_void(), boolean()), method(_void(), _void(), { kind: "mutation" }), method(_void(), object({ backend: string() }), {
8850
+ addonId: string(),
8851
+ nodeId: string().optional(),
8852
+ overlay: record(string(), unknown()).optional(),
8853
+ cap: string().optional()
8854
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8855
+ addonId: string(),
8856
+ nodeId: string().optional(),
8857
+ patch: SettingsPatchSchema
8858
+ }), SettingsUpdateResultSchema, {
8859
+ kind: "mutation",
8860
+ auth: "admin"
8861
+ }), method(object({
8862
+ addonId: string(),
8863
+ deviceId: number(),
8864
+ nodeId: string().optional()
8865
+ }), SettingsSchemaWithValuesSchema.nullable()), method(object({
8866
+ addonId: string(),
8867
+ deviceId: number(),
8868
+ nodeId: string().optional(),
8869
+ patch: SettingsPatchSchema
8870
+ }), SettingsUpdateResultSchema, {
8396
8871
  kind: "mutation",
8397
8872
  auth: "admin"
8398
8873
  });
8399
- var PcmSampleFormatSchema = _enum(["f32le", "s16le"]);
8400
- var AudioCodecInfoSchema = object({
8401
- codec: string(),
8402
- canDecode: boolean(),
8403
- canEncode: boolean(),
8404
- label: string().optional()
8405
- });
8406
- var AudioDecodeSessionConfigSchema = object({
8407
- codec: string(),
8408
- sourceSampleRate: number().int().positive(),
8409
- sourceChannels: number().int().positive(),
8410
- extraData: _instanceof(Uint8Array).optional(),
8411
- targetSampleRate: number().int().positive(),
8412
- targetChannels: number().int().positive(),
8413
- targetFormat: PcmSampleFormatSchema.optional(),
8414
- idleMs: number().int().positive().optional(),
8415
- tag: string().optional()
8416
- });
8417
- var AudioEncodeSessionConfigSchema = object({
8418
- codec: string(),
8419
- sourceSampleRate: number().int().positive(),
8420
- sourceChannels: number().int().positive(),
8421
- sourceFormat: PcmSampleFormatSchema.optional(),
8422
- targetSampleRate: number().int().positive(),
8423
- targetChannels: number().int().positive(),
8424
- bitrateKbps: number().int().positive().optional(),
8425
- idleMs: number().int().positive().optional(),
8426
- tag: string().optional()
8427
- });
8428
- var AudioPcmChunkSchema = object({
8429
- data: _instanceof(Uint8Array),
8430
- sampleRate: number().int().positive(),
8431
- channels: number().int().positive(),
8432
- format: PcmSampleFormatSchema,
8433
- pts: number()
8434
- });
8435
- var AudioEncodedChunkSchema = object({
8436
- data: _instanceof(Uint8Array),
8437
- codec: string(),
8438
- pts: number(),
8439
- frameComplete: boolean()
8440
- });
8441
- var SessionInventoryEntrySchema = object({
8442
- sessionId: string(),
8443
- kind: _enum(["decode", "encode"]),
8444
- codec: string(),
8445
- sourceSampleRate: number(),
8446
- sourceChannels: number(),
8447
- targetSampleRate: number(),
8448
- targetChannels: number(),
8449
- format: PcmSampleFormatSchema,
8450
- tag: string().optional(),
8451
- createdAtMs: number(),
8452
- lastActivityMs: number(),
8453
- framesIn: number(),
8454
- framesOut: number()
8455
- });
8456
- method(_void(), array(AudioCodecInfoSchema).readonly()), method(object({
8457
- codec: string(),
8458
- kind: _enum(["decode", "encode"])
8459
- }), boolean()), method(AudioDecodeSessionConfigSchema, object({
8460
- sessionId: string(),
8461
- nodeId: string()
8462
- }), { kind: "mutation" }), method(AudioEncodeSessionConfigSchema, object({
8463
- sessionId: string(),
8464
- nodeId: string()
8465
- }), { kind: "mutation" }), method(object({
8466
- sessionId: string(),
8467
- nodeId: string().optional()
8468
- }), _void(), { kind: "mutation" }), method(object({
8469
- sessionId: string(),
8470
- nodeId: string().optional(),
8471
- data: _instanceof(Uint8Array),
8472
- /** Source PTS in milliseconds. Synthesised when omitted. */
8473
- pts: number().optional()
8474
- }), _void(), { kind: "mutation" }), method(object({
8475
- sessionId: string(),
8476
- nodeId: string().optional(),
8477
- maxCount: number().int().positive().default(8)
8478
- }), array(AudioPcmChunkSchema)), method(object({
8479
- sessionId: string(),
8480
- nodeId: string().optional(),
8481
- data: _instanceof(Uint8Array),
8482
- /** Source PTS in milliseconds. */
8483
- pts: number().optional()
8484
- }), _void(), { kind: "mutation" }), method(object({
8485
- sessionId: string(),
8486
- nodeId: string().optional(),
8487
- maxCount: number().int().positive().default(8)
8488
- }), array(AudioEncodedChunkSchema)), method(object({
8489
- sessionId: string(),
8490
- nodeId: string().optional()
8491
- }), array(AudioEncodedChunkSchema), { kind: "mutation" }), method(_void(), array(SessionInventoryEntrySchema).readonly());
8492
- var AuthResultSchema = object({
8493
- userId: string(),
8494
- username: string(),
8495
- email: string().optional(),
8496
- displayName: string().optional(),
8497
- /**
8498
- * Whether the authenticating user is an admin. The auth-provider
8499
- * surface returns this so the server's login flow can mint a JWT
8500
- * with the correct bypass flag. Non-admin users authenticated via
8501
- * an external IdP still need their scopes assigned by an admin via
8502
- * `setUserScopes` — the SSO flow doesn't carry permissions.
8503
- */
8504
- isAdmin: boolean().default(false)
8874
+ /**
8875
+ * `addon-widgets-source` collection cap exposing per-addon raw widget
8876
+ * declarations. Mirrors the addon-pages split: every addon shipping
8877
+ * widgets registers a provider on this collection cap; the hub-local
8878
+ * aggregator (`addon-widgets`, see `addon-widgets.cap.ts`) walks the
8879
+ * collection, stamps versioned `bundleUrl`s onto each declaration, and
8880
+ * exposes the public listing surface that admin-ui consumes.
8881
+ *
8882
+ * The split exists because the public listing has a different output
8883
+ * shape (flat enriched metadata with `addonId` + `bundleUrl`) than the
8884
+ * per-provider raw declarations. Both ends flow through codegen.
8885
+ *
8886
+ * Unified UI-contribution model (Task 10): a widget descriptor IS a
8887
+ * `UiContribution` with `kind:'remote'`. The host renders it through the
8888
+ * same `ContributionRenderer` / Module-Federation path as every other
8889
+ * contributed UI surface — no bespoke widget-rendering path. The widget-
8890
+ * only metadata (sizing hints, `requires`) lives as extra fields on the
8891
+ * descriptor; the `UiContribution` core (`tab` / `label` / `order` /
8892
+ * `kind` / `remote`) carries identity + placement + the MF remote.
8893
+ */
8894
+ /** Where the widget makes sense to render — maps to a contribution `tab`. */
8895
+ var WidgetHostEnum = _enum([
8896
+ "device-tab",
8897
+ "dashboard",
8898
+ "integration-detail"
8899
+ ]);
8900
+ var WidgetSizeEnum = _enum([
8901
+ "xs",
8902
+ "sm",
8903
+ "md",
8904
+ "lg",
8905
+ "xl"
8906
+ ]);
8907
+ /**
8908
+ * MF remote descriptor — mirrors `UiContributionRemote` from
8909
+ * `capability-definition.ts`. Widget remotes expose a single
8910
+ * `'./widgets'` module whose default export is a
8911
+ * `Record<componentKey, Component>` map; `componentKey` (the widget
8912
+ * `stableId`) picks the entry the host mounts.
8913
+ */
8914
+ var WidgetRemoteSchema = object({
8915
+ remoteName: string(),
8916
+ exposedModule: string(),
8917
+ componentKey: string().optional()
8505
8918
  });
8506
- method(object({
8507
- username: string(),
8508
- password: string()
8509
- }), AuthResultSchema.nullable(), { kind: "mutation" }), method(object({ state: string() }), string()), method(record(string(), string()), AuthResultSchema, { kind: "mutation" }), method(object({ token: string() }), AuthResultSchema.nullable());
8510
8919
  /**
8511
- * Orchestrator-side destination metadata. The orchestrator computes
8512
- * `id = <addonId>:<subId>` from its provider lookup so consumers
8513
- * (admin UI, restore flow) see one canonical key.
8920
+ * One widget declaration a `UiContribution` (`kind:'remote'`) plus
8921
+ * widget-only metadata. The `UiContribution` core fields:
8514
8922
  *
8515
- * Phase 4 (admin UI redesign) adds the per-destination policy fields
8516
- * (`enabled`, `retentionCount`, `label`) so the destinations table can
8517
- * render the joined view without a follow-up round-trip. The backend
8518
- * already joins these in `listDestinations` we now surface them on
8519
- * the wire.
8923
+ * - `tab` — where the widget hosts. A widget that runs on the
8924
+ * dashboard declares `tab:'dashboard'`; a device-tab
8925
+ * widget declares the target device-detail tab id.
8926
+ * - `subTab` optional sub-tab within `tab`.
8927
+ * - `label` — operator-facing label.
8928
+ * - `order` — ordering within `(tab, subTab)`.
8929
+ * - `kind` — always `'remote'` for widgets.
8930
+ * - `remote` — the MF remote `{ remoteName, exposedModule, componentKey }`.
8520
8931
  *
8521
- * `lastSuccessAt` / `lastSuccessSizeBytes` are computed from each
8522
- * location's `manifests.json` (newest archive). Optional — locations
8523
- * with no archives yet leave them undefined.
8932
+ * Widget-only fields retained alongside the contribution core:
8933
+ *
8934
+ * - `stableId` — stable identity within the addon (the MF
8935
+ * `componentKey`; kept top-level so consumers have
8936
+ * a stable key without reaching into `remote`).
8937
+ * - `description` / `icon` — picker metadata.
8938
+ * - `bundle` — entry filename inside the addon `dist/` dir; the
8939
+ * aggregator stamps a versioned `bundleUrl` from it.
8940
+ * - `hosts` — every host the widget supports (a widget can run
8941
+ * both on the dashboard and a device tab). `tab`
8942
+ * is the PRIMARY host; `hosts` is the full set the
8943
+ * picker filters on.
8944
+ * - `requires` — host-context requirements validated at mount.
8945
+ * - `defaultSize` / `allowedSizes` / `defaultColumns` / `defaultRows`
8946
+ * — dashboard placement hints.
8524
8947
  */
8525
- var BackupDestinationInfoSchema = object({
8948
+ var WidgetMetadataSchema = object({
8949
+ /** Primary host tab — `'dashboard'`, `'device-tab'`, or a device-detail tab id. */
8950
+ tab: string(),
8951
+ /** Optional sub-tab within `tab`. */
8952
+ subTab: string().optional(),
8953
+ /** Operator-facing label. */
8954
+ label: string(),
8955
+ /** Ordering within `(tab, subTab)`, ascending. */
8956
+ order: number().optional(),
8957
+ /** Always `'remote'` — a widget is a Module Federation remote. */
8958
+ kind: literal("remote"),
8959
+ /** MF remote descriptor. */
8960
+ remote: WidgetRemoteSchema,
8961
+ /** Stable id within the addon — kebab-case. Equals `remote.componentKey`. */
8962
+ stableId: string(),
8963
+ description: string().optional(),
8964
+ icon: string().optional(),
8526
8965
  /**
8527
- * Sub-id within this addon. Convention `default` for single-
8528
- * destination addons; addons that host many (S3 with N buckets)
8529
- * pick stable per-bucket strings. The orchestrator stitches
8530
- * `<manifestId>:<subId>` for the wire format.
8966
+ * Bundle filename inside the addon's `dist/` dir served at
8967
+ * `/api/addon-widgets/<addonId>/<bundle>`. With Module Federation
8968
+ * this is always `'remoteEntry.js'` the value is kept on the
8969
+ * metadata so the static-file route can compute an mtime-based
8970
+ * cache-buster URL without a separate filesystem stat.
8531
8971
  */
8532
- subId: string(),
8533
- displayName: string(),
8534
- description: string().optional(),
8535
- kind: string(),
8536
- triggerSupported: boolean(),
8537
- restoreSupported: boolean()
8538
- }).extend({
8539
- /** `<addonId>:<subId>` — globally-unique dispatch key. */
8540
- id: string(),
8972
+ bundle: string(),
8973
+ /** Every host the widget supports. The picker filters on this set. */
8974
+ hosts: array(WidgetHostEnum).readonly(),
8975
+ /** Required props the host must supply. Validated at `<WidgetSlot>` mount. */
8976
+ requires: object({
8977
+ deviceContext: boolean().default(false),
8978
+ integrationContext: boolean().default(false)
8979
+ }),
8541
8980
  /**
8542
- * Manifest id of the owning addon. The Settings button on the
8543
- * destination card opens that addon's `globalSettingsSchema` panel.
8981
+ * Loadable BEFORE authentication. The normal widget registry listing
8982
+ * (`addon-widgets.listWidgets`) is auth-gated, so a pre-auth surface
8983
+ * (the login page) cannot discover a widget through it. A widget that
8984
+ * declares `preAuth: true` marks itself as safe to mount on a pre-auth
8985
+ * screen — it is surfaced through the PUBLIC `auth.listLoginMethods`
8986
+ * login-method contribution channel (see `login-method.cap.ts`) rather
8987
+ * than the authenticated registry, and its bundle is served by the
8988
+ * public `/api/addon-widgets/:addonId/*` static route. Defaults false.
8544
8989
  */
8545
- addonId: string(),
8546
- /** Operator-toggled enable flag from the destination-policy table. */
8547
- enabled: boolean(),
8548
- /** Operator-defined retention count (archives kept per destination). */
8549
- retentionCount: number(),
8550
- /** Operator-defined display label (overrides storage location displayName). */
8551
- label: string().optional(),
8552
- /** Newest-archive timestamp from `manifests.json`, or undefined. */
8553
- lastSuccessAt: number().optional(),
8554
- /** Newest-archive size from `manifests.json`, or undefined. */
8555
- lastSuccessSizeBytes: number().optional(),
8556
- /** Per-destination cron expression. Empty = manual-only (no schedule). */
8557
- cron: string().optional(),
8558
- /** ms-epoch of next computed firing for this destination's cron, if any. */
8559
- nextRunAt: number().optional(),
8560
- /** ms-epoch of last successful scheduled run (mirrors policy.lastRunAt). */
8561
- lastRunAt: number().optional()
8990
+ preAuth: boolean().optional().default(false),
8991
+ /** Dashboard placement HINTS (operator can override per instance). */
8992
+ defaultSize: WidgetSizeEnum.default("md"),
8993
+ allowedSizes: array(WidgetSizeEnum).readonly().default([
8994
+ "sm",
8995
+ "md",
8996
+ "lg"
8997
+ ]),
8998
+ defaultColumns: number().int().min(1).max(12).default(6),
8999
+ defaultRows: number().int().min(1).max(12).default(1)
8562
9000
  });
9001
+ method(_void(), array(WidgetMetadataSchema).readonly());
8563
9002
  /**
8564
- * Per-archive entry returned by `backup.listArchives({ destinationId })`.
8565
- * Same shape the destination drill-in renders. Sourced from the per-
8566
- * location `manifests.json` (no per-archive tarball read required).
9003
+ * `addon-widgets` system-scoped singleton aggregator cap. Public-facing
9004
+ * surface that admin-ui consumes through `useAddonWidgetsListWidgets()`.
9005
+ *
9006
+ * The provider iterates every `addon-widgets-source` (collection)
9007
+ * provider and emits `EnrichedWidgetMetadata[]` enriched with versioned
9008
+ * `bundleUrl` strings pointing at
9009
+ * `/api/addon-widgets/<addonId>/<bundle>?v=<mtime>`. The filesystem
9010
+ * `mtime` cache-buster lets the browser pick up addon rebuilds without
9011
+ * manual reload — same scheme used by `addon-pages`.
9012
+ *
9013
+ * The hub-local builtin `addon-widgets-aggregator` (see
9014
+ * `@camstack/system/builtins/addon-widgets-aggregator`) registers the
9015
+ * provider. Splitting the public aggregator from the raw collection
9016
+ * keeps both ends in codegen — there's no hand-written wrapper.
8567
9017
  */
8568
- var BackupArchiveEntrySchema = object({
8569
- id: string(),
8570
- filename: string(),
8571
- createdAt: number(),
8572
- sizeBytes: number(),
8573
- label: string().optional(),
8574
- /** Top-level locations included in the archive (db, addons, tls, …). */
8575
- locations: array(string()).readonly()
9018
+ var EnrichedWidgetMetadataSchema = WidgetMetadataSchema.extend({
9019
+ addonId: string(),
9020
+ bundleUrl: string()
8576
9021
  });
8577
- var BackupEntrySchema = object({
9022
+ method(_void(), array(EnrichedWidgetMetadataSchema).readonly());
9023
+ /**
9024
+ * Alerts capability — collection-based internal alert system.
9025
+ *
9026
+ * Multiple providers can register. Each provider filters by EventBus category
9027
+ * and creates/updates alerts. The built-in Alert Center addon persists alerts
9028
+ * in the DB and serves them to the admin UI.
9029
+ */
9030
+ var AlertSeveritySchema = _enum([
9031
+ "info",
9032
+ "success",
9033
+ "warning",
9034
+ "error"
9035
+ ]);
9036
+ var AlertStatusSchema = _enum([
9037
+ "active",
9038
+ "in-progress",
9039
+ "completed",
9040
+ "failed",
9041
+ "dismissed"
9042
+ ]);
9043
+ var AlertSourceSchema = object({
9044
+ type: string(),
9045
+ id: string()
9046
+ });
9047
+ var AlertSchema = object({
8578
9048
  id: string(),
8579
- /** Addon id of the destination that owns this backup (e.g. `local-backup`, `s3-backup`). */
8580
- destinationId: string().optional(),
8581
- label: string().optional(),
9049
+ category: string(),
9050
+ severity: AlertSeveritySchema,
9051
+ title: string(),
9052
+ message: string(),
9053
+ status: AlertStatusSchema,
9054
+ progress: number().optional(),
9055
+ read: boolean(),
8582
9056
  createdAt: number(),
8583
- sizeBytes: number(),
8584
- locations: array(string()).optional()
9057
+ updatedAt: number(),
9058
+ source: AlertSourceSchema.optional(),
9059
+ metadata: record(string(), unknown()).optional()
8585
9060
  });
8586
- var ArchiveEntrySchema = object({
8587
- path: string(),
8588
- kind: _enum([
8589
- "file",
8590
- "dir",
8591
- "symlink"
8592
- ]),
8593
- sizeBytes: number(),
8594
- mtime: number()
9061
+ method(AlertSchema, _void(), { kind: "mutation" }), method(object({
9062
+ alertId: string(),
9063
+ patch: AlertSchema.partial()
9064
+ }), _void(), { kind: "mutation" }), method(object({
9065
+ unreadOnly: boolean().optional(),
9066
+ limit: number().optional()
9067
+ }).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" });
9068
+ DeviceType.Camera, method(object({ deviceId: number() }), custom()), object({
9069
+ deviceId: number(),
9070
+ rms: number(),
9071
+ dbfs: number()
8595
9072
  });
8596
- var ArchiveManifestSchema = object({
8597
- archiveVersion: literal(1),
8598
- createdAt: number(),
8599
- dataDir: string(),
8600
- locations: array(string()),
8601
- entries: array(ArchiveEntrySchema),
8602
- totalBytes: number(),
8603
- totalFiles: number()
9073
+ /** Shared Zod schemas used across detection capabilities. */
9074
+ /**
9075
+ * Canonical frame-format enum mirrored on `FrameFormat` in
9076
+ * `packages/types/src/types/io.ts`. Kept inline (vs imported) so the
9077
+ * Zod runtime schema and TypeScript type stay in sync at the call site
9078
+ * — adding a new format requires changing both this enum and the
9079
+ * `FrameFormat` type alias together.
9080
+ */
9081
+ var FrameFormatSchema = _enum([
9082
+ "jpeg",
9083
+ "rgb",
9084
+ "bgr",
9085
+ "yuv420",
9086
+ "gray"
9087
+ ]);
9088
+ var FrameInputSchema = object({
9089
+ data: custom(),
9090
+ format: FrameFormatSchema,
9091
+ width: number(),
9092
+ height: number(),
9093
+ timestamp: number()
8604
9094
  });
8605
- var LocationStatSchema = object({
8606
- name: string(),
8607
- sizeBytes: number(),
8608
- fileCount: number(),
8609
- present: boolean()
9095
+ var BoundingBoxSchema = object({
9096
+ x: number(),
9097
+ y: number(),
9098
+ w: number(),
9099
+ h: number()
9100
+ });
9101
+ object({
9102
+ class: string(),
9103
+ originalClass: string(),
9104
+ score: number(),
9105
+ bbox: BoundingBoxSchema
8610
9106
  });
8611
9107
  /**
8612
- * A backup schedule the N:M "entry" that binds one cron cadence to a
8613
- * SET of destination locations. Supersedes the per-location cron on
8614
- * `BackupDestinationPolicy`: an operator creates a schedule, picks the
8615
- * `backups` locations it should write to, and the orchestrator fans a
8616
- * single archive out to all of them when the cron fires.
8617
- *
8618
- * `retentionCount` is per-schedule (D-decision 2026-07-28): every
8619
- * location targeted by this schedule keeps this many archives from
8620
- * this schedule's runs.
9108
+ * `data` carries the raw f32le bytes of the PCM samples (4 bytes per
9109
+ * sample, little-endian IEEE 754). `Uint8Array` is the wire-safe choice
9110
+ * because `@msgpack/msgpack` serialises it as a MsgPack `bin` type that
9111
+ * round-trips losslessly over the UDS transport. `Float32Array` is NOT
9112
+ * preserved the encoder serialises it as `bin` (its raw bytes) but
9113
+ * the decoder returns `Uint8Array`, so treating a `Float32Array` as the
9114
+ * wire type causes receivers to read bytes as sample values, producing
9115
+ * wildly wrong RMS/dBFS results (~+43 dBFS instead of ≤0).
8621
9116
  *
8622
- * `dataSources` optionally narrows which top-level state locations
8623
- * (db, addons, tls, ) are archived; omitted = the orchestrator's
8624
- * default full set.
9117
+ * Callers that need float arithmetic reconstruct the view with:
9118
+ * `new Float32Array(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength / 4)`
8625
9119
  */
8626
- var BackupScheduleSchema = object({
8627
- /** Stable id. Generated by the orchestrator on first upsert if absent. */
8628
- id: string(),
8629
- /** Operator-facing display name. */
8630
- label: string(),
8631
- /** 5-field POSIX cron. Empty = disabled cadence (kept for editing). */
8632
- cron: string(),
8633
- /** Master on/off toggle for the whole schedule. */
8634
- enabled: boolean(),
8635
- /** `backups`-location ids this schedule writes to (fan-out set). */
8636
- locationIds: array(string()).readonly(),
8637
- /** Archives kept per targeted location for this schedule. */
8638
- retentionCount: number().int().min(1).max(1e3),
8639
- /** Optional subset of source locations to include; omitted = all. */
8640
- dataSources: array(string()).readonly().optional(),
8641
- /** ms-epoch of last successful run. */
8642
- lastRunAt: number().optional(),
8643
- /** ms-epoch of next computed firing (read-only, filled on list). */
8644
- nextRunAt: number().optional()
9120
+ var AudioChunkInputSchema = object({
9121
+ data: _instanceof(Uint8Array),
9122
+ sampleRate: number(),
9123
+ channels: number(),
9124
+ timestamp: number(),
9125
+ /** Originating device id used by the classifier for per-camera concurrency tracking. */
9126
+ deviceId: number().optional()
8645
9127
  });
8646
- method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }), method(object({
8647
- /** Subset of registered `backup-destination` addon ids to write to. */
8648
- destinations: array(string()).optional(),
8649
- locations: array(string()).optional(),
8650
- label: string().optional(),
9128
+ var AudioLevelSchema = object({
9129
+ rms: number(),
9130
+ dbfs: number()
9131
+ });
9132
+ var AudioClassificationLabelSchema = object({
8651
9133
  /**
8652
- * Per-run retention override applied to every targeted
8653
- * destination. Used by schedule-driven runs (per-entry
8654
- * retention). Omitted = each destination's own policy
8655
- * retention (manual runs).
9134
+ * Primary display class. Depending on how the label was produced
9135
+ * this is either the macro category (e.g. `dog`) or the raw
9136
+ * backend label (e.g. `Dog bark`). Mirrors `class` on
9137
+ * `SpatialDetection`.
8656
9138
  */
8657
- retentionCount: number().int().min(1).max(1e3).optional()
8658
- }).optional(), array(BackupEntrySchema).readonly(), {
8659
- kind: "mutation",
8660
- auth: "admin"
8661
- }), method(_void(), array(BackupEntrySchema).readonly(), { auth: "admin" }), method(_void(), array(LocationStatSchema).readonly(), { auth: "admin" }), method(object({
8662
- destinationId: string(),
8663
- backupId: string()
8664
- }), ArchiveManifestSchema.nullable(), { auth: "admin" }), method(object({
8665
- destinationId: string(),
8666
- backupId: string(),
9139
+ className: string(),
8667
9140
  /**
8668
- * Optional whitelist only restore these top-level locations
8669
- * from the archive. Default = every location the archive
8670
- * carries (full restore). The boot-time apply hook reads
8671
- * this list and skips entries whose path doesn't start with
8672
- * any of them.
9141
+ * Raw backend-native label the classifier actually emitted (e.g.
9142
+ * `Dog bark` for YAMNet, `dog_bark` for Apple SoundAnalysis). For
9143
+ * macro-aggregated entries this is the top raw contributor. Mirrors
9144
+ * `originalClass` on `SpatialDetection`.
8673
9145
  */
8674
- locations: array(string()).optional()
8675
- }), _void(), {
8676
- kind: "mutation",
8677
- auth: "admin"
8678
- }), method(object({
8679
- destinationId: string(),
8680
- backupId: string()
8681
- }), _void(), {
9146
+ originalClass: string().optional(),
9147
+ score: number()
9148
+ });
9149
+ var AudioAnalysisResultSchema = object({
9150
+ level: AudioLevelSchema,
9151
+ classification: object({
9152
+ labels: array(AudioClassificationLabelSchema).readonly(),
9153
+ inferenceMs: number()
9154
+ }).optional(),
9155
+ timestamp: number()
9156
+ });
9157
+ var AudioAnalysisSettingsSchema = object({
9158
+ minConfidence: number().min(0).max(1).default(.3),
9159
+ allowedClasses: array(string()).default([])
9160
+ });
9161
+ var AudioClassificationResultSchema = object({
9162
+ labels: array(AudioClassificationLabelSchema).readonly(),
9163
+ rawLabels: array(AudioClassificationLabelSchema).readonly().optional(),
9164
+ inferenceMs: number()
9165
+ });
9166
+ method(object({
9167
+ chunk: AudioChunkInputSchema,
9168
+ settings: AudioAnalysisSettingsSchema
9169
+ }), AudioAnalysisResultSchema.nullable(), { kind: "mutation" }), method(AudioChunkInputSchema, AudioClassificationResultSchema, { timeoutMs: 3e4 }), method(_void(), boolean()), method(_void(), _void(), { kind: "mutation" }), method(_void(), object({ backend: string() }), {
8682
9170
  kind: "mutation",
8683
9171
  auth: "admin"
8684
- }), method(object({ destinationId: string() }), array(BackupArchiveEntrySchema).readonly(), { auth: "admin" }), method(object({
8685
- locationId: string(),
8686
- enabled: boolean(),
8687
- retentionCount: number().int().min(1).max(1e3),
8688
- label: string().optional(),
8689
- /**
8690
- * Per-destination cron expression. Empty string clears the
8691
- * schedule (manual-only). Validated server-side via croner;
8692
- * malformed expressions reject the upsert with an actionable
8693
- * message.
9172
+ });
9173
+ var PcmSampleFormatSchema = _enum(["f32le", "s16le"]);
9174
+ var AudioCodecInfoSchema = object({
9175
+ codec: string(),
9176
+ canDecode: boolean(),
9177
+ canEncode: boolean(),
9178
+ label: string().optional()
9179
+ });
9180
+ var AudioDecodeSessionConfigSchema = object({
9181
+ codec: string(),
9182
+ sourceSampleRate: number().int().positive(),
9183
+ sourceChannels: number().int().positive(),
9184
+ extraData: _instanceof(Uint8Array).optional(),
9185
+ targetSampleRate: number().int().positive(),
9186
+ targetChannels: number().int().positive(),
9187
+ targetFormat: PcmSampleFormatSchema.optional(),
9188
+ idleMs: number().int().positive().optional(),
9189
+ tag: string().optional()
9190
+ });
9191
+ var AudioEncodeSessionConfigSchema = object({
9192
+ codec: string(),
9193
+ sourceSampleRate: number().int().positive(),
9194
+ sourceChannels: number().int().positive(),
9195
+ sourceFormat: PcmSampleFormatSchema.optional(),
9196
+ targetSampleRate: number().int().positive(),
9197
+ targetChannels: number().int().positive(),
9198
+ bitrateKbps: number().int().positive().optional(),
9199
+ idleMs: number().int().positive().optional(),
9200
+ tag: string().optional()
9201
+ });
9202
+ var AudioPcmChunkSchema = object({
9203
+ data: _instanceof(Uint8Array),
9204
+ sampleRate: number().int().positive(),
9205
+ channels: number().int().positive(),
9206
+ format: PcmSampleFormatSchema,
9207
+ pts: number()
9208
+ });
9209
+ var AudioEncodedChunkSchema = object({
9210
+ data: _instanceof(Uint8Array),
9211
+ codec: string(),
9212
+ pts: number(),
9213
+ frameComplete: boolean()
9214
+ });
9215
+ var SessionInventoryEntrySchema = object({
9216
+ sessionId: string(),
9217
+ kind: _enum(["decode", "encode"]),
9218
+ codec: string(),
9219
+ sourceSampleRate: number(),
9220
+ sourceChannels: number(),
9221
+ targetSampleRate: number(),
9222
+ targetChannels: number(),
9223
+ format: PcmSampleFormatSchema,
9224
+ tag: string().optional(),
9225
+ createdAtMs: number(),
9226
+ lastActivityMs: number(),
9227
+ framesIn: number(),
9228
+ framesOut: number()
9229
+ });
9230
+ method(_void(), array(AudioCodecInfoSchema).readonly()), method(object({
9231
+ codec: string(),
9232
+ kind: _enum(["decode", "encode"])
9233
+ }), boolean()), method(AudioDecodeSessionConfigSchema, object({
9234
+ sessionId: string(),
9235
+ nodeId: string()
9236
+ }), { kind: "mutation" }), method(AudioEncodeSessionConfigSchema, object({
9237
+ sessionId: string(),
9238
+ nodeId: string()
9239
+ }), { kind: "mutation" }), method(object({
9240
+ sessionId: string(),
9241
+ nodeId: string().optional()
9242
+ }), _void(), { kind: "mutation" }), method(object({
9243
+ sessionId: string(),
9244
+ nodeId: string().optional(),
9245
+ data: _instanceof(Uint8Array),
9246
+ /** Source PTS in milliseconds. Synthesised when omitted. */
9247
+ pts: number().optional()
9248
+ }), _void(), { kind: "mutation" }), method(object({
9249
+ sessionId: string(),
9250
+ nodeId: string().optional(),
9251
+ maxCount: number().int().positive().default(8)
9252
+ }), array(AudioPcmChunkSchema)), method(object({
9253
+ sessionId: string(),
9254
+ nodeId: string().optional(),
9255
+ data: _instanceof(Uint8Array),
9256
+ /** Source PTS in milliseconds. */
9257
+ pts: number().optional()
9258
+ }), _void(), { kind: "mutation" }), method(object({
9259
+ sessionId: string(),
9260
+ nodeId: string().optional(),
9261
+ maxCount: number().int().positive().default(8)
9262
+ }), array(AudioEncodedChunkSchema)), method(object({
9263
+ sessionId: string(),
9264
+ nodeId: string().optional()
9265
+ }), array(AudioEncodedChunkSchema), { kind: "mutation" }), method(_void(), array(SessionInventoryEntrySchema).readonly());
9266
+ var AuthResultSchema = object({
9267
+ userId: string(),
9268
+ username: string(),
9269
+ email: string().optional(),
9270
+ displayName: string().optional(),
9271
+ /**
9272
+ * Whether the authenticating user is an admin. The auth-provider
9273
+ * surface returns this so the server's login flow can mint a JWT
9274
+ * with the correct bypass flag. Non-admin users authenticated via
9275
+ * an external IdP still need their scopes assigned by an admin via
9276
+ * `setUserScopes` — the SSO flow doesn't carry permissions.
8694
9277
  */
8695
- cron: string().optional()
8696
- }), _void(), {
8697
- kind: "mutation",
8698
- auth: "admin"
8699
- }), method(object({
8700
- cron: string(),
8701
- count: number().int().min(1).max(20).optional()
8702
- }), object({
8703
- ok: boolean(),
8704
- error: string().optional(),
8705
- nextRuns: array(number()).readonly()
8706
- })), method(_void(), array(BackupScheduleSchema).readonly(), { auth: "admin" }), method(object({
8707
- id: string().optional(),
8708
- label: string(),
8709
- cron: string(),
8710
- enabled: boolean(),
8711
- locationIds: array(string()).readonly(),
8712
- retentionCount: number().int().min(1).max(1e3),
8713
- dataSources: array(string()).readonly().optional()
8714
- }), BackupScheduleSchema, {
8715
- kind: "mutation",
8716
- auth: "admin"
8717
- }), method(object({ id: string() }), _void(), {
8718
- kind: "mutation",
8719
- auth: "admin"
9278
+ isAdmin: boolean().default(false)
8720
9279
  });
9280
+ method(object({
9281
+ username: string(),
9282
+ password: string()
9283
+ }), AuthResultSchema.nullable(), { kind: "mutation" }), method(object({ state: string() }), string()), method(record(string(), string()), AuthResultSchema, { kind: "mutation" }), method(object({ token: string() }), AuthResultSchema.nullable());
8721
9284
  /**
8722
- * `broker` unified pub/sub broker registry, system-scoped collection.
8723
- *
8724
- * The cap models any kind-tagged message broker the user wants to
8725
- * register and that other addons might consume. The first two kinds
8726
- * are MQTT (mosquitto / aedes embedded / cloud bridge) and
8727
- * Home Assistant (HA WebSocket — subscribe_entities + call_service).
8728
- * Future kinds (Zigbee2MQTT bridge, ZHA, KNX, Telegram, …) plug in
8729
- * the same surface.
8730
- *
8731
- * Why one cap, not one cap per kind:
8732
- * - The integrations page wants a single table of "every broker"
8733
- * across kinds; a unified cap drives it without join logic.
8734
- * - Generic admin operations (add / remove / test / status) are
8735
- * identical across kinds — duplicating them per cap was busywork.
8736
- * - The `network-access` cap precedent: same interface, many
8737
- * `providerKind: 'ingress'` implementations (Tailscale, ngrok, …).
8738
- *
8739
- * What stays kind-specific:
8740
- * - `getBrokerConfig` payload (kind decides whether it returns
8741
- * mqtt URL+creds, HA baseUrl+token, …) — typed as
8742
- * `Record<string, unknown>` at the wire; consumers narrow.
8743
- * - `publish` / `subscribe` / `getState` arguments (target / filter /
8744
- * key) — also typed as `Record<string, unknown>` so each kind can
8745
- * evolve its surface (MQTT topic+qos vs HA entity_id+domain)
8746
- * without rebreaking the cap signature.
8747
- * - `add` `settings` payload (different fields per kind, validated by
8748
- * the kind-specific provider).
9285
+ * Orchestrator-side destination metadata. The orchestrator computes
9286
+ * `id = <addonId>:<subId>` from its provider lookup so consumers
9287
+ * (admin UI, restore flow) see one canonical key.
8749
9288
  *
8750
- * Bidirectionality:
8751
- * - `publish` is RPC synchronous result (provider-defined).
8752
- * - `subscribe` returns a subscription handle; the actual message
8753
- * stream flows over the typed event-bus as `broker.message` events
8754
- * keyed by `(brokerId, subscriptionId)`. Consumers filter in their
8755
- * event handler. Subscriptions persist across reconnects — the
8756
- * provider re-subscribes upstream after a transport drop.
9289
+ * Phase 4 (admin UI redesign) adds the per-destination policy fields
9290
+ * (`enabled`, `retentionCount`, `label`) so the destinations table can
9291
+ * render the joined view without a follow-up round-trip. The backend
9292
+ * already joins these in `listDestinations` — we now surface them on
9293
+ * the wire.
8757
9294
  *
8758
- * Why event-bus push (not a subscription RPC stream):
8759
- * - Matches the rest of CamStack's D8 contract events for telemetry,
8760
- * RPC for loss-is-a-bug. Broker messages are telemetry (a single
8761
- * drop is recoverable via `getState({key})`).
8762
- * - Frees `broker.subscribe` from holding a long-lived RPC channel.
8763
- * - Cross-process: events already route through `$event-bus`; no
8764
- * per-broker wire-shaping needed.
9295
+ * `lastSuccessAt` / `lastSuccessSizeBytes` are computed from each
9296
+ * location's `manifests.json` (newest archive). Optional locations
9297
+ * with no archives yet leave them undefined.
8765
9298
  */
8766
- var BrokerStatusEnum = _enum([
8767
- "connected",
8768
- "disconnected",
8769
- "connecting",
8770
- "auth-failed",
8771
- "unreachable",
8772
- "error"
8773
- ]);
8774
- var BrokerInfoSchema$1 = object({
8775
- /** Stable broker id. Persisted; survives addon restarts. */
9299
+ var BackupDestinationInfoSchema = object({
9300
+ /**
9301
+ * Sub-id within this addon. Convention `default` for single-
9302
+ * destination addons; addons that host many (S3 with N buckets)
9303
+ * pick stable per-bucket strings. The orchestrator stitches
9304
+ * `<manifestId>:<subId>` for the wire format.
9305
+ */
9306
+ subId: string(),
9307
+ displayName: string(),
9308
+ description: string().optional(),
9309
+ kind: string(),
9310
+ triggerSupported: boolean(),
9311
+ restoreSupported: boolean()
9312
+ }).extend({
9313
+ /** `<addonId>:<subId>` — globally-unique dispatch key. */
8776
9314
  id: string(),
8777
- /** Addon id of the provider that OWNS this broker.
8778
- *
8779
- * The `broker` cap is a system-scoped collection: several addons
8780
- * register a `broker` provider, each owning a DISJOINT set of
8781
- * brokers (mqtt-broker owns `mqtt_*`, provider-homeassistant owns
8782
- * `ha_*`). A broker therefore belongs to exactly one addon — like a
8783
- * device belongs to one integration. The admin UI threads this id
8784
- * back as the `{ addonId }` system-collection selector on every
8785
- * id-keyed call (`get` / `getSettings` / `setSettings` / `remove` /
8786
- * `testConnection`), so the call routes to the OWNING provider
8787
- * instead of defaulting to the first-registered one. */
9315
+ /**
9316
+ * Manifest id of the owning addon. The Settings button on the
9317
+ * destination card opens that addon's `globalSettingsSchema` panel.
9318
+ */
8788
9319
  addonId: string(),
8789
- /** Human-readable name (operator-chosen at add-time). */
8790
- name: string(),
8791
- /** Provider-defined kind tag `mqtt` / `home-assistant` / future. */
8792
- kind: string(),
8793
- status: BrokerStatusEnum,
8794
- /** Free-form provider-specific info (HA version, MQTT broker
8795
- * flavour, latency, mTLS active, …). */
8796
- info: record(string(), unknown()),
8797
- /** Ms epoch of the last connection probe / status update. */
8798
- lastCheckedAt: number().nullable(),
8799
- /** Last error message, when `status` indicates failure. */
8800
- error: string().nullable()
8801
- });
8802
- var RegistryStatusSchema = object({
8803
- brokerCount: number().int().nonnegative(),
8804
- connectedCount: number().int().nonnegative()
8805
- });
8806
- /**
8807
- * One entry per `broker` provider — which addon provides which broker
8808
- * kind(s). The unified create picker reads this to know, for each
8809
- * (addonId, kind) pair the operator can pick, where to route the
8810
- * follow-up `getSettingsSchema` / `testSettings` / `add` calls (via the
8811
- * `{ addonId }` selector). A provider only advertises a kind here when
8812
- * it implements `getSettingsSchema` for that kind (so the picker never
8813
- * offers a kind whose creation form can't be rendered).
8814
- */
8815
- var BrokerProviderInfoSchema = object({
8816
- /** Addon id of the `broker` provider this entry describes. */
8817
- addonId: string(),
8818
- /** Broker kinds this provider can create, with a display label. */
8819
- kinds: array(object({
8820
- kind: string(),
8821
- label: string()
8822
- }))
9320
+ /** Operator-toggled enable flag from the destination-policy table. */
9321
+ enabled: boolean(),
9322
+ /** Operator-defined retention count (archives kept per destination). */
9323
+ retentionCount: number(),
9324
+ /** Operator-defined display label (overrides storage location displayName). */
9325
+ label: string().optional(),
9326
+ /** Newest-archive timestamp from `manifests.json`, or undefined. */
9327
+ lastSuccessAt: number().optional(),
9328
+ /** Newest-archive size from `manifests.json`, or undefined. */
9329
+ lastSuccessSizeBytes: number().optional(),
9330
+ /** Per-destination cron expression. Empty = manual-only (no schedule). */
9331
+ cron: string().optional(),
9332
+ /** ms-epoch of next computed firing for this destination's cron, if any. */
9333
+ nextRunAt: number().optional(),
9334
+ /** ms-epoch of last successful scheduled run (mirrors policy.lastRunAt). */
9335
+ lastRunAt: number().optional()
8823
9336
  });
8824
- var ListInputSchema = object({
8825
- /** Optional kind filter `list({kind:'home-assistant'})` returns
8826
- * only HA brokers. Omit to list every broker. */
8827
- kind: string().optional() });
8828
- var GetInputSchema = object({ id: string() });
8829
- var AddInputSchema = object({
8830
- kind: string().min(1),
8831
- name: string().min(1),
8832
- /**
8833
- * ADOPT an existing id instead of minting a new one.
8834
- *
8835
- * Written the day this cost an outage. A broker's id is not a detail: HA
8836
- * devices carry it inside their `stableId` (`ha:ha_004:dev:…`), so a broker
8837
- * lost from config and re-added as `ha_001` leaves every one of its devices
8838
- * bound to a broker that no longer exists. Re-entering the password under the
8839
- * ORIGINAL id turns a multi-step device migration back into re-entering a
8840
- * password.
8841
- *
8842
- * A provider MUST refuse an id that is already in use — adopting a live
8843
- * broker's id would silently take it over.
8844
- */
8845
- id: string().min(1).optional(),
8846
- /** Kind-specific settings (e.g. MQTT `{url,username,password}` or HA
8847
- * `{baseUrl,accessToken}`). Validated by the kind-specific provider
8848
- * branch on receipt — invalid shape rejects the add. */
8849
- settings: record(string(), unknown())
9337
+ /**
9338
+ * Per-archive entry returned by `backup.listArchives({ destinationId })`.
9339
+ * Same shape the destination drill-in renders. Sourced from the per-
9340
+ * location `manifests.json` (no per-archive tarball read required).
9341
+ */
9342
+ var BackupArchiveEntrySchema = object({
9343
+ id: string(),
9344
+ filename: string(),
9345
+ createdAt: number(),
9346
+ sizeBytes: number(),
9347
+ label: string().optional(),
9348
+ /** Top-level locations included in the archive (db, addons, tls, …). */
9349
+ locations: array(string()).readonly()
8850
9350
  });
8851
- var AddResultSchema = object({ id: string() });
8852
- var RemoveInputSchema = object({ id: string() });
8853
- var TestConnectionResultSchema = discriminatedUnion("ok", [object({
8854
- ok: literal(true),
8855
- latencyMs: number().nonnegative()
8856
- }), object({
8857
- ok: literal(false),
8858
- error: string()
8859
- })]);
8860
- var SettingsRecordSchema$1 = record(string(), unknown());
8861
- var SettingsSchemaInputSchema = object({ kind: string() });
8862
- var TestSettingsInputSchema = object({
8863
- kind: string(),
8864
- settings: SettingsRecordSchema$1
9351
+ var BackupEntrySchema = object({
9352
+ id: string(),
9353
+ /** Addon id of the destination that owns this backup (e.g. `local-backup`, `s3-backup`). */
9354
+ destinationId: string().optional(),
9355
+ label: string().optional(),
9356
+ createdAt: number(),
9357
+ sizeBytes: number(),
9358
+ locations: array(string()).optional()
8865
9359
  });
8866
- var TestSettingsResultSchema = discriminatedUnion("ok", [object({
8867
- ok: literal(true),
8868
- latencyMs: number().nonnegative().optional()
8869
- }).strict(), object({
8870
- ok: literal(false),
8871
- error: string()
8872
- })]);
8873
- var SettingsSchemaResultSchema = unknown().nullable();
8874
- var PublishInputSchema = object({
8875
- brokerId: string(),
8876
- /** Kind-specific routing target.
8877
- * MQTT: `{ topic: string, qos?: 0|1|2, retain?: boolean }`.
8878
- * HA: `{ domain: string, service: string, entityId?: string, area?: string }`. */
8879
- target: record(string(), unknown()),
8880
- /** Kind-specific payload.
8881
- * MQTT: raw string / number / object (provider serialises).
8882
- * HA: service-call `data` object (transition, brightness, …). */
8883
- payload: unknown().optional()
9360
+ var ArchiveEntrySchema = object({
9361
+ path: string(),
9362
+ kind: _enum([
9363
+ "file",
9364
+ "dir",
9365
+ "symlink"
9366
+ ]),
9367
+ sizeBytes: number(),
9368
+ mtime: number()
8884
9369
  });
8885
- var SubscribeInputSchema = object({
8886
- brokerId: string(),
8887
- /** Kind-specific filter.
8888
- * MQTT: `{ topic: string, qos?: 0|1|2 }` — topic can include wildcards.
8889
- * HA: `{ entityIds: string[] }` or `{ domain: string }`. */
8890
- filter: record(string(), unknown())
9370
+ var ArchiveManifestSchema = object({
9371
+ archiveVersion: literal(1),
9372
+ createdAt: number(),
9373
+ dataDir: string(),
9374
+ locations: array(string()),
9375
+ entries: array(ArchiveEntrySchema),
9376
+ totalBytes: number(),
9377
+ totalFiles: number()
8891
9378
  });
8892
- var SubscribeResultSchema = object({
8893
- /** Stable subscription id. Used to unsubscribe and to filter the
8894
- * matching `broker.message` events on the bus. */
8895
- subscriptionId: string() });
8896
- var UnsubscribeInputSchema = object({
8897
- brokerId: string(),
8898
- subscriptionId: string()
9379
+ var LocationStatSchema = object({
9380
+ name: string(),
9381
+ sizeBytes: number(),
9382
+ fileCount: number(),
9383
+ present: boolean()
8899
9384
  });
8900
- var GetStateInputSchema = object({
8901
- brokerId: string(),
8902
- /** Kind-specific lookup key.
8903
- * MQTT: topic string (returns the last retained message).
8904
- * HA: entity_id (returns the cached entity state). */
8905
- key: string()
9385
+ /**
9386
+ * A backup schedule — the N:M "entry" that binds one cron cadence to a
9387
+ * SET of destination locations. Supersedes the per-location cron on
9388
+ * `BackupDestinationPolicy`: an operator creates a schedule, picks the
9389
+ * `backups` locations it should write to, and the orchestrator fans a
9390
+ * single archive out to all of them when the cron fires.
9391
+ *
9392
+ * `retentionCount` is per-schedule (D-decision 2026-07-28): every
9393
+ * location targeted by this schedule keeps this many archives from
9394
+ * this schedule's runs.
9395
+ *
9396
+ * `dataSources` optionally narrows which top-level state locations
9397
+ * (db, addons, tls, …) are archived; omitted = the orchestrator's
9398
+ * default full set.
9399
+ */
9400
+ var BackupScheduleSchema = object({
9401
+ /** Stable id. Generated by the orchestrator on first upsert if absent. */
9402
+ id: string(),
9403
+ /** Operator-facing display name. */
9404
+ label: string(),
9405
+ /** 5-field POSIX cron. Empty = disabled cadence (kept for editing). */
9406
+ cron: string(),
9407
+ /** Master on/off toggle for the whole schedule. */
9408
+ enabled: boolean(),
9409
+ /** `backups`-location ids this schedule writes to (fan-out set). */
9410
+ locationIds: array(string()).readonly(),
9411
+ /** Archives kept per targeted location for this schedule. */
9412
+ retentionCount: number().int().min(1).max(1e3),
9413
+ /** Optional subset of source locations to include; omitted = all. */
9414
+ dataSources: array(string()).readonly().optional(),
9415
+ /** ms-epoch of last successful run. */
9416
+ lastRunAt: number().optional(),
9417
+ /** ms-epoch of next computed firing (read-only, filled on list). */
9418
+ nextRunAt: number().optional()
8906
9419
  });
8907
- method(ListInputSchema, array(BrokerInfoSchema$1)), method(GetInputSchema, BrokerInfoSchema$1.nullable()), method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }), method(AddInputSchema, AddResultSchema, {
8908
- kind: "mutation",
8909
- auth: "admin"
8910
- }), method(RemoveInputSchema, _void(), {
8911
- kind: "mutation",
8912
- auth: "admin"
8913
- }), method(GetInputSchema, TestConnectionResultSchema, {
9420
+ method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }), method(object({
9421
+ /** Subset of registered `backup-destination` addon ids to write to. */
9422
+ destinations: array(string()).optional(),
9423
+ locations: array(string()).optional(),
9424
+ label: string().optional(),
9425
+ /**
9426
+ * Per-run retention override applied to every targeted
9427
+ * destination. Used by schedule-driven runs (per-entry
9428
+ * retention). Omitted = each destination's own policy
9429
+ * retention (manual runs).
9430
+ */
9431
+ retentionCount: number().int().min(1).max(1e3).optional()
9432
+ }).optional(), array(BackupEntrySchema).readonly(), {
8914
9433
  kind: "mutation",
8915
9434
  auth: "admin"
8916
- }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(object({
8917
- id: string(),
8918
- settings: SettingsRecordSchema$1
9435
+ }), method(_void(), array(BackupEntrySchema).readonly(), { auth: "admin" }), method(_void(), array(LocationStatSchema).readonly(), { auth: "admin" }), method(object({
9436
+ destinationId: string(),
9437
+ backupId: string()
9438
+ }), ArchiveManifestSchema.nullable(), { auth: "admin" }), method(object({
9439
+ destinationId: string(),
9440
+ backupId: string(),
9441
+ /**
9442
+ * Optional whitelist — only restore these top-level locations
9443
+ * from the archive. Default = every location the archive
9444
+ * carries (full restore). The boot-time apply hook reads
9445
+ * this list and skips entries whose path doesn't start with
9446
+ * any of them.
9447
+ */
9448
+ locations: array(string()).optional()
8919
9449
  }), _void(), {
8920
9450
  kind: "mutation",
8921
9451
  auth: "admin"
8922
- }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }), method(TestSettingsInputSchema, TestSettingsResultSchema, {
9452
+ }), method(object({
9453
+ destinationId: string(),
9454
+ backupId: string()
9455
+ }), _void(), {
8923
9456
  kind: "mutation",
8924
9457
  auth: "admin"
8925
- }), method(PublishInputSchema, unknown(), {
9458
+ }), method(object({ destinationId: string() }), array(BackupArchiveEntrySchema).readonly(), { auth: "admin" }), method(object({
9459
+ locationId: string(),
9460
+ enabled: boolean(),
9461
+ retentionCount: number().int().min(1).max(1e3),
9462
+ label: string().optional(),
9463
+ /**
9464
+ * Per-destination cron expression. Empty string clears the
9465
+ * schedule (manual-only). Validated server-side via croner;
9466
+ * malformed expressions reject the upsert with an actionable
9467
+ * message.
9468
+ */
9469
+ cron: string().optional()
9470
+ }), _void(), {
8926
9471
  kind: "mutation",
8927
9472
  auth: "admin"
8928
- }), method(SubscribeInputSchema, SubscribeResultSchema, {
9473
+ }), method(object({
9474
+ cron: string(),
9475
+ count: number().int().min(1).max(20).optional()
9476
+ }), object({
9477
+ ok: boolean(),
9478
+ error: string().optional(),
9479
+ nextRuns: array(number()).readonly()
9480
+ })), method(_void(), array(BackupScheduleSchema).readonly(), { auth: "admin" }), method(object({
9481
+ id: string().optional(),
9482
+ label: string(),
9483
+ cron: string(),
9484
+ enabled: boolean(),
9485
+ locationIds: array(string()).readonly(),
9486
+ retentionCount: number().int().min(1).max(1e3),
9487
+ dataSources: array(string()).readonly().optional()
9488
+ }), BackupScheduleSchema, {
8929
9489
  kind: "mutation",
8930
9490
  auth: "admin"
8931
- }), method(UnsubscribeInputSchema, _void(), {
9491
+ }), method(object({ id: string() }), _void(), {
8932
9492
  kind: "mutation",
8933
9493
  auth: "admin"
8934
- }), method(GetStateInputSchema, unknown().nullable()), method(_void(), RegistryStatusSchema);
8935
- DeviceType.Camera;
9494
+ });
9495
+ /**
9496
+ * `broker` — unified pub/sub broker registry, system-scoped collection.
9497
+ *
9498
+ * The cap models any kind-tagged message broker the user wants to
9499
+ * register and that other addons might consume. The first two kinds
9500
+ * are MQTT (mosquitto / aedes embedded / cloud bridge) and
9501
+ * Home Assistant (HA WebSocket — subscribe_entities + call_service).
9502
+ * Future kinds (Zigbee2MQTT bridge, ZHA, KNX, Telegram, …) plug in
9503
+ * the same surface.
9504
+ *
9505
+ * Why one cap, not one cap per kind:
9506
+ * - The integrations page wants a single table of "every broker"
9507
+ * across kinds; a unified cap drives it without join logic.
9508
+ * - Generic admin operations (add / remove / test / status) are
9509
+ * identical across kinds — duplicating them per cap was busywork.
9510
+ * - The `network-access` cap precedent: same interface, many
9511
+ * `providerKind: 'ingress'` implementations (Tailscale, ngrok, …).
9512
+ *
9513
+ * What stays kind-specific:
9514
+ * - `getBrokerConfig` payload (kind decides whether it returns
9515
+ * mqtt URL+creds, HA baseUrl+token, …) — typed as
9516
+ * `Record<string, unknown>` at the wire; consumers narrow.
9517
+ * - `publish` / `subscribe` / `getState` arguments (target / filter /
9518
+ * key) — also typed as `Record<string, unknown>` so each kind can
9519
+ * evolve its surface (MQTT topic+qos vs HA entity_id+domain)
9520
+ * without rebreaking the cap signature.
9521
+ * - `add` `settings` payload (different fields per kind, validated by
9522
+ * the kind-specific provider).
9523
+ *
9524
+ * Bidirectionality:
9525
+ * - `publish` is RPC — synchronous result (provider-defined).
9526
+ * - `subscribe` returns a subscription handle; the actual message
9527
+ * stream flows over the typed event-bus as `broker.message` events
9528
+ * keyed by `(brokerId, subscriptionId)`. Consumers filter in their
9529
+ * event handler. Subscriptions persist across reconnects — the
9530
+ * provider re-subscribes upstream after a transport drop.
9531
+ *
9532
+ * Why event-bus push (not a subscription RPC stream):
9533
+ * - Matches the rest of CamStack's D8 contract — events for telemetry,
9534
+ * RPC for loss-is-a-bug. Broker messages are telemetry (a single
9535
+ * drop is recoverable via `getState({key})`).
9536
+ * - Frees `broker.subscribe` from holding a long-lived RPC channel.
9537
+ * - Cross-process: events already route through `$event-bus`; no
9538
+ * per-broker wire-shaping needed.
9539
+ */
9540
+ var BrokerStatusEnum = _enum([
9541
+ "connected",
9542
+ "disconnected",
9543
+ "connecting",
9544
+ "auth-failed",
9545
+ "unreachable",
9546
+ "error"
9547
+ ]);
9548
+ var BrokerInfoSchema$1 = object({
9549
+ /** Stable broker id. Persisted; survives addon restarts. */
9550
+ id: string(),
9551
+ /** Addon id of the provider that OWNS this broker.
9552
+ *
9553
+ * The `broker` cap is a system-scoped collection: several addons
9554
+ * register a `broker` provider, each owning a DISJOINT set of
9555
+ * brokers (mqtt-broker owns `mqtt_*`, provider-homeassistant owns
9556
+ * `ha_*`). A broker therefore belongs to exactly one addon — like a
9557
+ * device belongs to one integration. The admin UI threads this id
9558
+ * back as the `{ addonId }` system-collection selector on every
9559
+ * id-keyed call (`get` / `getSettings` / `setSettings` / `remove` /
9560
+ * `testConnection`), so the call routes to the OWNING provider
9561
+ * instead of defaulting to the first-registered one. */
9562
+ addonId: string(),
9563
+ /** Human-readable name (operator-chosen at add-time). */
9564
+ name: string(),
9565
+ /** Provider-defined kind tag — `mqtt` / `home-assistant` / future. */
9566
+ kind: string(),
9567
+ status: BrokerStatusEnum,
9568
+ /** Free-form provider-specific info (HA version, MQTT broker
9569
+ * flavour, latency, mTLS active, …). */
9570
+ info: record(string(), unknown()),
9571
+ /** Ms epoch of the last connection probe / status update. */
9572
+ lastCheckedAt: number().nullable(),
9573
+ /** Last error message, when `status` indicates failure. */
9574
+ error: string().nullable()
9575
+ });
9576
+ var RegistryStatusSchema = object({
9577
+ brokerCount: number().int().nonnegative(),
9578
+ connectedCount: number().int().nonnegative()
9579
+ });
9580
+ /**
9581
+ * One entry per `broker` provider — which addon provides which broker
9582
+ * kind(s). The unified create picker reads this to know, for each
9583
+ * (addonId, kind) pair the operator can pick, where to route the
9584
+ * follow-up `getSettingsSchema` / `testSettings` / `add` calls (via the
9585
+ * `{ addonId }` selector). A provider only advertises a kind here when
9586
+ * it implements `getSettingsSchema` for that kind (so the picker never
9587
+ * offers a kind whose creation form can't be rendered).
9588
+ */
9589
+ var BrokerProviderInfoSchema = object({
9590
+ /** Addon id of the `broker` provider this entry describes. */
9591
+ addonId: string(),
9592
+ /** Broker kinds this provider can create, with a display label. */
9593
+ kinds: array(object({
9594
+ kind: string(),
9595
+ label: string()
9596
+ }))
9597
+ });
9598
+ var ListInputSchema = object({
9599
+ /** Optional kind filter — `list({kind:'home-assistant'})` returns
9600
+ * only HA brokers. Omit to list every broker. */
9601
+ kind: string().optional() });
9602
+ var GetInputSchema = object({ id: string() });
9603
+ var AddInputSchema = object({
9604
+ kind: string().min(1),
9605
+ name: string().min(1),
9606
+ /**
9607
+ * ADOPT an existing id instead of minting a new one.
9608
+ *
9609
+ * Written the day this cost an outage. A broker's id is not a detail: HA
9610
+ * devices carry it inside their `stableId` (`ha:ha_004:dev:…`), so a broker
9611
+ * lost from config and re-added as `ha_001` leaves every one of its devices
9612
+ * bound to a broker that no longer exists. Re-entering the password under the
9613
+ * ORIGINAL id turns a multi-step device migration back into re-entering a
9614
+ * password.
9615
+ *
9616
+ * A provider MUST refuse an id that is already in use — adopting a live
9617
+ * broker's id would silently take it over.
9618
+ */
9619
+ id: string().min(1).optional(),
9620
+ /** Kind-specific settings (e.g. MQTT `{url,username,password}` or HA
9621
+ * `{baseUrl,accessToken}`). Validated by the kind-specific provider
9622
+ * branch on receipt — invalid shape rejects the add. */
9623
+ settings: record(string(), unknown())
9624
+ });
9625
+ var AddResultSchema = object({ id: string() });
9626
+ var RemoveInputSchema = object({ id: string() });
9627
+ var TestConnectionResultSchema = discriminatedUnion("ok", [object({
9628
+ ok: literal(true),
9629
+ latencyMs: number().nonnegative()
9630
+ }), object({
9631
+ ok: literal(false),
9632
+ error: string()
9633
+ })]);
9634
+ var SettingsRecordSchema$1 = record(string(), unknown());
9635
+ var SettingsSchemaInputSchema = object({ kind: string() });
9636
+ var TestSettingsInputSchema = object({
9637
+ kind: string(),
9638
+ settings: SettingsRecordSchema$1
9639
+ });
9640
+ var TestSettingsResultSchema = discriminatedUnion("ok", [object({
9641
+ ok: literal(true),
9642
+ latencyMs: number().nonnegative().optional()
9643
+ }).strict(), object({
9644
+ ok: literal(false),
9645
+ error: string()
9646
+ })]);
9647
+ var SettingsSchemaResultSchema = unknown().nullable();
9648
+ var PublishInputSchema = object({
9649
+ brokerId: string(),
9650
+ /** Kind-specific routing target.
9651
+ * MQTT: `{ topic: string, qos?: 0|1|2, retain?: boolean }`.
9652
+ * HA: `{ domain: string, service: string, entityId?: string, area?: string }`. */
9653
+ target: record(string(), unknown()),
9654
+ /** Kind-specific payload.
9655
+ * MQTT: raw string / number / object (provider serialises).
9656
+ * HA: service-call `data` object (transition, brightness, …). */
9657
+ payload: unknown().optional()
9658
+ });
9659
+ var SubscribeInputSchema = object({
9660
+ brokerId: string(),
9661
+ /** Kind-specific filter.
9662
+ * MQTT: `{ topic: string, qos?: 0|1|2 }` — topic can include wildcards.
9663
+ * HA: `{ entityIds: string[] }` or `{ domain: string }`. */
9664
+ filter: record(string(), unknown())
9665
+ });
9666
+ var SubscribeResultSchema = object({
9667
+ /** Stable subscription id. Used to unsubscribe and to filter the
9668
+ * matching `broker.message` events on the bus. */
9669
+ subscriptionId: string() });
9670
+ var UnsubscribeInputSchema = object({
9671
+ brokerId: string(),
9672
+ subscriptionId: string()
9673
+ });
9674
+ var GetStateInputSchema = object({
9675
+ brokerId: string(),
9676
+ /** Kind-specific lookup key.
9677
+ * MQTT: topic string (returns the last retained message).
9678
+ * HA: entity_id (returns the cached entity state). */
9679
+ key: string()
9680
+ });
9681
+ method(ListInputSchema, array(BrokerInfoSchema$1)), method(GetInputSchema, BrokerInfoSchema$1.nullable()), method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }), method(AddInputSchema, AddResultSchema, {
9682
+ kind: "mutation",
9683
+ auth: "admin"
9684
+ }), method(RemoveInputSchema, _void(), {
9685
+ kind: "mutation",
9686
+ auth: "admin"
9687
+ }), method(GetInputSchema, TestConnectionResultSchema, {
9688
+ kind: "mutation",
9689
+ auth: "admin"
9690
+ }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(object({
9691
+ id: string(),
9692
+ settings: SettingsRecordSchema$1
9693
+ }), _void(), {
9694
+ kind: "mutation",
9695
+ auth: "admin"
9696
+ }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }), method(TestSettingsInputSchema, TestSettingsResultSchema, {
9697
+ kind: "mutation",
9698
+ auth: "admin"
9699
+ }), method(PublishInputSchema, unknown(), {
9700
+ kind: "mutation",
9701
+ auth: "admin"
9702
+ }), method(SubscribeInputSchema, SubscribeResultSchema, {
9703
+ kind: "mutation",
9704
+ auth: "admin"
9705
+ }), method(UnsubscribeInputSchema, _void(), {
9706
+ kind: "mutation",
9707
+ auth: "admin"
9708
+ }), method(GetStateInputSchema, unknown().nullable()), method(_void(), RegistryStatusSchema);
9709
+ DeviceType.Camera;
8936
9710
  /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
8937
9711
  var StreamFormatSchema = _enum([
8938
9712
  "webrtc",
@@ -11708,7 +12482,7 @@ DeviceType.Camera, method(object({
11708
12482
  * Why: pub/sub routing over the system event-bus loses fidelity
11709
12483
  * (callback shape, QoS guarantees, will/retain semantics) and adds
11710
12484
  * refcount bookkeeping that addons would rather own themselves. The
11711
- * canonical consumer (`addon-export-ha-mqtt`) needs raw `mqtt.js`
12485
+ * canonical consumer needs raw `mqtt.js`
11712
12486
  * features anyway — give it the connection config, get out of the way.
11713
12487
  *
11714
12488
  * Consumer flow:
@@ -14248,8 +15022,26 @@ var TrackSchema = object({
14248
15022
  /** Periodic snapshots at snapshotIntervalMs cadence (subject to
14249
15023
  * saveThumbnails policy). */
14250
15024
  snapshots: array(TrackSnapshotSchema).readonly(),
14251
- /** Deduplicated zones the track has entered at least once. */
15025
+ /** Deduplicated zones the track has entered at least once. Zone IDS. */
14252
15026
  zonesVisited: array(string()).readonly(),
15027
+ /**
15028
+ * Human NAMES for {@link zonesVisited}, resolved at READ time against the
15029
+ * `zones` capability.
15030
+ *
15031
+ * `zonesVisited` persists ids (`cfeec78c-8d69-…`), which no operator can type
15032
+ * and no card can render — so every free-text search surface was structurally
15033
+ * unable to answer "show me the tracks in Uscio", and did not fail loudly, it
15034
+ * just returned nothing. Resolving here rather than in each client keeps ONE
15035
+ * derivation and costs the clients no extra call (the `zones` cap is
15036
+ * per-device, so a client-side resolve would be a per-camera fan-out on a
15037
+ * surface built to avoid exactly that).
15038
+ *
15039
+ * Resolved, never invented: a zone deleted since the track was written has no
15040
+ * name and is DROPPED, so this array can be shorter than `zonesVisited` — the
15041
+ * two are not positionally aligned. Absent when the track visited no zone, or
15042
+ * when the zone catalogue could not be read.
15043
+ */
15044
+ zoneNames: array(string()).readonly().optional(),
14253
15045
  /** Deduplicated set of detector classes observed for this track over its
14254
15046
  * life (a track may be reclassified, e.g. person→vehicle). Absent on
14255
15047
  * legacy rows written before class accumulation shipped. */
@@ -16319,6 +17111,23 @@ var CameraRecordingStatusSchema = object({
16319
17111
  active: boolean(),
16320
17112
  storageBytes: number()
16321
17113
  });
17114
+ /** One stage of the fan-out that could NOT be read, and how long it cost. */
17115
+ var CameraStatusDegradationSchema = object({
17116
+ stage: _enum([
17117
+ "source",
17118
+ "broker",
17119
+ "detection",
17120
+ "recording",
17121
+ "switches"
17122
+ ]),
17123
+ reason: _enum([
17124
+ "timeout",
17125
+ "error",
17126
+ "partial"
17127
+ ]),
17128
+ /** Wall-clock ms spent on the stage before it was abandoned. */
17129
+ elapsedMs: number()
17130
+ });
16322
17131
  /**
16323
17132
  * Aggregated per-camera pipeline status — server-composed, single call.
16324
17133
  *
@@ -16349,9 +17158,28 @@ var CameraStatusSchema = object({
16349
17158
  * differently — a quiet camera that looks identical to a dead one is the
16350
17159
  * silence-reads-as-never-happened trap this repo keeps paying for.
16351
17160
  *
16352
- * Empty when nothing is off. Never contains a switch no provider offers.
17161
+ * Empty when nothing is off, and never contains a switch no provider offers
17162
+ * — but an empty list is only a POSITIVE claim when `degraded` does not name
17163
+ * `'switches'`. When it does, the switch set could not be read and nothing
17164
+ * here may be rendered as "the operator turned nothing off": that is the
17165
+ * D62 failure (a camera we could not read painted as broken) in the very
17166
+ * field that exists to prevent it.
16353
17167
  */
16354
17168
  switchedOff: array(CameraSwitchIdSchema).readonly(),
17169
+ /**
17170
+ * Stages of the bounded fan-out that were CUT SHORT — a timeout or a
17171
+ * rejection — and whose block is therefore `null` because we could not
17172
+ * READ it, not because there is nothing there.
17173
+ *
17174
+ * Without this, three different facts arrive as the same `null`: "the stage
17175
+ * timed out", "the stage failed", and "this camera legitimately has no
17176
+ * decoder / no recording". Every surface that draws a conclusion from a null
17177
+ * block (or from an empty `switchedOff`) must consult this first; a stage
17178
+ * named here supports no conclusion at all, only "unknown".
17179
+ *
17180
+ * Empty on a clean read — the overwhelmingly common case.
17181
+ */
17182
+ degraded: array(CameraStatusDegradationSchema).readonly(),
16355
17183
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
16356
17184
  fetchedAt: number()
16357
17185
  });
@@ -22825,809 +23653,180 @@ DeviceType.Weather;
22825
23653
  * Counts are derived from the analytics tracker (tracked detections
22826
23654
  * with stable trackIds), not raw detector hits — this filters out
22827
23655
  * one-off detector flickers and gives counts that match what the user
22828
- * sees on the live overlay.
22829
- *
22830
- * Overlap policy: a detection that intersects two zones counts in
22831
- * BOTH zones' `byClass` and `totalObjects` (count-in-each). The
22832
- * `frame` aggregate de-duplicates trivially since it's frame-wide.
22833
- * `unzoned` counts only detections that landed in zero zones.
22834
- */
22835
- var PerScopeBreakdownSchema = object({
22836
- /** Total tracked objects in this scope (frame / zone / unzoned). */
22837
- totalObjects: number().int().nonnegative(),
22838
- /** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
22839
- byClass: record(string(), number().int().nonnegative())
22840
- });
22841
- var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
22842
- zoneId: string(),
22843
- zoneName: string(),
22844
- /** TrackIds of objects currently inside this zone — for cross-reference
22845
- * with the per-track detail panel and live overlay. */
22846
- trackIds: array(string()).readonly()
22847
- });
22848
- /**
22849
- * A parked ("stationary") object surfaced alongside occupancy — an object that
22850
- * settled and stopped moving. It is NO LONGER a tracked object (the tracker was
22851
- * told to forget it so it stops re-spawning tracks/events), but it IS still
22852
- * physically present, so it keeps counting toward `frame` occupancy and is
22853
- * listed here so the UI can show it in a dedicated "Stationary" section instead
22854
- * of flooding the live event feed.
22855
- */
22856
- var StationaryObjectSchema = object({
22857
- id: string(),
22858
- className: string(),
22859
- bbox: object({
22860
- x: number(),
22861
- y: number(),
22862
- w: number(),
22863
- h: number()
22864
- }),
22865
- frameWidth: number().int().nonnegative(),
22866
- frameHeight: number().int().nonnegative(),
22867
- /** When the source track was first seen. */
22868
- firstSeenAt: number().int(),
22869
- /** When the object was recognised as parked (promotion time). */
22870
- becameStationaryAt: number().int(),
22871
- /** Last frame a detection confirmed the object is still there. */
22872
- lastConfirmedAt: number().int(),
22873
- /** Enrichment label carried from the source track (identity / plate). */
22874
- label: string().optional(),
22875
- /** Native-resolution key-frame media key for the parked object's best image. */
22876
- keyFrameMediaKey: string().optional()
22877
- });
22878
- var CameraOccupancySnapshotSchema = object({
22879
- /** Frame timestamp of the inference result that produced this snapshot. */
22880
- ts: number().int(),
22881
- /** Frame width/height in pixels — let the UI normalize bbox coords. */
22882
- frameWidth: number().int().nonnegative(),
22883
- frameHeight: number().int().nonnegative(),
22884
- /** Per-zone breakdown — one entry per defined zone (user + onboard). */
22885
- zones: array(ZoneScopeBreakdownSchema).readonly(),
22886
- /** Frame-wide aggregate (everywhere, regardless of zone membership).
22887
- * INCLUDES currently-confirmed stationary objects (they are still present). */
22888
- frame: PerScopeBreakdownSchema,
22889
- /** Detections that landed outside every zone. Empty when no zones defined. */
22890
- unzoned: PerScopeBreakdownSchema,
22891
- /** Parked objects on this camera (additive — absent on legacy snapshots).
22892
- * Surfaced separately so the UI shows them in a dedicated section rather
22893
- * than as repeated tracks/events. */
22894
- stationaryObjects: array(StationaryObjectSchema).readonly().optional()
22895
- });
22896
- /**
22897
- * Time-series resolution. The history methods return one bucket per
22898
- * step over the requested range. Smaller resolutions cost more
22899
- * memory + bandwidth; bound to discrete steps so caller cannot ask
22900
- * for arbitrary fractional buckets.
22901
- */
22902
- var HistoryResolutionEnum = _enum([
22903
- "minute",
22904
- "5min",
22905
- "hour"
22906
- ]);
22907
- var HistoryRangeSchema = object({
22908
- /** Range start (epoch ms, inclusive). */
22909
- from: number().int(),
22910
- /** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
22911
- to: number().int(),
22912
- resolution: HistoryResolutionEnum
22913
- });
22914
- var HistoryPointSchema = object({
22915
- /** Bucket midpoint (epoch ms). */
22916
- ts: number().int(),
22917
- /** Object count averaged over the bucket (rounded to nearest integer). */
22918
- count: number().int().nonnegative()
22919
- });
22920
- DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({
22921
- deviceId: number(),
22922
- zoneId: string(),
22923
- className: string().optional()
22924
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
22925
- deviceId: number(),
22926
- className: string().optional()
22927
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
22928
- deviceId: number(),
22929
- className: string().optional()
22930
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
22931
- /**
22932
- * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
22933
- * cap so a single CRUD surface backs every consumer; each stage has
22934
- * its own dev-state mirror slice (`motion-zone-rules`,
22935
- * `detection-zone-rules`, …) so consumer addons subscribe independently.
22936
- *
22937
- * Extend the enum here when a new gating consumer comes online (audio
22938
- * gating, alert filtering, …) — no other surface needs to change.
22939
- */
22940
- var ZoneRuleStageEnum = _enum([
22941
- "motion",
22942
- "detection",
22943
- "package"
22944
- ]);
22945
- DeviceType.Camera, method(object({
22946
- deviceId: number(),
22947
- stage: ZoneRuleStageEnum
22948
- }), array(ZoneRuleSchema).readonly()), method(object({
22949
- deviceId: number(),
22950
- stage: ZoneRuleStageEnum,
22951
- rules: array(ZoneRuleSchema).readonly()
22952
- }), _void(), {
22953
- kind: "mutation",
22954
- auth: "admin"
22955
- }), object({
22956
- motion: array(ZoneRuleSchema).readonly(),
22957
- detection: array(ZoneRuleSchema).readonly(),
22958
- package: array(ZoneRuleSchema).readonly()
22959
- });
22960
- /**
22961
- * Accessory device helpers — shared across drivers.
22962
- *
22963
- * Many vendor-specific drivers register accessory child devices on
22964
- * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
22965
- * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
22966
- * driver picks the right `DeviceType` + `DeviceRole` explicitly when
22967
- * spawning, builds a name derived from the parent, and produces a
22968
- * stableId tied to the parent so boot-restore can reconstruct the
22969
- * relationship.
22970
- *
22971
- * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
22972
- * drivers may reasonably disagree on the right type for an accessory
22973
- * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
22974
- * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
22975
- * one canonical mapping was over-prescriptive and added a layer of
22976
- * indirection without saving meaningful code at call sites — the
22977
- * driver knows its own hardware best.
22978
- */
22979
- /**
22980
- * Subset of `DeviceRole` values that drivers register as child
22981
- * accessories of a parent device. Sourced verbatim from `DeviceRole`
22982
- * — `AccessoryKind` is the alias drivers use when building accessory
22983
- * children, so the call site reads as
22984
- * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
22985
- * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
22986
- * any role works, including non-accessory ones like Doorbell).
22987
- */
22988
- var AccessoryKind = {
22989
- Siren: DeviceRole.Siren,
22990
- Floodlight: DeviceRole.Floodlight,
22991
- Spotlight: DeviceRole.Spotlight,
22992
- PirSensor: DeviceRole.PirSensor,
22993
- Chime: DeviceRole.Chime,
22994
- Autotrack: DeviceRole.Autotrack,
22995
- Nightvision: DeviceRole.Nightvision,
22996
- PrivacyMask: DeviceRole.PrivacyMask
22997
- };
22998
- AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
22999
- 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;
23000
- new Set(Object.values(DeviceType));
23001
- DeviceFeature.BatteryOperated;
23002
- /**
23003
- * Error types for the safe expression engine. Two distinct classes so callers
23004
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
23005
- * failure — both are non-fatal to the host: read paths degrade to "skip link".
23006
- */
23007
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
23008
- * the failure is anchored to a character (author-facing inline feedback). */
23009
- var ExpressionParseError = class extends Error {
23010
- position;
23011
- constructor(message, position) {
23012
- super(message);
23013
- this.name = "ExpressionParseError";
23014
- this.position = position;
23015
- }
23016
- };
23017
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
23018
- * result, unknown builtin, step-budget exceeded). */
23019
- var ExpressionEvalError = class extends Error {
23020
- constructor(message) {
23021
- super(message);
23022
- this.name = "ExpressionEvalError";
23023
- }
23024
- };
23025
- /**
23026
- * Frozen, null-prototype builtin function table for the expression engine
23027
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
23028
- * parser rejects any callee not in it, and the evaluator gates each call on an
23029
- * own-property check against it.
23030
- *
23031
- * Because the object has a NULL prototype AND is `Object.freeze`d:
23032
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
23033
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
23034
- * (there is no `Object.prototype` in the chain), so those names are not
23035
- * callable — they are simply "unknown function" at parse time.
23036
- *
23037
- * Every numeric argument is validated as a finite number and every numeric
23038
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
23039
- * (`pow(10,400)` → Infinity) all raise `ExpressionEvalError` and fail the link
23040
- * closed rather than emitting a garbage value.
23041
- */
23042
- function asFiniteNumber(value, name, index) {
23043
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
23044
- return value;
23045
- }
23046
- function asString$1(value, name, index) {
23047
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
23048
- return value;
23049
- }
23050
- function finiteResult(value, name) {
23051
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
23052
- return value;
23053
- }
23054
- function allFiniteNumbers(args, name) {
23055
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
23056
- }
23057
- var INF = Number.POSITIVE_INFINITY;
23058
- var table = {
23059
- min: {
23060
- minArgs: 1,
23061
- maxArgs: INF,
23062
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
23063
- },
23064
- max: {
23065
- minArgs: 1,
23066
- maxArgs: INF,
23067
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
23068
- },
23069
- abs: {
23070
- minArgs: 1,
23071
- maxArgs: 1,
23072
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
23073
- },
23074
- floor: {
23075
- minArgs: 1,
23076
- maxArgs: 1,
23077
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
23078
- },
23079
- ceil: {
23080
- minArgs: 1,
23081
- maxArgs: 1,
23082
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
23083
- },
23084
- sqrt: {
23085
- minArgs: 1,
23086
- maxArgs: 1,
23087
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
23088
- },
23089
- round: {
23090
- minArgs: 1,
23091
- maxArgs: 2,
23092
- apply: (args) => {
23093
- const x = asFiniteNumber(args[0], "round", 0);
23094
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
23095
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
23096
- const factor = 10 ** digits;
23097
- return finiteResult(Math.round(x * factor) / factor, "round");
23098
- }
23099
- },
23100
- pow: {
23101
- minArgs: 2,
23102
- maxArgs: 2,
23103
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
23104
- },
23105
- clamp: {
23106
- minArgs: 3,
23107
- maxArgs: 3,
23108
- apply: (args) => {
23109
- const x = asFiniteNumber(args[0], "clamp", 0);
23110
- const lo = asFiniteNumber(args[1], "clamp", 1);
23111
- const hi = asFiniteNumber(args[2], "clamp", 2);
23112
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
23113
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
23114
- }
23115
- },
23116
- avg: {
23117
- minArgs: 1,
23118
- maxArgs: INF,
23119
- apply: (args) => {
23120
- const nums = allFiniteNumbers(args, "avg");
23121
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
23122
- }
23123
- },
23124
- sum: {
23125
- minArgs: 1,
23126
- maxArgs: INF,
23127
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
23128
- },
23129
- coalesce: {
23130
- minArgs: 1,
23131
- maxArgs: INF,
23132
- apply: (args) => {
23133
- for (const a of args) if (a !== null) return a;
23134
- return null;
23135
- }
23136
- },
23137
- age: {
23138
- minArgs: 2,
23139
- maxArgs: 2,
23140
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
23141
- },
23142
- convert: {
23143
- minArgs: 3,
23144
- maxArgs: 3,
23145
- apply: (args, hooks) => {
23146
- const x = asFiniteNumber(args[0], "convert", 0);
23147
- const from = asString$1(args[1], "convert", 1).trim();
23148
- const to = asString$1(args[2], "convert", 2).trim();
23149
- if (hooks.convert) {
23150
- const out = hooks.convert(x, from, to);
23151
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
23152
- return finiteResult(out, "convert");
23153
- }
23154
- if (from === to) return x;
23155
- throw new ExpressionEvalError("convert: unit conversion table not installed");
23156
- }
23157
- }
23158
- };
23159
- Object.freeze(Object.assign(Object.create(null), table));
23160
- /** The set of valid builtin names — used by the parser to reject unknown
23161
- * callees at parse time (immediate author feedback). */
23162
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
23163
- /**
23164
- * Resource-bound constants for the safe expression engine.
23165
- *
23166
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
23167
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
23168
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
23169
- * work a single author-supplied expression can request, so a hostile or
23170
- * accidental pathological string can never spend unbounded CPU/memory.
23171
- */
23172
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
23173
- * rejected without allocation. */
23174
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
23175
- /** A legal binding / identifier name. */
23176
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
23177
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
23178
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
23179
- var RESERVED_BINDING_NAMES = new Set([
23180
- "now",
23181
- "true",
23182
- "false",
23183
- "null"
23184
- ]);
23185
- /**
23186
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
23187
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
23188
- * single/double-quoted strings with a tiny escape set, identifiers, the three
23189
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
23190
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
23191
- * is a parse error with a source position, so member access / assignment /
23192
- * template literals are lexically impossible.
23193
- */
23194
- var KEYWORDS = new Set([
23195
- "true",
23196
- "false",
23197
- "null"
23198
- ]);
23199
- function isDigit(ch) {
23200
- return ch >= "0" && ch <= "9";
23201
- }
23202
- function isIdentStart(ch) {
23203
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
23204
- }
23205
- function isIdentPart(ch) {
23206
- return isIdentStart(ch) || isDigit(ch);
23207
- }
23208
- function isWhitespace(ch) {
23209
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
23210
- }
23211
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
23212
- * Throws `ExpressionParseError` on any illegal character or unterminated
23213
- * string. */
23214
- function tokenize(source) {
23215
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
23216
- const tokens = [];
23217
- let i = 0;
23218
- const n = source.length;
23219
- while (i < n) {
23220
- const ch = source[i];
23221
- if (isWhitespace(ch)) {
23222
- i += 1;
23223
- continue;
23224
- }
23225
- if (isDigit(ch)) {
23226
- const start = i;
23227
- while (i < n && isDigit(source[i])) i += 1;
23228
- if (i < n && source[i] === ".") {
23229
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
23230
- i += 1;
23231
- while (i < n && isDigit(source[i])) i += 1;
23232
- }
23233
- const text = source.slice(start, i);
23234
- const value = Number(text);
23235
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
23236
- tokens.push({
23237
- type: "number",
23238
- value,
23239
- pos: start
23240
- });
23241
- continue;
23242
- }
23243
- if (ch === "'" || ch === "\"") {
23244
- const quote = ch;
23245
- const start = i;
23246
- i += 1;
23247
- let out = "";
23248
- let closed = false;
23249
- while (i < n) {
23250
- const c = source[i];
23251
- if (c === "\\") {
23252
- const next = i + 1 < n ? source[i + 1] : "";
23253
- if (next === "\\" || next === "'" || next === "\"") {
23254
- out += next;
23255
- i += 2;
23256
- continue;
23257
- }
23258
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
23259
- }
23260
- if (c === quote) {
23261
- closed = true;
23262
- i += 1;
23263
- break;
23264
- }
23265
- out += c;
23266
- i += 1;
23267
- }
23268
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
23269
- tokens.push({
23270
- type: "string",
23271
- value: out,
23272
- pos: start
23273
- });
23274
- continue;
23275
- }
23276
- if (isIdentStart(ch)) {
23277
- const start = i;
23278
- while (i < n && isIdentPart(source[i])) i += 1;
23279
- const text = source.slice(start, i);
23280
- if (KEYWORDS.has(text)) tokens.push({
23281
- type: "keyword",
23282
- keyword: keywordOf(text),
23283
- pos: start
23284
- });
23285
- else tokens.push({
23286
- type: "identifier",
23287
- name: text,
23288
- pos: start
23289
- });
23290
- continue;
23291
- }
23292
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
23293
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
23294
- tokens.push({
23295
- type: "punct",
23296
- punct: two,
23297
- pos: i
23298
- });
23299
- i += 2;
23300
- continue;
23301
- }
23302
- if (isSinglePunct(ch)) {
23303
- tokens.push({
23304
- type: "punct",
23305
- punct: ch,
23306
- pos: i
23307
- });
23308
- i += 1;
23309
- continue;
23310
- }
23311
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
23312
- }
23313
- tokens.push({
23314
- type: "eof",
23315
- pos: n
23316
- });
23317
- return tokens;
23318
- }
23319
- function keywordOf(text) {
23320
- if (text === "true") return "true";
23321
- if (text === "false") return "false";
23322
- return "null";
23323
- }
23324
- function isSinglePunct(ch) {
23325
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
23326
- }
23327
- /**
23328
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
23329
- *
23330
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
23331
- * → relational → additive → multiplicative → unary `! -` → call / primary.
23332
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
23333
- * string validated against the builtin table at parse time, so an unknown
23334
- * function is rejected immediately (author feedback) and a persisted expression
23335
- * that references a since-removed builtin degrades at read.
23336
- *
23337
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
23338
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
23339
- */
23340
- /** Binary/logical operator precedence (higher binds tighter). */
23341
- var BINARY_PRECEDENCE = {
23342
- "||": 1,
23343
- "&&": 2,
23344
- "==": 3,
23345
- "!=": 3,
23346
- "<": 4,
23347
- "<=": 4,
23348
- ">": 4,
23349
- ">=": 4,
23350
- "+": 5,
23351
- "-": 5,
23352
- "*": 6,
23353
- "/": 6,
23354
- "%": 6
23355
- };
23356
- function isLogicalOp(op) {
23357
- return op === "&&" || op === "||";
23358
- }
23359
- function isBinaryOp(op) {
23360
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
23361
- }
23362
- var Parser = class {
23363
- tokens;
23364
- pos = 0;
23365
- nodeCount = 0;
23366
- identifiers = /* @__PURE__ */ new Set();
23367
- callees = /* @__PURE__ */ new Set();
23368
- constructor(tokens) {
23369
- this.tokens = tokens;
23370
- }
23371
- parse() {
23372
- const ast = this.parseTernary();
23373
- const tok = this.peek();
23374
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
23375
- return {
23376
- ast,
23377
- identifiers: this.identifiers,
23378
- callees: this.callees,
23379
- nodeCount: this.nodeCount
23380
- };
23381
- }
23382
- peek() {
23383
- return this.tokens[this.pos];
23384
- }
23385
- next() {
23386
- return this.tokens[this.pos++];
23387
- }
23388
- /** Consume a punctuator token, erroring if the next token isn't it. */
23389
- expectPunct(punct) {
23390
- const tok = this.peek();
23391
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
23392
- this.pos += 1;
23393
- }
23394
- matchPunct(punct) {
23395
- const tok = this.peek();
23396
- if (tok.type === "punct" && tok.punct === punct) {
23397
- this.pos += 1;
23398
- return true;
23399
- }
23400
- return false;
23401
- }
23402
- countNode() {
23403
- this.nodeCount += 1;
23404
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
23405
- }
23406
- parseTernary() {
23407
- const test = this.parseBinary(1);
23408
- if (this.matchPunct("?")) {
23409
- const consequent = this.parseTernary();
23410
- this.expectPunct(":");
23411
- const alternate = this.parseTernary();
23412
- this.countNode();
23413
- return {
23414
- kind: "conditional",
23415
- test,
23416
- consequent,
23417
- alternate
23418
- };
23419
- }
23420
- return test;
23421
- }
23422
- parseBinary(minPrec) {
23423
- let left = this.parseUnary();
23424
- for (;;) {
23425
- const tok = this.peek();
23426
- if (tok.type !== "punct") break;
23427
- const prec = BINARY_PRECEDENCE[tok.punct];
23428
- if (prec === void 0 || prec < minPrec) break;
23429
- const op = tok.punct;
23430
- this.pos += 1;
23431
- const right = this.parseBinary(prec + 1);
23432
- this.countNode();
23433
- if (isLogicalOp(op)) left = {
23434
- kind: "logical",
23435
- op,
23436
- left,
23437
- right
23438
- };
23439
- else if (isBinaryOp(op)) left = {
23440
- kind: "binary",
23441
- op,
23442
- left,
23443
- right
23444
- };
23445
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
23446
- }
23447
- return left;
23448
- }
23449
- parseUnary() {
23450
- const tok = this.peek();
23451
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
23452
- const op = tok.punct;
23453
- this.pos += 1;
23454
- const operand = this.parseUnary();
23455
- this.countNode();
23456
- return {
23457
- kind: "unary",
23458
- op,
23459
- operand
23460
- };
23461
- }
23462
- return this.parsePrimary();
23463
- }
23464
- parsePrimary() {
23465
- const tok = this.next();
23466
- switch (tok.type) {
23467
- case "number":
23468
- this.countNode();
23469
- return {
23470
- kind: "literal",
23471
- value: tok.value
23472
- };
23473
- case "string":
23474
- this.countNode();
23475
- return {
23476
- kind: "literal",
23477
- value: tok.value
23478
- };
23479
- case "keyword":
23480
- this.countNode();
23481
- return {
23482
- kind: "literal",
23483
- value: tok.keyword === "null" ? null : tok.keyword === "true"
23484
- };
23485
- case "identifier": {
23486
- const nextTok = this.peek();
23487
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
23488
- this.identifiers.add(tok.name);
23489
- this.countNode();
23490
- return {
23491
- kind: "identifier",
23492
- name: tok.name
23493
- };
23494
- }
23495
- case "punct":
23496
- if (tok.punct === "(") {
23497
- const inner = this.parseTernary();
23498
- this.expectPunct(")");
23499
- return inner;
23500
- }
23501
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
23502
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
23503
- }
23504
- }
23505
- parseCall(callee, pos) {
23506
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
23507
- this.expectPunct("(");
23508
- const args = [];
23509
- if (!this.matchPunct(")")) for (;;) {
23510
- args.push(this.parseTernary());
23511
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
23512
- if (this.matchPunct(",")) continue;
23513
- this.expectPunct(")");
23514
- break;
23515
- }
23516
- this.callees.add(callee);
23517
- this.countNode();
23518
- return {
23519
- kind: "call",
23520
- callee,
23521
- args
23522
- };
23523
- }
23524
- };
23525
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
23526
- * `ExpressionParseError` on any lexical or grammatical failure. */
23527
- function parseExpression(source) {
23528
- return new Parser(tokenize(source)).parse();
23529
- }
23530
- /**
23531
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
23532
- * by expr"). The cache stores BOTH successes and failures (negative caching),
23533
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
23534
- * one per read on a hot resolve path.
23656
+ * sees on the live overlay.
23535
23657
  *
23536
- * The cache is a module-level singleton: entries are pure, content-addressed
23537
- * ASTs keyed by the raw source string, so sharing one instance across all
23538
- * callers is safe and maximises hit rate.
23658
+ * Overlap policy: a detection that intersects two zones counts in
23659
+ * BOTH zones' `byClass` and `totalObjects` (count-in-each). The
23660
+ * `frame` aggregate de-duplicates trivially since it's frame-wide.
23661
+ * `unzoned` counts only detections that landed in zero zones.
23539
23662
  */
23540
- var cache = /* @__PURE__ */ new Map();
23541
- function getCached(source) {
23542
- const hit = cache.get(source);
23543
- if (hit !== void 0) {
23544
- cache.delete(source);
23545
- cache.set(source, hit);
23546
- return hit;
23547
- }
23548
- let result;
23549
- try {
23550
- result = {
23551
- ok: true,
23552
- parsed: parseExpression(source)
23553
- };
23554
- } catch (err) {
23555
- result = {
23556
- ok: false,
23557
- error: err instanceof ExpressionParseError ? err.message : String(err)
23558
- };
23559
- }
23560
- cache.set(source, result);
23561
- if (cache.size > 256) {
23562
- const oldest = cache.keys().next().value;
23563
- if (oldest !== void 0) cache.delete(oldest);
23564
- }
23565
- return result;
23566
- }
23567
- /** Compile `source`, returning a discriminated result instead of throwing.
23568
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
23569
- function compileExpressionSafe(source) {
23570
- return getCached(source);
23571
- }
23572
- Object.freeze({});
23663
+ var PerScopeBreakdownSchema = object({
23664
+ /** Total tracked objects in this scope (frame / zone / unzoned). */
23665
+ totalObjects: number().int().nonnegative(),
23666
+ /** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
23667
+ byClass: record(string(), number().int().nonnegative())
23668
+ });
23669
+ var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
23670
+ zoneId: string(),
23671
+ zoneName: string(),
23672
+ /** TrackIds of objects currently inside this zone — for cross-reference
23673
+ * with the per-track detail panel and live overlay. */
23674
+ trackIds: array(string()).readonly()
23675
+ });
23573
23676
  /**
23574
- * Author-time validation. Returns `null` when the source is valid, else a
23575
- * human-readable error message. Checks: the expression compiles; binding count
23576
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
23577
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
23578
- * FREE identifier of the AST is covered by a binding or the injected `now`.
23677
+ * A parked ("stationary") object surfaced alongside occupancy an object that
23678
+ * settled and stopped moving. It is NO LONGER a tracked object (the tracker was
23679
+ * told to forget it so it stops re-spawning tracks/events), but it IS still
23680
+ * physically present, so it keeps counting toward `frame` occupancy and is
23681
+ * listed here so the UI can show it in a dedicated "Stationary" section instead
23682
+ * of flooding the live event feed.
23579
23683
  */
23580
- function validateExpressionSource(src) {
23581
- const names = Object.keys(src.bindings);
23582
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
23583
- for (const name of names) {
23584
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
23585
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
23586
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
23587
- }
23588
- const compiled = compileExpressionSafe(src.expr);
23589
- if (!compiled.ok) return compiled.error;
23590
- const bound = new Set(names);
23591
- for (const id of compiled.parsed.identifiers) {
23592
- if (id === "now") continue;
23593
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
23594
- }
23595
- return null;
23596
- }
23597
- var ExpressionBindingSourceSchema = union([
23598
- object({
23599
- kind: literal("field").optional(),
23600
- sourceKey: string(),
23601
- cap: string(),
23602
- fieldPath: string()
23603
- }),
23604
- object({
23605
- kind: literal("literal"),
23606
- value: union([
23607
- string(),
23608
- number(),
23609
- boolean(),
23610
- _null()
23611
- ])
23684
+ var StationaryObjectSchema = object({
23685
+ id: string(),
23686
+ className: string(),
23687
+ bbox: object({
23688
+ x: number(),
23689
+ y: number(),
23690
+ w: number(),
23691
+ h: number()
23612
23692
  }),
23613
- object({
23614
- kind: literal("global"),
23615
- sourceStableId: string(),
23616
- cap: string(),
23617
- fieldPath: string()
23618
- })
23693
+ frameWidth: number().int().nonnegative(),
23694
+ frameHeight: number().int().nonnegative(),
23695
+ /** When the source track was first seen. */
23696
+ firstSeenAt: number().int(),
23697
+ /** When the object was recognised as parked (promotion time). */
23698
+ becameStationaryAt: number().int(),
23699
+ /** Last frame a detection confirmed the object is still there. */
23700
+ lastConfirmedAt: number().int(),
23701
+ /** Enrichment label carried from the source track (identity / plate). */
23702
+ label: string().optional(),
23703
+ /** Native-resolution key-frame media key for the parked object's best image. */
23704
+ keyFrameMediaKey: string().optional()
23705
+ });
23706
+ var CameraOccupancySnapshotSchema = object({
23707
+ /** Frame timestamp of the inference result that produced this snapshot. */
23708
+ ts: number().int(),
23709
+ /** Frame width/height in pixels — let the UI normalize bbox coords. */
23710
+ frameWidth: number().int().nonnegative(),
23711
+ frameHeight: number().int().nonnegative(),
23712
+ /** Per-zone breakdown — one entry per defined zone (user + onboard). */
23713
+ zones: array(ZoneScopeBreakdownSchema).readonly(),
23714
+ /** Frame-wide aggregate (everywhere, regardless of zone membership).
23715
+ * INCLUDES currently-confirmed stationary objects (they are still present). */
23716
+ frame: PerScopeBreakdownSchema,
23717
+ /** Detections that landed outside every zone. Empty when no zones defined. */
23718
+ unzoned: PerScopeBreakdownSchema,
23719
+ /** Parked objects on this camera (additive — absent on legacy snapshots).
23720
+ * Surfaced separately so the UI shows them in a dedicated section rather
23721
+ * than as repeated tracks/events. */
23722
+ stationaryObjects: array(StationaryObjectSchema).readonly().optional()
23723
+ });
23724
+ /**
23725
+ * Time-series resolution. The history methods return one bucket per
23726
+ * step over the requested range. Smaller resolutions cost more
23727
+ * memory + bandwidth; bound to discrete steps so caller cannot ask
23728
+ * for arbitrary fractional buckets.
23729
+ */
23730
+ var HistoryResolutionEnum = _enum([
23731
+ "minute",
23732
+ "5min",
23733
+ "hour"
23619
23734
  ]);
23620
- object({
23621
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
23622
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
23623
- }).superRefine((src, ctx) => {
23624
- const err = validateExpressionSource(src);
23625
- if (err !== null) ctx.addIssue({
23626
- code: "custom",
23627
- message: err,
23628
- path: ["expr"]
23629
- });
23735
+ var HistoryRangeSchema = object({
23736
+ /** Range start (epoch ms, inclusive). */
23737
+ from: number().int(),
23738
+ /** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
23739
+ to: number().int(),
23740
+ resolution: HistoryResolutionEnum
23741
+ });
23742
+ var HistoryPointSchema = object({
23743
+ /** Bucket midpoint (epoch ms). */
23744
+ ts: number().int(),
23745
+ /** Object count averaged over the bucket (rounded to nearest integer). */
23746
+ count: number().int().nonnegative()
23747
+ });
23748
+ DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({
23749
+ deviceId: number(),
23750
+ zoneId: string(),
23751
+ className: string().optional()
23752
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
23753
+ deviceId: number(),
23754
+ className: string().optional()
23755
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
23756
+ deviceId: number(),
23757
+ className: string().optional()
23758
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
23759
+ /**
23760
+ * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
23761
+ * cap so a single CRUD surface backs every consumer; each stage has
23762
+ * its own dev-state mirror slice (`motion-zone-rules`,
23763
+ * `detection-zone-rules`, …) so consumer addons subscribe independently.
23764
+ *
23765
+ * Extend the enum here when a new gating consumer comes online (audio
23766
+ * gating, alert filtering, …) — no other surface needs to change.
23767
+ */
23768
+ var ZoneRuleStageEnum = _enum([
23769
+ "motion",
23770
+ "detection",
23771
+ "package"
23772
+ ]);
23773
+ DeviceType.Camera, method(object({
23774
+ deviceId: number(),
23775
+ stage: ZoneRuleStageEnum
23776
+ }), array(ZoneRuleSchema).readonly()), method(object({
23777
+ deviceId: number(),
23778
+ stage: ZoneRuleStageEnum,
23779
+ rules: array(ZoneRuleSchema).readonly()
23780
+ }), _void(), {
23781
+ kind: "mutation",
23782
+ auth: "admin"
23783
+ }), object({
23784
+ motion: array(ZoneRuleSchema).readonly(),
23785
+ detection: array(ZoneRuleSchema).readonly(),
23786
+ package: array(ZoneRuleSchema).readonly()
23630
23787
  });
23788
+ /**
23789
+ * Accessory device helpers — shared across drivers.
23790
+ *
23791
+ * Many vendor-specific drivers register accessory child devices on
23792
+ * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
23793
+ * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
23794
+ * driver picks the right `DeviceType` + `DeviceRole` explicitly when
23795
+ * spawning, builds a name derived from the parent, and produces a
23796
+ * stableId tied to the parent so boot-restore can reconstruct the
23797
+ * relationship.
23798
+ *
23799
+ * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
23800
+ * drivers may reasonably disagree on the right type for an accessory
23801
+ * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
23802
+ * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
23803
+ * one canonical mapping was over-prescriptive and added a layer of
23804
+ * indirection without saving meaningful code at call sites — the
23805
+ * driver knows its own hardware best.
23806
+ */
23807
+ /**
23808
+ * Subset of `DeviceRole` values that drivers register as child
23809
+ * accessories of a parent device. Sourced verbatim from `DeviceRole`
23810
+ * — `AccessoryKind` is the alias drivers use when building accessory
23811
+ * children, so the call site reads as
23812
+ * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
23813
+ * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
23814
+ * any role works, including non-accessory ones like Doorbell).
23815
+ */
23816
+ var AccessoryKind = {
23817
+ Siren: DeviceRole.Siren,
23818
+ Floodlight: DeviceRole.Floodlight,
23819
+ Spotlight: DeviceRole.Spotlight,
23820
+ PirSensor: DeviceRole.PirSensor,
23821
+ Chime: DeviceRole.Chime,
23822
+ Autotrack: DeviceRole.Autotrack,
23823
+ Nightvision: DeviceRole.Nightvision,
23824
+ PrivacyMask: DeviceRole.PrivacyMask
23825
+ };
23826
+ AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
23827
+ 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;
23828
+ new Set(Object.values(DeviceType));
23829
+ DeviceFeature.BatteryOperated;
23631
23830
  Object.freeze({
23632
23831
  "accessories.setChildHidden": {
23633
23832
  capName: "accessories",
@@ -29254,7 +29453,7 @@ var AdminUIAddon = class extends BaseAddon {
29254
29453
  capability: adminUiCapability,
29255
29454
  provider: {
29256
29455
  getStaticDir: async () => ({ staticDir: resolveAdminUiDistDir() }),
29257
- getVersion: async () => ({ version: "1.2.38" })
29456
+ getVersion: async () => ({ version: "1.2.40" })
29258
29457
  }
29259
29458
  }];
29260
29459
  }