@camstack/addon-admin-ui 1.2.35 → 1.2.37

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 (116) hide show
  1. package/dist/assets/{AddLocationWizard-BpkZ7g8M.js → AddLocationWizard-ruAb2ySj.js} +1 -1
  2. package/dist/assets/AddonCollectionPage-y9PlVqw3.js +1 -0
  3. package/dist/assets/Addons-cAsBu2OQ.js +1 -0
  4. package/dist/assets/{AdminTabs-BzqgdZo8.js → AdminTabs-BVHcviOJ.js} +1 -1
  5. package/dist/assets/{AiProfiles-Du2Gut1p.js → AiProfiles-B6EskvNu.js} +1 -1
  6. package/dist/assets/{BrokerForm-en5xEKn5.js → BrokerForm-DIw2g_KM.js} +1 -1
  7. package/dist/assets/Build-sVmfeGFf.js +29 -0
  8. package/dist/assets/Capabilities-CTlKJVid.js +1 -0
  9. package/dist/assets/{CapabilityBadges-2-jFnJcJ.js → CapabilityBadges-B_ZC7k04.js} +1 -1
  10. package/dist/assets/Cluster-DkXZdWpm.js +1 -0
  11. package/dist/assets/{ClusterRecordingTab-BdEH4zMe.js → ClusterRecordingTab-U0cdF9UF.js} +1 -1
  12. package/dist/assets/{Dashboard-Bi7LNLdP.js → Dashboard-BCNcg--7.js} +1 -1
  13. package/dist/assets/Data-ZtVnR1Fe.js +1 -0
  14. package/dist/assets/DetectionIntelligence-BlZl9E8S.js +1 -0
  15. package/dist/assets/{DeviceAggregateSection-D8oQ4avR.js → DeviceAggregateSection-UxtJHE5B.js} +1 -1
  16. package/dist/assets/DeviceDetail-BbYvgUSY.js +2 -0
  17. package/dist/assets/{Devices-BmmGXj4K.js → Devices-DzLVWcFg.js} +1 -1
  18. package/dist/assets/{EmbedPlayerPage-CRkfP_Ng.js → EmbedPlayerPage-C5yT7D0W.js} +1 -1
  19. package/dist/assets/Events-BGJ5su4V.js +1 -0
  20. package/dist/assets/FormBuilder-C0N6NRfe.js +1 -0
  21. package/dist/assets/Identity-FI4f8ox5.js +1 -0
  22. package/dist/assets/IntegrationDetail-DxAOxN6t.js +1 -0
  23. package/dist/assets/Integrations-D5S4Yf1A.js +1 -0
  24. package/dist/assets/{Integrations-CsuR-wnp.js → Integrations-w3RuUxDV.js} +1 -1
  25. package/dist/assets/{Logs-D-v1pY1C.js → Logs-BfDIf7QZ.js} +1 -1
  26. package/dist/assets/{MotionZonesSettings-DSAuUczY.js → MotionZonesSettings-CHyWV5lm.js} +1 -1
  27. package/dist/assets/{MyAccess-CSduDxGh.js → MyAccess-76Y7QhMu.js} +1 -1
  28. package/dist/assets/Network-BAUdETHi.js +1 -0
  29. package/dist/assets/{NodeAddonsSettingsPanel-0BTA46bg.js → NodeAddonsSettingsPanel-CeWEr-Zg.js} +1 -1
  30. package/dist/assets/NodeInferenceDevices-CUinkSSN.js +1 -0
  31. package/dist/assets/Notifications-CK3PuxFQ.js +1 -0
  32. package/dist/assets/Pipeline-CTSS1aGu.js +1 -0
  33. package/dist/assets/Plates-Cns09Gg2.js +1 -0
  34. package/dist/assets/{PrivacyMaskSettings-CfGREDOs.js → PrivacyMaskSettings-C4ycA7Un.js} +1 -1
  35. package/dist/assets/{ProviderIcon-mSy1d917.js → ProviderIcon-DyK-gNEs.js} +1 -1
  36. package/dist/assets/Recordings-UAlZznSu.js +1 -0
  37. package/dist/assets/{ScanNetworkModal-DsEiGH4q.js → ScanNetworkModal-h3WtjMhA.js} +1 -1
  38. package/dist/assets/{Settings-_QzMAEQz.js → Settings-oupU5GDf.js} +1 -1
  39. package/dist/assets/{Showroom-BrqmuRQr.js → Showroom-HqqCUr23.js} +1 -1
  40. package/dist/assets/{Terminal-D6ADzUw6.js → Terminal-7B0qx8ho.js} +2 -2
  41. package/dist/assets/{_virtual_mf-localSharedImportMap___mfe_internal__admin_ui_host-D6JCOoeB.js → _virtual_mf-localSharedImportMap___mfe_internal__admin_ui_host-BnceOEA8.js} +1 -1
  42. package/dist/assets/authed-media-B8T1dsFh.js +1 -0
  43. package/dist/assets/{rotate-ccw-RsWJHCWz.js → boxes-BS6UzXs9.js} +1 -1
  44. package/dist/assets/{build-cluster-graph-DK8tHUpQ.js → build-cluster-graph-CO-MCBmD.js} +2 -2
  45. package/dist/assets/circle-check-big-DKZCFZff.js +1 -0
  46. package/dist/assets/clock-DqPUDB1Y.js +1 -0
  47. package/dist/assets/{copy-CiHOHvWK.js → copy-bKWchDUS.js} +1 -1
  48. package/dist/assets/debounced-invalidate-CoCA-chF.js +1 -0
  49. package/dist/assets/dist-B66gZe27.js +7 -0
  50. package/dist/assets/{dist-CD_UFqu_.js → dist-CXHmz2j1.js} +1 -1
  51. package/dist/assets/{download-BbJFh_GP.js → download-C3-lXLSJ.js} +1 -1
  52. package/dist/assets/{face-bbox-DFwfVPg5.js → face-bbox-Cb2eJK0t.js} +1 -1
  53. package/dist/assets/{hostInit-Gt7MZSrm.js → hostInit-DtkE57IB.js} +1 -1
  54. package/dist/assets/index-Bke_0boK.js +4 -0
  55. package/dist/assets/index-mdJi1UIz.css +2 -0
  56. package/dist/assets/{key-round-wDYpv-03.js → key-round-CSNhr2jN.js} +1 -1
  57. package/dist/assets/{mf-entry-bootstrap-0-eafb02b4.js → mf-entry-bootstrap-0-e0821d3f.js} +2 -2
  58. package/dist/assets/{pencil-CGcp1H_S.js → pencil-Dbscj0iB.js} +1 -1
  59. package/dist/assets/{player-overlays-DnLMvF2y.js → player-overlays-CCOENCr_.js} +1 -1
  60. package/dist/assets/plus-kjGKne3v.js +1 -0
  61. package/dist/assets/{radio-DejuKcx3.js → radio-BCrXf-T5.js} +1 -1
  62. package/dist/assets/{refresh-cw-CW_Ljqdp.js → refresh-cw-B50UNH29.js} +1 -1
  63. package/dist/assets/remoteEntry-DSjWOwlm.js +1 -0
  64. package/dist/assets/rotate-ccw-CnFi79eH.js +1 -0
  65. package/dist/assets/rotate-cw-BMi2-EKp.js +1 -0
  66. package/dist/assets/search-Cqbwip0Z.js +1 -0
  67. package/dist/assets/{send-CJWIa1yB.js → send-vIA2Sg01.js} +1 -1
  68. package/dist/assets/{server-DLCgy7fU.js → server-CVgGKwKo.js} +1 -1
  69. package/dist/assets/square-NIJNDIvZ.js +1 -0
  70. package/dist/assets/{square-check-big-C1eD-s2U.js → square-check-big-DTiBKI6p.js} +1 -1
  71. package/dist/assets/src-C3IwZqep.js +7 -0
  72. package/dist/assets/src-DiF3p-RR.js +67 -0
  73. package/dist/assets/{undo-2-B2bADvVZ.js → undo-2-buMXiXDJ.js} +1 -1
  74. package/dist/assets/{upload-DeJAhsSx.js → upload-YICZuTHD.js} +1 -1
  75. package/dist/assets/{useEventInvalidation-BuTU98fj.js → useEventInvalidation-CGT7WKQk.js} +1 -1
  76. package/dist/assets/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__admin_ui_host__remoteEntry-_hash_-CwEvP83T.js → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__admin_ui_host__remoteEntry-_hash_-CbdC118i.js} +2 -2
  77. package/dist/assets/{wifi-BuwizIJA.js → wifi-B6uMvniy.js} +1 -1
  78. package/dist/assets/{zap-CWuo4dzD.js → zap-O3h5iXhW.js} +1 -1
  79. package/dist/index.html +6 -6
  80. package/dist/server/addon.js +1361 -1283
  81. package/package.json +1 -1
  82. package/dist/assets/AddonCollectionPage-CqEMePgW.js +0 -1
  83. package/dist/assets/Addons-cCLSqWfM.js +0 -1
  84. package/dist/assets/Automation-CryfmheY.js +0 -29
  85. package/dist/assets/Capabilities-DOVICQ71.js +0 -1
  86. package/dist/assets/Cluster-DSXKolnm.js +0 -1
  87. package/dist/assets/Data-CTCmNwJC.js +0 -1
  88. package/dist/assets/DetectionIntelligence-3cLwl4I3.js +0 -1
  89. package/dist/assets/DeviceDetail-BcRXDqYg.js +0 -2
  90. package/dist/assets/Events-CRvWzt-T.js +0 -1
  91. package/dist/assets/FormBuilder-D36jBkTY.js +0 -1
  92. package/dist/assets/Identity-D6X1Vx5c.js +0 -1
  93. package/dist/assets/IntegrationDetail-Kd39JHzv.js +0 -1
  94. package/dist/assets/Integrations-BUaA-BHV.js +0 -1
  95. package/dist/assets/Network-D2GsfRiT.js +0 -1
  96. package/dist/assets/NodeInferenceDevices-FEzIpBGg.js +0 -1
  97. package/dist/assets/Notifications-C2xW5T2d.js +0 -1
  98. package/dist/assets/Pipeline-BAy-OSuJ.js +0 -1
  99. package/dist/assets/Plates-B-enbHRO.js +0 -1
  100. package/dist/assets/Recordings-CVuEjle9.js +0 -1
  101. package/dist/assets/Retrain-B38fDPkv.js +0 -1
  102. package/dist/assets/authed-media-XfBD4Ma-.js +0 -1
  103. package/dist/assets/circle-check-big-CmRUug-s.js +0 -1
  104. package/dist/assets/clock-CbQ_m2UA.js +0 -1
  105. package/dist/assets/debounced-invalidate-CS1Zql4g.js +0 -1
  106. package/dist/assets/dist-DyDQKWiO.js +0 -7
  107. package/dist/assets/image-off-CePvOw2Z.js +0 -1
  108. package/dist/assets/index-D4VDD87s.css +0 -2
  109. package/dist/assets/index-DzefosH4.js +0 -4
  110. package/dist/assets/plus-CWclgqCc.js +0 -1
  111. package/dist/assets/remoteEntry-DNdwEnrY.js +0 -1
  112. package/dist/assets/rotate-cw-Bj3NywrT.js +0 -1
  113. package/dist/assets/search-kZ6MJ1BT.js +0 -1
  114. package/dist/assets/square-I9BIO4a-.js +0 -1
  115. package/dist/assets/src-C2KiI31z.js +0 -67
  116. package/dist/assets/src-DREH726E.js +0 -7
@@ -8940,6 +8940,69 @@ var StreamFormatSchema = _enum([
8940
8940
  "mjpeg",
8941
8941
  "rtsp"
8942
8942
  ]);
8943
+ /** A container `produceEventMedia` can emit. */
8944
+ var EventMediaKindSchema = _enum(["mp4", "gif"]);
8945
+ /**
8946
+ * One produced artifact, referenced by HANDLE.
8947
+ *
8948
+ * Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
8949
+ * method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
8950
+ * [D18](../../../../docs/decisions/adr-0018.md) — cross-process media is fetched
8951
+ * on demand, compressed, by handle). `bytes` is here so a caller can decide
8952
+ * whether it wants the fetch at all.
8953
+ */
8954
+ var EventMediaArtifactSchema = object({
8955
+ kind: EventMediaKindSchema,
8956
+ /** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
8957
+ handle: string(),
8958
+ /**
8959
+ * The node holding the bytes — the ROUTING key for `fetchEventMedia`.
8960
+ *
8961
+ * `stream-broker` is a singleton cap and an unpinned call never leaves the
8962
+ * hub, so a handle produced on an agent's broker would be redeemed against
8963
+ * the hub's store and come back `null`. Same contract, same field name and
8964
+ * the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
8965
+ * lives and the consumer pins to it.
8966
+ */
8967
+ nodeId: string(),
8968
+ mime: string(),
8969
+ bytes: number().int(),
8970
+ width: number().int(),
8971
+ height: number().int()
8972
+ });
8973
+ /**
8974
+ * What a production actually covered — the answer to the only question an
8975
+ * operator asks about a notification clip.
8976
+ *
8977
+ * `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
8978
+ * so a caller can state "this clip starts 4.1 s before the event" instead of
8979
+ * inferring it from a duration. A production whose `fromTs` is later than the
8980
+ * event is a production with no pre-roll, and that is exactly the defect this
8981
+ * method exists to make visible rather than plausible.
8982
+ */
8983
+ var EventMediaCoverageSchema = object({
8984
+ fromTs: number(),
8985
+ toTs: number(),
8986
+ /** Encoded packets in the muxed window. */
8987
+ packets: number().int()
8988
+ });
8989
+ /**
8990
+ * The result of ONE cut, in every container the caller asked for.
8991
+ *
8992
+ * Every artifact in `media` came out of the SAME window of the SAME rendition —
8993
+ * that is the whole reason this is one method rather than one call per format.
8994
+ * A consumer attaching a gif and a video can no longer show two different
8995
+ * moments, because it never chose two sources.
8996
+ */
8997
+ var EventMediaProductionSchema = object({
8998
+ media: array(EventMediaArtifactSchema).readonly(),
8999
+ coverage: EventMediaCoverageSchema,
9000
+ /** The rendition actually cut from — what the default or the fallback chose. */
9001
+ profile: CamProfileSchema,
9002
+ /** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
9003
+ * source, a downscale, or a playback rate other than 1). */
9004
+ video: _enum(["copy", "encode"])
9005
+ });
8943
9006
  var RtspRestreamEntrySchema = object({
8944
9007
  brokerId: string(),
8945
9008
  url: string(),
@@ -9339,6 +9402,56 @@ method(object({
9339
9402
  }), {
9340
9403
  kind: "mutation",
9341
9404
  auth: "admin"
9405
+ }), method(object({
9406
+ deviceId: number(),
9407
+ /** Absent = the largest H.264 rendition at or below 1080p, which is
9408
+ * also the one that can be copied. Falls back to whatever the ring
9409
+ * actually retained, and the answer says which. */
9410
+ profile: CamProfileSchema.optional(),
9411
+ aroundMs: number(),
9412
+ preSeconds: number().min(0).max(20).default(4),
9413
+ postSeconds: number().min(0).max(20).default(6),
9414
+ kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
9415
+ /** GIF geometry. The video keeps the source's own. */
9416
+ gifMaxWidth: number().int().min(120).max(1280).default(640),
9417
+ /**
9418
+ * The gif's own PLAYBACK rate in frames per second — what the finished
9419
+ * gif runs at, not how many source frames feed it. The decimation that
9420
+ * feeds it samples `gifFps / gifSpeed` source frames per second, so at
9421
+ * the defaults a 12 fps gif is built out of 3 source frames a second.
9422
+ */
9423
+ gifFps: number().int().min(1).max(15).default(12),
9424
+ /**
9425
+ * How fast the GIF plays against real time, independent of `speed`.
9426
+ *
9427
+ * 4× by default, by operator request: a notification gif is glanced at
9428
+ * on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
9429
+ * a separate knob from `speed` even though both now default to 4 —
9430
+ * a caller wanting a real-time video and a fast gif must not have to
9431
+ * choose.
9432
+ */
9433
+ gifSpeed: number().min(1).max(8).default(4),
9434
+ /**
9435
+ * Playback rate of the VIDEO. Also 4× by default, by operator decision.
9436
+ *
9437
+ * `1` is real time and is the ONLY value that allows the copy branch —
9438
+ * anything else forces `libx264` over the window. That was priced
9439
+ * before it was chosen: a per-event burst measured at 0.23 s and 254 KB
9440
+ * on a real 615 720p cut, against 922 KB for the copy it replaces. A
9441
+ * re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
9442
+ * once the decode is forced the width stops being free.
9443
+ */
9444
+ speed: number().min(1).max(8).default(4)
9445
+ }), EventMediaProductionSchema, {
9446
+ kind: "mutation",
9447
+ auth: "admin"
9448
+ }), method(object({ handle: string() }), object({
9449
+ base64: string(),
9450
+ mime: string(),
9451
+ bytes: number().int()
9452
+ }).nullable(), {
9453
+ kind: "mutation",
9454
+ auth: "admin"
9342
9455
  }), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
9343
9456
  probed: boolean(),
9344
9457
  summary: string()
@@ -9531,25 +9644,6 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
9531
9644
  }),
9532
9645
  lastChangedAt: number()
9533
9646
  });
9534
- /**
9535
- * core-blocks — user-authored TypeScript, stored in the kernel and executed in
9536
- * its own process.
9537
- *
9538
- * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
9539
- *
9540
- * The first use is **owning devices without being a device provider**: a block
9541
- * declares devices under a system or custom integration and drives their state,
9542
- * with the same `ctx` an addon gets. Automations come later; nothing here
9543
- * models a trigger.
9544
- *
9545
- * **Stated plainly, because it does not change by being true:** a block has an
9546
- * addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
9547
- * with no review step. What makes that survivable is not a sandbox, it is
9548
- * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
9549
- * so a block that throws or never returns is marked `failed` and visible
9550
- * instead of taking the hub with it (D6). Every method here is admin-only, and
9551
- * must stay so.
9552
- */
9553
9647
  /** Where a block runs. The operator chooses — a block driving a device on an
9554
9648
  * agent is the reason placement is not fixed to the hub. */
9555
9649
  var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
@@ -9621,6 +9715,9 @@ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }
9621
9715
  }), object({ block: CoreBlockSchema }), {
9622
9716
  kind: "mutation",
9623
9717
  auth: "admin"
9718
+ }), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
9719
+ kind: "mutation",
9720
+ auth: "admin"
9624
9721
  }), method(object({ code: string() }), CoreBlockCompileResultSchema, {
9625
9722
  kind: "mutation",
9626
9723
  auth: "admin"
@@ -10299,840 +10396,159 @@ var ExposeInputSchema = object({
10299
10396
  });
10300
10397
  var UnexposeInputSchema = object({ deviceId: string() });
10301
10398
  method(_void(), DeviceExportStatusSchema), method(_void(), array(DeviceKindSchema)), method(_void(), array(ExposedDeviceSchema)), method(ExposeInputSchema, _void(), { kind: "mutation" }), method(UnexposeInputSchema, _void(), { kind: "mutation" });
10399
+ var ProviderStatusSchema = object({
10400
+ connected: boolean(),
10401
+ deviceCount: number(),
10402
+ error: string().optional()
10403
+ });
10404
+ object({
10405
+ externalId: string(),
10406
+ name: string(),
10407
+ type: string(),
10408
+ metadata: record(string(), unknown()).optional()
10409
+ });
10302
10410
  /**
10303
- * Resource-bound constants for the safe expression engine.
10304
- *
10305
- * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
10306
- * loops, recursion, lambdas or member access — see `ast.ts`), so evaluation is
10307
- * O(nodeCount) by construction. These caps merely put a hard ceiling on the
10308
- * work a single author-supplied expression can request, so a hostile or
10309
- * accidental pathological string can never spend unbounded CPU/memory.
10411
+ * Candidate handed back from discovery and accepted by
10412
+ * `adoptDiscoveredDevice`. Shape mirrors the in-process
10413
+ * `DiscoveredDevice` interface used by `DeviceDiscovery`.
10310
10414
  */
10311
- /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
10312
- * rejected without allocation. */
10313
- var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
10314
- /** A legal binding / identifier name. */
10315
- var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
10316
- /** Binding names an author may NOT use: `now` is auto-injected; the literal
10317
- * keywords lex as values, not identifiers, so binding to them is meaningless. */
10318
- var RESERVED_BINDING_NAMES = new Set([
10319
- "now",
10320
- "true",
10321
- "false",
10322
- "null"
10323
- ]);
10415
+ var DiscoveryCandidateSchema = object({
10416
+ stableId: string(),
10417
+ type: _enum(DeviceType),
10418
+ suggestedName: string(),
10419
+ prefilledConfig: record(string(), unknown()),
10420
+ /**
10421
+ * Optional upstream-system identity (HA entity_id, vendor MAC, …).
10422
+ * Discovery pre-populates this for systems that know the upstream
10423
+ * identity ahead of adoption. Rendering metadata (unit, precision)
10424
+ * flows live through the cap STATUS SLICE after adoption.
10425
+ */
10426
+ sourceInfo: SourceInfoSchema.optional()
10427
+ });
10324
10428
  /**
10325
- * Error types for the safe expression engine. Two distinct classes so callers
10326
- * can tell a compile-time (grammar) failure from a runtime (evaluation)
10327
- * failure both are non-fatal to the host: read paths degrade to "skip link".
10429
+ * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
10430
+ * Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
10431
+ * tRPC layer can pass it through without reshaping.
10328
10432
  */
10329
- /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
10330
- * the failure is anchored to a character (author-facing inline feedback). */
10331
- var ExpressionParseError = class extends Error {
10332
- position;
10333
- constructor(message, position) {
10334
- super(message);
10335
- this.name = "ExpressionParseError";
10336
- this.position = position;
10337
- }
10338
- };
10339
- /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
10340
- * result, unknown builtin, step-budget exceeded). */
10341
- var ExpressionEvalError = class extends Error {
10342
- constructor(message) {
10343
- super(message);
10344
- this.name = "ExpressionEvalError";
10345
- }
10346
- };
10433
+ var DeviceSummarySchema = object({
10434
+ id: number(),
10435
+ stableId: string(),
10436
+ addonId: string(),
10437
+ type: string(),
10438
+ name: string(),
10439
+ parentDeviceId: number().nullable(),
10440
+ online: boolean(),
10441
+ features: array(string()),
10442
+ config: record(string(), unknown()),
10443
+ /** Optional upstream-system identity (dispatch key + system tag).
10444
+ * See `SourceInfo`. Present when the device has a non-synthetic
10445
+ * source identifier (HA entities, vendor MAC, …); omitted when the
10446
+ * synthetic backfill is in effect. */
10447
+ sourceInfo: SourceInfoSchema.optional()
10448
+ });
10347
10449
  /**
10348
- * Frozen, null-prototype builtin function table for the expression engine
10349
- * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
10350
- * parser rejects any callee not in it, and the evaluator gates each call on an
10351
- * own-property check against it.
10450
+ * Result of a live field test (e.g. probing an RTSP URL during device
10451
+ * creation). Matches the UI-side `FieldProbeResult` in
10452
+ * `interfaces/config-ui.ts` the admin `FormBuilder` renders the
10453
+ * returned `labels` as chips next to the input.
10454
+ */
10455
+ var FieldProbeResultSchema = object({
10456
+ status: _enum(["ok", "error"]),
10457
+ labels: array(string()).optional(),
10458
+ error: string().optional()
10459
+ });
10460
+ /**
10461
+ * The output of `getChildCreationSchema` is a UI schema tree. We store
10462
+ * it as `unknown` at the capability layer — the router just passes it
10463
+ * through and the admin UI renders it via `FormBuilder`. The actual
10464
+ * type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
10465
+ * but we deliberately avoid a Zod mirror because the union is large and
10466
+ * not meant for runtime validation at this seam.
10467
+ */
10468
+ var CreationSchemaOutputSchema = unknown();
10469
+ method(_void(), _void(), { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), ProviderStatusSchema), method(_void(), array(object({
10470
+ id: string(),
10471
+ name: string(),
10472
+ type: string()
10473
+ }))), method(object({}), boolean()), method(object({ params: record(string(), unknown()).optional() }), array(DiscoveryCandidateSchema), {
10474
+ kind: "mutation",
10475
+ auth: "admin"
10476
+ }), method(object({}), CreationSchemaOutputSchema), method(object({}), object({ deviceType: _enum(DeviceType).nullable() })), method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
10477
+ kind: "mutation",
10478
+ auth: "admin"
10479
+ }), method(object({}), boolean()), method(object({ type: _enum(DeviceType) }), CreationSchemaOutputSchema), method(object({
10480
+ type: _enum(DeviceType),
10481
+ config: record(string(), unknown())
10482
+ }), DeviceSummarySchema, {
10483
+ kind: "mutation",
10484
+ auth: "admin"
10485
+ }), method(object({
10486
+ type: _enum(DeviceType),
10487
+ key: string(),
10488
+ value: unknown(),
10489
+ formValues: record(string(), unknown()).optional()
10490
+ }), FieldProbeResultSchema, {
10491
+ kind: "mutation",
10492
+ auth: "admin"
10493
+ });
10494
+ /**
10495
+ * Device Manager capability — hub-side singleton that unifies device persistence,
10496
+ * live registry access, and all management operations into a single tRPC surface.
10352
10497
  *
10353
- * Because the object has a NULL prototype AND is `Object.freeze`d:
10354
- * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
10355
- * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
10356
- * (there is no `Object.prototype` in the chain), so those names are not
10357
- * callable — they are simply "unknown function" at parse time.
10498
+ * Replaces:
10499
+ * - `device-persistence` capability (persistence methods absorbed here)
10500
+ * - `device-management.router.ts` (deleted in Phase 2)
10501
+ * - `device-ops.router.ts` (compat layer deleted; device-provider ops absorbed here)
10358
10502
  *
10359
- * Every numeric argument is validated as a finite number and every numeric
10360
- * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
10361
- * (`pow(10,400)` Infinity) all raise `ExpressionEvalError` and fail the link
10362
- * closed rather than emitting a garbage value.
10503
+ * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
10504
+ * fork into separate processes but never run on remote cluster agents. Therefore:
10505
+ * - No nodeId routing needed this is a pure hub singleton.
10506
+ * - The hub's DeviceRegistry is the single source of truth for all live devices.
10507
+ * - No shadow registry or cross-node aggregation required.
10508
+ *
10509
+ * Forked workers register devices back to the hub via `ctx.devices`
10510
+ * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
10363
10511
  */
10364
- function asFiniteNumber(value, name, index) {
10365
- if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
10366
- return value;
10367
- }
10368
- function asString$1(value, name, index) {
10369
- if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
10370
- return value;
10371
- }
10372
- function finiteResult(value, name) {
10373
- if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
10374
- return value;
10375
- }
10376
- function allFiniteNumbers(args, name) {
10377
- return args.map((a, idx) => asFiniteNumber(a, name, idx));
10378
- }
10379
- var INF = Number.POSITIVE_INFINITY;
10380
- var table = {
10381
- min: {
10382
- minArgs: 1,
10383
- maxArgs: INF,
10384
- apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
10385
- },
10386
- max: {
10387
- minArgs: 1,
10388
- maxArgs: INF,
10389
- apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
10390
- },
10391
- abs: {
10392
- minArgs: 1,
10393
- maxArgs: 1,
10394
- apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
10395
- },
10396
- floor: {
10397
- minArgs: 1,
10398
- maxArgs: 1,
10399
- apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
10400
- },
10401
- ceil: {
10402
- minArgs: 1,
10403
- maxArgs: 1,
10404
- apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
10405
- },
10406
- sqrt: {
10407
- minArgs: 1,
10408
- maxArgs: 1,
10409
- apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
10410
- },
10411
- round: {
10412
- minArgs: 1,
10413
- maxArgs: 2,
10414
- apply: (args) => {
10415
- const x = asFiniteNumber(args[0], "round", 0);
10416
- const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
10417
- if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
10418
- const factor = 10 ** digits;
10419
- return finiteResult(Math.round(x * factor) / factor, "round");
10420
- }
10421
- },
10422
- pow: {
10423
- minArgs: 2,
10424
- maxArgs: 2,
10425
- apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
10426
- },
10427
- clamp: {
10428
- minArgs: 3,
10429
- maxArgs: 3,
10430
- apply: (args) => {
10431
- const x = asFiniteNumber(args[0], "clamp", 0);
10432
- const lo = asFiniteNumber(args[1], "clamp", 1);
10433
- const hi = asFiniteNumber(args[2], "clamp", 2);
10434
- if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
10435
- return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
10436
- }
10437
- },
10438
- avg: {
10439
- minArgs: 1,
10440
- maxArgs: INF,
10441
- apply: (args) => {
10442
- const nums = allFiniteNumbers(args, "avg");
10443
- return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
10444
- }
10445
- },
10446
- sum: {
10447
- minArgs: 1,
10448
- maxArgs: INF,
10449
- apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
10450
- },
10451
- coalesce: {
10452
- minArgs: 1,
10453
- maxArgs: INF,
10454
- apply: (args) => {
10455
- for (const a of args) if (a !== null) return a;
10456
- return null;
10457
- }
10458
- },
10459
- age: {
10460
- minArgs: 2,
10461
- maxArgs: 2,
10462
- apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
10463
- },
10464
- convert: {
10465
- minArgs: 3,
10466
- maxArgs: 3,
10467
- apply: (args, hooks) => {
10468
- const x = asFiniteNumber(args[0], "convert", 0);
10469
- const from = asString$1(args[1], "convert", 1).trim();
10470
- const to = asString$1(args[2], "convert", 2).trim();
10471
- if (hooks.convert) {
10472
- const out = hooks.convert(x, from, to);
10473
- if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
10474
- return finiteResult(out, "convert");
10475
- }
10476
- if (from === to) return x;
10477
- throw new ExpressionEvalError("convert: unit conversion table not installed");
10478
- }
10479
- }
10480
- };
10481
- Object.freeze(Object.assign(Object.create(null), table));
10482
- /** The set of valid builtin names — used by the parser to reject unknown
10483
- * callees at parse time (immediate author feedback). */
10484
- var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
10512
+ /** One child-placement directive on a container's `childLayout`. Structurally
10513
+ * identical to `ChildLayoutEntry` in `device-management.ts` the cap wire
10514
+ * shape for the same field. The child is identified by its re-sync-stable
10515
+ * accessory `stableIdSuffix` (`childKey`); listed children are grouped into
10516
+ * named accordion sections (with optional intra-section order). */
10517
+ var ChildLayoutEntrySchema = object({
10518
+ childKey: string(),
10519
+ section: string(),
10520
+ order: number().optional(),
10521
+ collapsed: boolean().optional()
10522
+ });
10523
+ /** Cap-wire shape of a per-cap display refinement — mirrors
10524
+ * `DeviceCapDisplayOverride` in `device-management.ts`. */
10525
+ var DeviceCapDisplayOverrideSchema = object({
10526
+ unit: string().min(1).optional(),
10527
+ precision: number().int().min(0).max(10).optional()
10528
+ });
10529
+ /** Cap-wire shape of an operator-authored per-device display override —
10530
+ * mirrors `DeviceDisplayOverride` in `device-management.ts`. `precision`
10531
+ * bounds mirror `numeric-sensor.cap.ts` (`int 0-10`). */
10532
+ var DeviceDisplayOverrideSchema = object({
10533
+ icon: string().min(1).optional(),
10534
+ label: string().min(1).optional(),
10535
+ unit: string().min(1).optional(),
10536
+ precision: number().int().min(0).max(10).optional(),
10537
+ hidden: boolean().optional(),
10538
+ perCap: record(string(), DeviceCapDisplayOverrideSchema).optional()
10539
+ });
10540
+ /** Cap-wire shape of a per-role display default — mirrors `RoleDisplayDefault`
10541
+ * in `device-management.ts`. Keyed by `DeviceRole` string (role strings cross
10542
+ * the wire as plain strings everywhere else — cf. `DeviceInfoSchema.role`). */
10543
+ var RoleDisplayDefaultSchema = object({
10544
+ unit: string().min(1).optional(),
10545
+ precision: number().int().min(0).max(10).optional(),
10546
+ icon: string().min(1).optional()
10547
+ });
10485
10548
  /**
10486
- * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
10487
- * zero-dependency. The grammar is deliberately boring: decimal numbers,
10488
- * single/double-quoted strings with a tiny escape set, identifiers, the three
10489
- * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
10490
- * outside that — a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
10491
- * is a parse error with a source position, so member access / assignment /
10492
- * template literals are lexically impossible.
10493
- */
10494
- var KEYWORDS = new Set([
10495
- "true",
10496
- "false",
10497
- "null"
10498
- ]);
10499
- function isDigit(ch) {
10500
- return ch >= "0" && ch <= "9";
10501
- }
10502
- function isIdentStart(ch) {
10503
- return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
10504
- }
10505
- function isIdentPart(ch) {
10506
- return isIdentStart(ch) || isDigit(ch);
10507
- }
10508
- function isWhitespace(ch) {
10509
- return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
10510
- }
10511
- /** Tokenize `source` into a flat token list ending with a single `eof` token.
10512
- * Throws `ExpressionParseError` on any illegal character or unterminated
10513
- * string. */
10514
- function tokenize(source) {
10515
- if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
10516
- const tokens = [];
10517
- let i = 0;
10518
- const n = source.length;
10519
- while (i < n) {
10520
- const ch = source[i];
10521
- if (isWhitespace(ch)) {
10522
- i += 1;
10523
- continue;
10524
- }
10525
- if (isDigit(ch)) {
10526
- const start = i;
10527
- while (i < n && isDigit(source[i])) i += 1;
10528
- if (i < n && source[i] === ".") {
10529
- if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
10530
- i += 1;
10531
- while (i < n && isDigit(source[i])) i += 1;
10532
- }
10533
- const text = source.slice(start, i);
10534
- const value = Number(text);
10535
- if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
10536
- tokens.push({
10537
- type: "number",
10538
- value,
10539
- pos: start
10540
- });
10541
- continue;
10542
- }
10543
- if (ch === "'" || ch === "\"") {
10544
- const quote = ch;
10545
- const start = i;
10546
- i += 1;
10547
- let out = "";
10548
- let closed = false;
10549
- while (i < n) {
10550
- const c = source[i];
10551
- if (c === "\\") {
10552
- const next = i + 1 < n ? source[i + 1] : "";
10553
- if (next === "\\" || next === "'" || next === "\"") {
10554
- out += next;
10555
- i += 2;
10556
- continue;
10557
- }
10558
- throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
10559
- }
10560
- if (c === quote) {
10561
- closed = true;
10562
- i += 1;
10563
- break;
10564
- }
10565
- out += c;
10566
- i += 1;
10567
- }
10568
- if (!closed) throw new ExpressionParseError("unterminated string literal", start);
10569
- tokens.push({
10570
- type: "string",
10571
- value: out,
10572
- pos: start
10573
- });
10574
- continue;
10575
- }
10576
- if (isIdentStart(ch)) {
10577
- const start = i;
10578
- while (i < n && isIdentPart(source[i])) i += 1;
10579
- const text = source.slice(start, i);
10580
- if (KEYWORDS.has(text)) tokens.push({
10581
- type: "keyword",
10582
- keyword: keywordOf(text),
10583
- pos: start
10584
- });
10585
- else tokens.push({
10586
- type: "identifier",
10587
- name: text,
10588
- pos: start
10589
- });
10590
- continue;
10591
- }
10592
- const two = i + 1 < n ? source.slice(i, i + 2) : "";
10593
- if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
10594
- tokens.push({
10595
- type: "punct",
10596
- punct: two,
10597
- pos: i
10598
- });
10599
- i += 2;
10600
- continue;
10601
- }
10602
- if (isSinglePunct(ch)) {
10603
- tokens.push({
10604
- type: "punct",
10605
- punct: ch,
10606
- pos: i
10607
- });
10608
- i += 1;
10609
- continue;
10610
- }
10611
- throw new ExpressionParseError(`unexpected character '${ch}'`, i);
10612
- }
10613
- tokens.push({
10614
- type: "eof",
10615
- pos: n
10616
- });
10617
- return tokens;
10618
- }
10619
- function keywordOf(text) {
10620
- if (text === "true") return "true";
10621
- if (text === "false") return "false";
10622
- return "null";
10623
- }
10624
- function isSinglePunct(ch) {
10625
- return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
10626
- }
10627
- /**
10628
- * Pratt (precedence-climbing) parser for the safe expression mini-language.
10629
- *
10630
- * Precedence (low → high): ternary `?:` (right-assoc) → `||` → `&&` → equality
10631
- * → relational → additive → multiplicative → unary `! -` → call / primary.
10632
- * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
10633
- * string validated against the builtin table at parse time, so an unknown
10634
- * function is rejected immediately (author feedback) and a persisted expression
10635
- * that references a since-removed builtin degrades at read.
10636
- *
10637
- * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
10638
- * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) — both raise `ExpressionParseError`.
10639
- */
10640
- /** Binary/logical operator precedence (higher binds tighter). */
10641
- var BINARY_PRECEDENCE = {
10642
- "||": 1,
10643
- "&&": 2,
10644
- "==": 3,
10645
- "!=": 3,
10646
- "<": 4,
10647
- "<=": 4,
10648
- ">": 4,
10649
- ">=": 4,
10650
- "+": 5,
10651
- "-": 5,
10652
- "*": 6,
10653
- "/": 6,
10654
- "%": 6
10655
- };
10656
- function isLogicalOp(op) {
10657
- return op === "&&" || op === "||";
10658
- }
10659
- function isBinaryOp(op) {
10660
- return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
10661
- }
10662
- var Parser = class {
10663
- tokens;
10664
- pos = 0;
10665
- nodeCount = 0;
10666
- identifiers = /* @__PURE__ */ new Set();
10667
- callees = /* @__PURE__ */ new Set();
10668
- constructor(tokens) {
10669
- this.tokens = tokens;
10670
- }
10671
- parse() {
10672
- const ast = this.parseTernary();
10673
- const tok = this.peek();
10674
- if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
10675
- return {
10676
- ast,
10677
- identifiers: this.identifiers,
10678
- callees: this.callees,
10679
- nodeCount: this.nodeCount
10680
- };
10681
- }
10682
- peek() {
10683
- return this.tokens[this.pos];
10684
- }
10685
- next() {
10686
- return this.tokens[this.pos++];
10687
- }
10688
- /** Consume a punctuator token, erroring if the next token isn't it. */
10689
- expectPunct(punct) {
10690
- const tok = this.peek();
10691
- if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
10692
- this.pos += 1;
10693
- }
10694
- matchPunct(punct) {
10695
- const tok = this.peek();
10696
- if (tok.type === "punct" && tok.punct === punct) {
10697
- this.pos += 1;
10698
- return true;
10699
- }
10700
- return false;
10701
- }
10702
- countNode() {
10703
- this.nodeCount += 1;
10704
- if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
10705
- }
10706
- parseTernary() {
10707
- const test = this.parseBinary(1);
10708
- if (this.matchPunct("?")) {
10709
- const consequent = this.parseTernary();
10710
- this.expectPunct(":");
10711
- const alternate = this.parseTernary();
10712
- this.countNode();
10713
- return {
10714
- kind: "conditional",
10715
- test,
10716
- consequent,
10717
- alternate
10718
- };
10719
- }
10720
- return test;
10721
- }
10722
- parseBinary(minPrec) {
10723
- let left = this.parseUnary();
10724
- for (;;) {
10725
- const tok = this.peek();
10726
- if (tok.type !== "punct") break;
10727
- const prec = BINARY_PRECEDENCE[tok.punct];
10728
- if (prec === void 0 || prec < minPrec) break;
10729
- const op = tok.punct;
10730
- this.pos += 1;
10731
- const right = this.parseBinary(prec + 1);
10732
- this.countNode();
10733
- if (isLogicalOp(op)) left = {
10734
- kind: "logical",
10735
- op,
10736
- left,
10737
- right
10738
- };
10739
- else if (isBinaryOp(op)) left = {
10740
- kind: "binary",
10741
- op,
10742
- left,
10743
- right
10744
- };
10745
- else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
10746
- }
10747
- return left;
10748
- }
10749
- parseUnary() {
10750
- const tok = this.peek();
10751
- if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
10752
- const op = tok.punct;
10753
- this.pos += 1;
10754
- const operand = this.parseUnary();
10755
- this.countNode();
10756
- return {
10757
- kind: "unary",
10758
- op,
10759
- operand
10760
- };
10761
- }
10762
- return this.parsePrimary();
10763
- }
10764
- parsePrimary() {
10765
- const tok = this.next();
10766
- switch (tok.type) {
10767
- case "number":
10768
- this.countNode();
10769
- return {
10770
- kind: "literal",
10771
- value: tok.value
10772
- };
10773
- case "string":
10774
- this.countNode();
10775
- return {
10776
- kind: "literal",
10777
- value: tok.value
10778
- };
10779
- case "keyword":
10780
- this.countNode();
10781
- return {
10782
- kind: "literal",
10783
- value: tok.keyword === "null" ? null : tok.keyword === "true"
10784
- };
10785
- case "identifier": {
10786
- const nextTok = this.peek();
10787
- if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
10788
- this.identifiers.add(tok.name);
10789
- this.countNode();
10790
- return {
10791
- kind: "identifier",
10792
- name: tok.name
10793
- };
10794
- }
10795
- case "punct":
10796
- if (tok.punct === "(") {
10797
- const inner = this.parseTernary();
10798
- this.expectPunct(")");
10799
- return inner;
10800
- }
10801
- throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
10802
- case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
10803
- }
10804
- }
10805
- parseCall(callee, pos) {
10806
- if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
10807
- this.expectPunct("(");
10808
- const args = [];
10809
- if (!this.matchPunct(")")) for (;;) {
10810
- args.push(this.parseTernary());
10811
- if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
10812
- if (this.matchPunct(",")) continue;
10813
- this.expectPunct(")");
10814
- break;
10815
- }
10816
- this.callees.add(callee);
10817
- this.countNode();
10818
- return {
10819
- kind: "call",
10820
- callee,
10821
- args
10822
- };
10823
- }
10824
- };
10825
- /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
10826
- * `ExpressionParseError` on any lexical or grammatical failure. */
10827
- function parseExpression(source) {
10828
- return new Parser(tokenize(source)).parse();
10829
- }
10830
- /**
10831
- * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
10832
- * by expr"). The cache stores BOTH successes and failures (negative caching),
10833
- * so a corrupt persisted string costs exactly one tokenize+parse total — not
10834
- * one per read on a hot resolve path.
10835
- *
10836
- * The cache is a module-level singleton: entries are pure, content-addressed
10837
- * ASTs keyed by the raw source string, so sharing one instance across all
10838
- * callers is safe and maximises hit rate.
10839
- */
10840
- var cache = /* @__PURE__ */ new Map();
10841
- function getCached(source) {
10842
- const hit = cache.get(source);
10843
- if (hit !== void 0) {
10844
- cache.delete(source);
10845
- cache.set(source, hit);
10846
- return hit;
10847
- }
10848
- let result;
10849
- try {
10850
- result = {
10851
- ok: true,
10852
- parsed: parseExpression(source)
10853
- };
10854
- } catch (err) {
10855
- result = {
10856
- ok: false,
10857
- error: err instanceof ExpressionParseError ? err.message : String(err)
10858
- };
10859
- }
10860
- cache.set(source, result);
10861
- if (cache.size > 256) {
10862
- const oldest = cache.keys().next().value;
10863
- if (oldest !== void 0) cache.delete(oldest);
10864
- }
10865
- return result;
10866
- }
10867
- /** Compile `source`, returning a discriminated result instead of throwing.
10868
- * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
10869
- function compileExpressionSafe(source) {
10870
- return getCached(source);
10871
- }
10872
- Object.freeze({});
10873
- /**
10874
- * Author-time validation. Returns `null` when the source is valid, else a
10875
- * human-readable error message. Checks: the expression compiles; binding count
10876
- * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
10877
- * is not reserved (`now`/keywords) and does not shadow a builtin; and every
10878
- * FREE identifier of the AST is covered by a binding or the injected `now`.
10879
- */
10880
- function validateExpressionSource(src) {
10881
- const names = Object.keys(src.bindings);
10882
- if (names.length > 32) return `too many bindings (${names.length} > 32)`;
10883
- for (const name of names) {
10884
- if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
10885
- if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
10886
- if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
10887
- }
10888
- const compiled = compileExpressionSafe(src.expr);
10889
- if (!compiled.ok) return compiled.error;
10890
- const bound = new Set(names);
10891
- for (const id of compiled.parsed.identifiers) {
10892
- if (id === "now") continue;
10893
- if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
10894
- }
10895
- return null;
10896
- }
10897
- var ProviderStatusSchema = object({
10898
- connected: boolean(),
10899
- deviceCount: number(),
10900
- error: string().optional()
10901
- });
10902
- object({
10903
- externalId: string(),
10904
- name: string(),
10905
- type: string(),
10906
- metadata: record(string(), unknown()).optional()
10907
- });
10908
- /**
10909
- * Candidate handed back from discovery and accepted by
10910
- * `adoptDiscoveredDevice`. Shape mirrors the in-process
10911
- * `DiscoveredDevice` interface used by `DeviceDiscovery`.
10912
- */
10913
- var DiscoveryCandidateSchema = object({
10914
- stableId: string(),
10915
- type: _enum(DeviceType),
10916
- suggestedName: string(),
10917
- prefilledConfig: record(string(), unknown()),
10918
- /**
10919
- * Optional upstream-system identity (HA entity_id, vendor MAC, …).
10920
- * Discovery pre-populates this for systems that know the upstream
10921
- * identity ahead of adoption. Rendering metadata (unit, precision)
10922
- * flows live through the cap STATUS SLICE after adoption.
10923
- */
10924
- sourceInfo: SourceInfoSchema.optional()
10925
- });
10926
- /**
10927
- * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
10928
- * Mirrors `toDeviceShape()` output in `device-management.router.ts` so the
10929
- * tRPC layer can pass it through without reshaping.
10930
- */
10931
- var DeviceSummarySchema = object({
10932
- id: number(),
10933
- stableId: string(),
10934
- addonId: string(),
10935
- type: string(),
10936
- name: string(),
10937
- parentDeviceId: number().nullable(),
10938
- online: boolean(),
10939
- features: array(string()),
10940
- config: record(string(), unknown()),
10941
- /** Optional upstream-system identity (dispatch key + system tag).
10942
- * See `SourceInfo`. Present when the device has a non-synthetic
10943
- * source identifier (HA entities, vendor MAC, …); omitted when the
10944
- * synthetic backfill is in effect. */
10945
- sourceInfo: SourceInfoSchema.optional()
10946
- });
10947
- /**
10948
- * Result of a live field test (e.g. probing an RTSP URL during device
10949
- * creation). Matches the UI-side `FieldProbeResult` in
10950
- * `interfaces/config-ui.ts` — the admin `FormBuilder` renders the
10951
- * returned `labels` as chips next to the input.
10952
- */
10953
- var FieldProbeResultSchema = object({
10954
- status: _enum(["ok", "error"]),
10955
- labels: array(string()).optional(),
10956
- error: string().optional()
10957
- });
10958
- /**
10959
- * The output of `getChildCreationSchema` is a UI schema tree. We store
10960
- * it as `unknown` at the capability layer — the router just passes it
10961
- * through and the admin UI renders it via `FormBuilder`. The actual
10962
- * type is `ConfigUISchema` (see `packages/types/src/interfaces/config-ui.ts`),
10963
- * but we deliberately avoid a Zod mirror because the union is large and
10964
- * not meant for runtime validation at this seam.
10965
- */
10966
- var CreationSchemaOutputSchema = unknown();
10967
- method(_void(), _void(), { kind: "mutation" }), method(_void(), _void(), { kind: "mutation" }), method(_void(), ProviderStatusSchema), method(_void(), array(object({
10968
- id: string(),
10969
- name: string(),
10970
- type: string()
10971
- }))), method(object({}), boolean()), method(object({ params: record(string(), unknown()).optional() }), array(DiscoveryCandidateSchema), {
10972
- kind: "mutation",
10973
- auth: "admin"
10974
- }), method(object({}), CreationSchemaOutputSchema), method(object({}), object({ deviceType: _enum(DeviceType).nullable() })), method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
10975
- kind: "mutation",
10976
- auth: "admin"
10977
- }), method(object({}), boolean()), method(object({ type: _enum(DeviceType) }), CreationSchemaOutputSchema), method(object({
10978
- type: _enum(DeviceType),
10979
- config: record(string(), unknown())
10980
- }), DeviceSummarySchema, {
10981
- kind: "mutation",
10982
- auth: "admin"
10983
- }), method(object({
10984
- type: _enum(DeviceType),
10985
- key: string(),
10986
- value: unknown(),
10987
- formValues: record(string(), unknown()).optional()
10988
- }), FieldProbeResultSchema, {
10989
- kind: "mutation",
10990
- auth: "admin"
10991
- });
10992
- /**
10993
- * Device Manager capability — hub-side singleton that unifies device persistence,
10994
- * live registry access, and all management operations into a single tRPC surface.
10995
- *
10996
- * Replaces:
10997
- * - `device-persistence` capability (persistence methods absorbed here)
10998
- * - `device-management.router.ts` (deleted in Phase 2)
10999
- * - `device-ops.router.ts` (compat layer — deleted; device-provider ops absorbed here)
11000
- *
11001
- * All device provider addons (rtsp, onvif, frigate, …) are hub-local: they may
11002
- * fork into separate processes but never run on remote cluster agents. Therefore:
11003
- * - No nodeId routing needed — this is a pure hub singleton.
11004
- * - The hub's DeviceRegistry is the single source of truth for all live devices.
11005
- * - No shadow registry or cross-node aggregation required.
11006
- *
11007
- * Forked workers register devices back to the hub via `ctx.devices`
11008
- * (DeviceManagerApi → ctx.api.deviceManager.registerDevice), same as today.
11009
- */
11010
- /** One child-placement directive on a container's `childLayout`. Structurally
11011
- * identical to `ChildLayoutEntry` in `device-management.ts` — the cap wire
11012
- * shape for the same field. The child is identified by its re-sync-stable
11013
- * accessory `stableIdSuffix` (`childKey`); listed children are grouped into
11014
- * named accordion sections (with optional intra-section order). */
11015
- var ChildLayoutEntrySchema = object({
11016
- childKey: string(),
11017
- section: string(),
11018
- order: number().optional(),
11019
- collapsed: boolean().optional()
11020
- });
11021
- /** Cap-wire shape of a DeviceLink — structurally mirrors `DeviceLink` in
11022
- * `device-management.ts`. Source is a union: a FIELD source copies a sibling
11023
- * accessory's status field (`kind` optional/absent for wire compat); a
11024
- * LITERAL source carries a per-device constant (no sibling is read); a
11025
- * GLOBAL source (P2e) copies ANY device's status field, addressed by the
11026
- * source device's full re-sync-stable `stableId`. */
11027
- var DeviceLinkFieldSourceSchema = object({
11028
- kind: literal("field").optional(),
11029
- sourceKey: string(),
11030
- cap: string(),
11031
- fieldPath: string()
11032
- });
11033
- var DeviceLinkLiteralSourceSchema = object({
11034
- kind: literal("literal"),
11035
- value: union([
11036
- string(),
11037
- number(),
11038
- boolean(),
11039
- _null()
11040
- ])
11041
- });
11042
- var DeviceLinkGlobalSourceSchema = object({
11043
- kind: literal("global"),
11044
- sourceStableId: string(),
11045
- cap: string(),
11046
- fieldPath: string()
11047
- });
11048
- /** Expression source (Stage X): compute the target field from N named bindings
11049
- * via the safe expression engine. Bindings are field | literal | global — never
11050
- * another expression (no nesting). The `superRefine` runs the SAME author-time
11051
- * validation as `validateExpressionSource` (compiles the expr, checks binding
11052
- * names + identifier coverage) so every wire boundary that parses a DeviceLink
11053
- * (tRPC mount, kernel create pre-seed, projection output) validates-at-write.
11054
- * Compiles are LRU-cached, so repeated validation of the same expr is a hit. */
11055
- var DeviceLinkExpressionSourceSchema = object({
11056
- kind: literal("expression"),
11057
- expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
11058
- bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), union([
11059
- DeviceLinkFieldSourceSchema,
11060
- DeviceLinkLiteralSourceSchema,
11061
- DeviceLinkGlobalSourceSchema
11062
- ]))
11063
- }).superRefine((src, ctx) => {
11064
- const err = validateExpressionSource(src);
11065
- if (err !== null) ctx.addIssue({
11066
- code: "custom",
11067
- message: err,
11068
- path: ["expr"]
11069
- });
11070
- });
11071
- var DeviceLinkSchema = object({
11072
- id: string(),
11073
- source: union([
11074
- DeviceLinkFieldSourceSchema,
11075
- DeviceLinkLiteralSourceSchema,
11076
- DeviceLinkGlobalSourceSchema,
11077
- DeviceLinkExpressionSourceSchema
11078
- ]),
11079
- target: object({
11080
- cap: string(),
11081
- fieldPath: string(),
11082
- itemKey: string().optional()
11083
- }),
11084
- transform: discriminatedUnion("kind", [
11085
- object({ kind: literal("identity") }),
11086
- object({
11087
- kind: literal("enum-map"),
11088
- mapping: record(string(), union([
11089
- string(),
11090
- number(),
11091
- boolean()
11092
- ])),
11093
- fallback: union([
11094
- string(),
11095
- number(),
11096
- boolean()
11097
- ]).optional()
11098
- }),
11099
- object({
11100
- kind: literal("linear"),
11101
- scale: number(),
11102
- offset: number(),
11103
- clamp: tuple([number(), number()]).readonly().optional()
11104
- })
11105
- ]).optional()
11106
- });
11107
- /** Cap-wire shape of a per-cap display refinement — mirrors
11108
- * `DeviceCapDisplayOverride` in `device-management.ts`. */
11109
- var DeviceCapDisplayOverrideSchema = object({
11110
- unit: string().min(1).optional(),
11111
- precision: number().int().min(0).max(10).optional()
11112
- });
11113
- /** Cap-wire shape of an operator-authored per-device display override —
11114
- * mirrors `DeviceDisplayOverride` in `device-management.ts`. `precision`
11115
- * bounds mirror `numeric-sensor.cap.ts` (`int 0-10`). */
11116
- var DeviceDisplayOverrideSchema = object({
11117
- icon: string().min(1).optional(),
11118
- label: string().min(1).optional(),
11119
- unit: string().min(1).optional(),
11120
- precision: number().int().min(0).max(10).optional(),
11121
- hidden: boolean().optional(),
11122
- perCap: record(string(), DeviceCapDisplayOverrideSchema).optional()
11123
- });
11124
- /** Cap-wire shape of a per-role display default — mirrors `RoleDisplayDefault`
11125
- * in `device-management.ts`. Keyed by `DeviceRole` string (role strings cross
11126
- * the wire as plain strings everywhere else — cf. `DeviceInfoSchema.role`). */
11127
- var RoleDisplayDefaultSchema = object({
11128
- unit: string().min(1).optional(),
11129
- precision: number().int().min(0).max(10).optional(),
11130
- icon: string().min(1).optional()
11131
- });
11132
- /**
11133
- * Serializable projection of a live IDevice.
11134
- * Returned by listAll, getDevice, getChildren.
11135
- * Live methods (getStreamSources, getConfigSchema) are separate calls.
10549
+ * Serializable projection of a live IDevice.
10550
+ * Returned by listAll, getDevice, getChildren.
10551
+ * Live methods (getStreamSources, getConfigSchema) are separate calls.
11136
10552
  */
11137
10553
  var DeviceInfoSchema = object({
11138
10554
  /** Progressive, system-wide unique number. Allocated synchronously by
@@ -11183,8 +10599,6 @@ var DeviceInfoSchema = object({
11183
10599
  * named accordion sections (with optional intra-section order). See
11184
10600
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
11185
10601
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
11186
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
11187
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
11188
10602
  /** Operator-authored per-device display override. See `DeviceMeta.display`. */
11189
10603
  display: DeviceDisplayOverrideSchema.optional()
11190
10604
  });
@@ -11193,7 +10607,7 @@ var ConfigEntrySchema = object({
11193
10607
  value: unknown(),
11194
10608
  description: string().optional()
11195
10609
  });
11196
- var DeviceLinkModeSchema = _enum(["auto", "manual"]);
10610
+ var LinkedDevicesModeSchema = _enum(["auto", "manual"]);
11197
10611
  /** One resolved linked device — the compact projection consumers need. */
11198
10612
  var LinkedDeviceSchema = object({
11199
10613
  deviceId: number(),
@@ -11256,8 +10670,6 @@ var DeviceMetaSchema = object({
11256
10670
  * accordion sections (with optional intra-section order). See
11257
10671
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
11258
10672
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
11259
- /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
11260
- deviceLinks: array(DeviceLinkSchema).readonly().optional(),
11261
10673
  /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
11262
10674
  * Optional: only present for accessory children that carry a known role. */
11263
10675
  role: string().nullable().optional(),
@@ -11350,12 +10762,6 @@ method(object({
11350
10762
  }), _void(), {
11351
10763
  kind: "mutation",
11352
10764
  auth: "admin"
11353
- }), method(object({
11354
- deviceId: number(),
11355
- deviceLinks: array(DeviceLinkSchema).readonly()
11356
- }), _void(), {
11357
- kind: "mutation",
11358
- auth: "admin"
11359
10765
  }), method(object({
11360
10766
  deviceId: number(),
11361
10767
  display: DeviceDisplayOverrideSchema.nullable()
@@ -11437,7 +10843,7 @@ method(object({
11437
10843
  * shipping 293 rows to find 12. */
11438
10844
  isCamera: boolean().optional()
11439
10845
  }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number() }), array(DeviceInfoSchema)), method(object({ deviceId: number() }), object({
11440
- mode: DeviceLinkModeSchema,
10846
+ mode: LinkedDevicesModeSchema,
11441
10847
  devices: array(LinkedDeviceSchema)
11442
10848
  })), method(object({ deviceId: number() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number() }), array(ConfigEntrySchema)), method(object({ deviceId: number() }), ConfigUISchemaOutput), method(object({
11443
10849
  deviceId: number(),
@@ -11470,11 +10876,7 @@ method(object({
11470
10876
  deviceId: number(),
11471
10877
  entries: array(object({
11472
10878
  capName: string(),
11473
- kind: _enum([
11474
- "native",
11475
- "wrapped",
11476
- "linked"
11477
- ]),
10879
+ kind: _enum(["native", "wrapped"]),
11478
10880
  providerAddonId: string(),
11479
10881
  providerNodeId: string(),
11480
10882
  nativeAddonId: string()
@@ -11483,11 +10885,7 @@ method(object({
11483
10885
  deviceId: number(),
11484
10886
  entries: array(object({
11485
10887
  capName: string(),
11486
- kind: _enum([
11487
- "native",
11488
- "wrapped",
11489
- "linked"
11490
- ]),
10888
+ kind: _enum(["native", "wrapped"]),
11491
10889
  providerAddonId: string(),
11492
10890
  providerNodeId: string(),
11493
10891
  nativeAddonId: string()
@@ -12290,7 +11688,7 @@ var MotionAnalysisResultSchema = object({
12290
11688
  frameHeight: number(),
12291
11689
  analysisMs: number()
12292
11690
  });
12293
- method(object({
11691
+ DeviceType.Camera, method(object({
12294
11692
  deviceId: number(),
12295
11693
  frame: FrameInputSchema.optional(),
12296
11694
  frameHandle: FrameHandleSchema.optional()
@@ -14479,6 +13877,18 @@ var OauthIntegrationDescriptorSchema = object({
14479
13877
  * redirect_uri that does not start with one of these. Required —
14480
13878
  * an empty list means the integration can never complete linking. */
14481
13879
  allowedRedirectPrefixes: array(string()).min(1),
13880
+ /** Paths accepted as a `redirect_uri` when the host is PRIVATE — loopback,
13881
+ * RFC1918, CGNAT (100.64/10, Tailscale), link-local, IPv6 ULA, or an
13882
+ * `.local` / `.internal` / `.ts.net` name. Exists for self-hosted clients
13883
+ * whose address the hub cannot know in advance (a Home Assistant at
13884
+ * `http://<lan-ip>:8123/auth/external/callback`). The PATH must match
13885
+ * exactly; a public host never satisfies this branch, so it is not a
13886
+ * wildcard prefix by another name. */
13887
+ allowedPrivateHostPaths: array(string()).optional(),
13888
+ /** When true this is a PUBLIC client (source is published, no secret can be
13889
+ * protected) and PKCE is mandatory: `/authorize` refuses without an S256
13890
+ * `code_challenge`, `/token` refuses without the matching `code_verifier`. */
13891
+ requiresPkce: boolean().optional(),
14482
13892
  /** Optional public origin (no trailing slash) that this integration's
14483
13893
  * issued codes/tokens should carry as the `hubUrl` claim — typically the
14484
13894
  * operator-selected external-access endpoint resolved by the addon. When
@@ -14629,7 +14039,7 @@ var TrackEnvelopeSchema = object({
14629
14039
  * `snapshots[]` references — megabytes across a page of tracks. `slim`
14630
14040
  * keeps every scalar the list surfaces actually render (ids, class(es),
14631
14041
  * label / audioLabels / importance enrichment, firstSeen/lastSeen, state,
14632
- * zonesVisited, bestEventId, envelope) and returns `positions` /
14042
+ * zonesVisited, bestEventId, envelope, hasFace) and returns `positions` /
14633
14043
  * `snapshots` as EMPTY arrays — detail views re-fetch the full row via
14634
14044
  * `getTrack`. Mirrors the event-store `projection` convention
14635
14045
  * (`getObjectEvents` et al.).
@@ -14866,6 +14276,24 @@ var TrackSchema = object({
14866
14276
  * Populated from the persisted envelope columns on historical reads;
14867
14277
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
14868
14278
  envelope: TrackEnvelopeSchema.optional(),
14279
+ /**
14280
+ * A face DETECTOR found a face on this track — nothing more. It says the
14281
+ * detail plane produced a `face` detail; it does NOT say the face was
14282
+ * embedded, matched, above `minFacePx`, or that the recognizer was even
14283
+ * enabled. Set once and never cleared.
14284
+ *
14285
+ * **This exists so "face present but not recognised" is expressible.** A
14286
+ * recognised identity lands in `subLabel` (attributed to the face chain via
14287
+ * `subLabelMeta.stepId`), so before this field a track with an unmatched face
14288
+ * and a track with no face at all were byte-identical on the wire and no
14289
+ * surface could tell them apart. The read is `hasFace === true && subLabel
14290
+ * === undefined`.
14291
+ *
14292
+ * **Absent ≠ false.** Every row written before the column existed omits it,
14293
+ * and so does every server that predates the field — a consumer must test
14294
+ * `=== true` and render nothing otherwise, never infer "no face".
14295
+ */
14296
+ hasFace: boolean().optional(),
14869
14297
  ...TrackFlagFields,
14870
14298
  ...TrackRetrainFields
14871
14299
  });
@@ -17480,6 +16908,10 @@ var SsoBridgeClaimsSchema = object({
17480
16908
  integrationId: string().optional(),
17481
16909
  /** JWT ID — unique per issued code; consumed-set enforces single-use. */
17482
16910
  jti: string().optional(),
16911
+ /** PKCE S256 challenge — set only on `oauth-code` tokens issued to a public
16912
+ * client. Its PRESENCE is what makes the verifier mandatory at exchange,
16913
+ * so the requirement travels with the code and not with mutable config. */
16914
+ codeChallenge: string().optional(),
17483
16915
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
17484
16916
  * tokens so the verify path can check the session is not revoked. */
17485
16917
  sessionId: string().optional()
@@ -18032,7 +17464,7 @@ var ClipPlaybackSchema = object({
18032
17464
  playbackEndpoints: array(string()).optional(),
18033
17465
  token: string().optional()
18034
17466
  });
18035
- method(object({
17467
+ DeviceType.Camera, method(object({
18036
17468
  deviceId: number(),
18037
17469
  since: number(),
18038
17470
  until: number(),
@@ -23090,440 +22522,1074 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
23090
22522
  code: string()
23091
22523
  }), object({ success: literal(true) }), {
23092
22524
  kind: "mutation",
23093
- auth: "admin",
23094
- access: "create"
23095
- }), method(object({ userId: string() }), object({ success: literal(true) }), {
22525
+ auth: "admin",
22526
+ access: "create"
22527
+ }), method(object({ userId: string() }), object({ success: literal(true) }), {
22528
+ kind: "mutation",
22529
+ auth: "admin",
22530
+ access: "delete"
22531
+ }), method(object({ userId: string() }), TotpStatusSchema, { auth: "admin" }), method(object({
22532
+ userId: string(),
22533
+ code: string()
22534
+ }), object({ valid: boolean() }), {
22535
+ kind: "mutation",
22536
+ access: "view"
22537
+ }), method(object({
22538
+ integrationId: string(),
22539
+ userId: string(),
22540
+ username: string(),
22541
+ scopes: array(TokenScopeSchema),
22542
+ redirectUri: string(),
22543
+ hubUrl: string(),
22544
+ /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
22545
+ * that carries one can ONLY be exchanged with the matching verifier. */
22546
+ codeChallenge: string().optional()
22547
+ }), object({ code: string() }), {
22548
+ kind: "mutation",
22549
+ access: "create"
22550
+ }), method(object({
22551
+ code: string(),
22552
+ redirectUri: string(),
22553
+ /** PKCE verifier. REQUIRED when the code carries a challenge. */
22554
+ codeVerifier: string().optional()
22555
+ }), object({
22556
+ accessToken: string(),
22557
+ refreshToken: string(),
22558
+ expiresIn: number()
22559
+ }).nullable(), {
22560
+ kind: "mutation",
22561
+ access: "view"
22562
+ }), method(object({ refreshToken: string() }), object({
22563
+ accessToken: string(),
22564
+ refreshToken: string(),
22565
+ expiresIn: number()
22566
+ }).nullable(), {
22567
+ kind: "mutation",
22568
+ access: "view"
22569
+ }), method(object({ token: string() }), object({
22570
+ userId: string(),
22571
+ username: string(),
22572
+ scopes: array(TokenScopeSchema)
22573
+ }).nullable(), { access: "view" }), method(_void(), array(OauthSessionSummarySchema), { auth: "admin" }), method(object({ id: string() }), object({ success: boolean() }), {
22574
+ kind: "mutation",
22575
+ auth: "admin",
22576
+ access: "delete"
22577
+ });
22578
+ /**
22579
+ * Robot-vacuum cap. Models HA `vacuum.*` entities — anything with a
22580
+ * cleaning lifecycle plus a return-to-base / locate surface and an
22581
+ * optional fan-speed selector.
22582
+ *
22583
+ * State follows HA's canonical vacuum lifecycle: `idle` / `cleaning` /
22584
+ * `paused` / `returning` / `docked` / `error`. `batteryLevel`
22585
+ * (0..100) is nullable — some vacuums don't report a battery
22586
+ * percentage. `fanSpeed` is the current speed token (provider-verbatim,
22587
+ * e.g. `'standard'` / `'turbo'`) and `availableFanSpeeds` lists the
22588
+ * tokens the hardware accepts so the UI renders only supported choices.
22589
+ *
22590
+ * The `setFanSpeed` method takes the bare `speed` token — the provider
22591
+ * validates it against the vacuum's own list. `locate` triggers the
22592
+ * find-me chirp; `returnToBase` sends it home.
22593
+ *
22594
+ * Consumable / waste tanks: `cleanWater` / `dirtyWater` / `detergent` /
22595
+ * `dustBin` each carry a nullable `{ level (0..100 %), status ('ok' |
22596
+ * 'low' | 'full') }` reading. Native providers (e.g. Dreame, Roborock)
22597
+ * SHOULD populate whichever tanks the hardware has — leave a field `null`
22598
+ * only when the device has no such tank at all, and use a `TankStatus`
22599
+ * with both inner fields `null` when the tank exists but its level is
22600
+ * currently unknown. HA `vacuum.*` entities expose no per-tank telemetry,
22601
+ * so the HA provider leaves all four `null`.
22602
+ */
22603
+ var VacuumStateSchema = _enum([
22604
+ "idle",
22605
+ "cleaning",
22606
+ "paused",
22607
+ "returning",
22608
+ "docked",
22609
+ "drying",
22610
+ "error"
22611
+ ]);
22612
+ /**
22613
+ * One consumable / waste tank on a robot vacuum (clean-water, dirty-water,
22614
+ * detergent or dust-bin). A tank can report a numeric fill `level` (0..100),
22615
+ * a discrete `status` (binary-style hardware), or both. Both `null` means the
22616
+ * level is currently unknown; the OWNING field being `null` means the
22617
+ * hardware has no such tank at all.
22618
+ */
22619
+ var TankStatusSchema = object({
22620
+ /** Numeric fill 0..100 when the hardware reports a percentage; null otherwise. */
22621
+ level: number().min(0).max(100).nullable(),
22622
+ /** Discrete state when the hardware is binary-mode; null otherwise. */
22623
+ status: _enum([
22624
+ "ok",
22625
+ "low",
22626
+ "full"
22627
+ ]).nullable()
22628
+ });
22629
+ object({
22630
+ /** Lifecycle state of the vacuum. */
22631
+ state: VacuumStateSchema,
22632
+ /** 0..100 battery percentage. Null when the device has no battery
22633
+ * reading. */
22634
+ batteryLevel: number().min(0).max(100).nullable(),
22635
+ /** Current fan-speed token (provider-verbatim). Null when unknown or
22636
+ * the vacuum has no speed control. */
22637
+ fanSpeed: string().nullable(),
22638
+ /** Speed tokens the hardware accepts — drives the UI selector. */
22639
+ availableFanSpeeds: array(string()),
22640
+ /** Clean-water (mop) tank. Null when the hardware has no clean-water tank. */
22641
+ cleanWater: TankStatusSchema.nullable(),
22642
+ /** Dirty-water (recovery) tank. Null when the hardware has no dirty-water tank. */
22643
+ dirtyWater: TankStatusSchema.nullable(),
22644
+ /** Detergent tank. Null when the hardware has no detergent tank. */
22645
+ detergent: TankStatusSchema.nullable(),
22646
+ /** Dust bin. Null when the hardware has no dust bin. */
22647
+ dustBin: TankStatusSchema.nullable(),
22648
+ /** 0..100 cleaning-completion percentage of the current task, or null. */
22649
+ progressPercent: number().min(0).max(100).nullable(),
22650
+ /** Current error code (0 / null = no error). */
22651
+ errorCode: number().nullable(),
22652
+ /** Human label for {@link errorCode}, or null when none / undecodable. */
22653
+ errorLabel: string().nullable(),
22654
+ /** Ms epoch when the slice was last updated. */
22655
+ lastChangedAt: number()
22656
+ });
22657
+ DeviceType.Vacuum, method(object({ deviceId: number().int().nonnegative() }), _void(), {
22658
+ kind: "mutation",
22659
+ auth: "admin"
22660
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
22661
+ kind: "mutation",
22662
+ auth: "admin"
22663
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
22664
+ kind: "mutation",
22665
+ auth: "admin"
22666
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
22667
+ kind: "mutation",
22668
+ auth: "admin"
22669
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
22670
+ kind: "mutation",
22671
+ auth: "admin"
22672
+ }), method(object({
22673
+ deviceId: number().int().nonnegative(),
22674
+ speed: string().min(1)
22675
+ }), _void(), {
22676
+ kind: "mutation",
22677
+ auth: "admin"
22678
+ });
22679
+ object({
22680
+ /** Lifecycle state of the valve. */
22681
+ state: _enum([
22682
+ "open",
22683
+ "opening",
22684
+ "closing",
22685
+ "closed",
22686
+ "stopped"
22687
+ ]),
22688
+ /** 0 = fully closed, 100 = fully open. Null when the device has no
22689
+ * intermediate position surface. */
22690
+ position: number().min(0).max(100).nullable(),
22691
+ /** Ms epoch when the slice was last updated. */
22692
+ lastChangedAt: number()
22693
+ });
22694
+ DeviceType.Valve, method(object({ deviceId: number().int().nonnegative() }), _void(), {
22695
+ kind: "mutation",
22696
+ auth: "admin"
22697
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
23096
22698
  kind: "mutation",
23097
- auth: "admin",
23098
- access: "delete"
23099
- }), method(object({ userId: string() }), TotpStatusSchema, { auth: "admin" }), method(object({
23100
- userId: string(),
23101
- code: string()
23102
- }), object({ valid: boolean() }), {
22699
+ auth: "admin"
22700
+ }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
23103
22701
  kind: "mutation",
23104
- access: "view"
22702
+ auth: "admin"
23105
22703
  }), method(object({
23106
- integrationId: string(),
23107
- userId: string(),
23108
- username: string(),
23109
- scopes: array(TokenScopeSchema),
23110
- redirectUri: string(),
23111
- hubUrl: string()
23112
- }), object({ code: string() }), {
22704
+ deviceId: number().int().nonnegative(),
22705
+ position: number().min(0).max(100)
22706
+ }), _void(), {
23113
22707
  kind: "mutation",
23114
- access: "create"
22708
+ auth: "admin"
22709
+ });
22710
+ object({
22711
+ detected: boolean(),
22712
+ /** Ms epoch of the last transition. 0 if never observed. */
22713
+ lastChangedAt: number()
22714
+ });
22715
+ DeviceType.Sensor;
22716
+ object({
22717
+ /** Current measured temperature. Null when not reported. */
22718
+ currentTemp: number().nullable(),
22719
+ /** Target temperature setpoint. Null when no setpoint surface. */
22720
+ targetTemp: number().nullable(),
22721
+ /** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
22722
+ * `heat_pump` / `high_demand` / `performance` / `off`). Null when the
22723
+ * device reports an unknown state. */
22724
+ operationMode: string().nullable(),
22725
+ /** Available operation modes = HA `operation_list`. */
22726
+ availableModes: array(string()),
22727
+ /** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
22728
+ * has no away surface. */
22729
+ away: boolean().nullable(),
22730
+ /** HA `min_temp` attribute. Null when not reported. */
22731
+ minTemp: number().nullable(),
22732
+ /** HA `max_temp` attribute. Null when not reported. */
22733
+ maxTemp: number().nullable(),
22734
+ /** Ms epoch when the slice was last updated. */
22735
+ lastChangedAt: number()
22736
+ });
22737
+ DeviceType.WaterHeater, method(object({
22738
+ deviceId: number().int().nonnegative(),
22739
+ temp: number().finite()
22740
+ }), _void(), {
22741
+ kind: "mutation",
22742
+ auth: "admin"
23115
22743
  }), method(object({
23116
- code: string(),
23117
- redirectUri: string()
23118
- }), object({
23119
- accessToken: string(),
23120
- refreshToken: string(),
23121
- expiresIn: number()
23122
- }).nullable(), {
22744
+ deviceId: number().int().nonnegative(),
22745
+ mode: string().min(1)
22746
+ }), _void(), {
23123
22747
  kind: "mutation",
23124
- access: "view"
23125
- }), method(object({ refreshToken: string() }), object({
23126
- accessToken: string(),
23127
- refreshToken: string(),
23128
- expiresIn: number()
23129
- }).nullable(), {
22748
+ auth: "admin"
22749
+ }), method(object({
22750
+ deviceId: number().int().nonnegative(),
22751
+ on: boolean()
22752
+ }), _void(), {
23130
22753
  kind: "mutation",
23131
- access: "view"
23132
- }), method(object({ token: string() }), object({
23133
- userId: string(),
23134
- username: string(),
23135
- scopes: array(TokenScopeSchema)
23136
- }).nullable(), { access: "view" }), method(_void(), array(OauthSessionSummarySchema), { auth: "admin" }), method(object({ id: string() }), object({ success: boolean() }), {
22754
+ auth: "admin"
22755
+ });
22756
+ object({
22757
+ /** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
22758
+ * when no condition has been reported yet. */
22759
+ condition: string().nullable(),
22760
+ /** Current temperature in the reported unit. Null when not provided. */
22761
+ temperature: number().nullable(),
22762
+ /** Temperature unit string (e.g. `°C` / `°F`). Null when not provided. */
22763
+ temperatureUnit: string().nullable(),
22764
+ /** Relative humidity (0..100). Null when not provided. */
22765
+ humidity: number().min(0).max(100).nullable(),
22766
+ /** Barometric pressure in the reported unit. Null when not provided. */
22767
+ pressure: number().nullable(),
22768
+ /** Pressure unit string (e.g. `hPa` / `inHg`). Null when not provided. */
22769
+ pressureUnit: string().nullable(),
22770
+ /** Wind speed in the reported unit. Null when not provided. */
22771
+ windSpeed: number().nullable(),
22772
+ /** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
22773
+ windSpeedUnit: string().nullable(),
22774
+ /** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
22775
+ windBearing: number().nullable(),
22776
+ /** Ms epoch when the slice was last updated. */
22777
+ lastFetchedAt: number()
22778
+ });
22779
+ DeviceType.Weather;
22780
+ /**
22781
+ * Per-zone occupancy aggregation produced by the analytics frame
22782
+ * processor on every inference result. Covers the full combinatorial
22783
+ * matrix the operator UI needs: total objects everywhere, total
22784
+ * objects per zone, single class everywhere, single class per zone,
22785
+ * objects outside any zone.
22786
+ *
22787
+ * Counts are derived from the analytics tracker (tracked detections
22788
+ * with stable trackIds), not raw detector hits — this filters out
22789
+ * one-off detector flickers and gives counts that match what the user
22790
+ * sees on the live overlay.
22791
+ *
22792
+ * Overlap policy: a detection that intersects two zones counts in
22793
+ * BOTH zones' `byClass` and `totalObjects` (count-in-each). The
22794
+ * `frame` aggregate de-duplicates trivially since it's frame-wide.
22795
+ * `unzoned` counts only detections that landed in zero zones.
22796
+ */
22797
+ var PerScopeBreakdownSchema = object({
22798
+ /** Total tracked objects in this scope (frame / zone / unzoned). */
22799
+ totalObjects: number().int().nonnegative(),
22800
+ /** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
22801
+ byClass: record(string(), number().int().nonnegative())
22802
+ });
22803
+ var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
22804
+ zoneId: string(),
22805
+ zoneName: string(),
22806
+ /** TrackIds of objects currently inside this zone — for cross-reference
22807
+ * with the per-track detail panel and live overlay. */
22808
+ trackIds: array(string()).readonly()
22809
+ });
22810
+ /**
22811
+ * A parked ("stationary") object surfaced alongside occupancy — an object that
22812
+ * settled and stopped moving. It is NO LONGER a tracked object (the tracker was
22813
+ * told to forget it so it stops re-spawning tracks/events), but it IS still
22814
+ * physically present, so it keeps counting toward `frame` occupancy and is
22815
+ * listed here so the UI can show it in a dedicated "Stationary" section instead
22816
+ * of flooding the live event feed.
22817
+ */
22818
+ var StationaryObjectSchema = object({
22819
+ id: string(),
22820
+ className: string(),
22821
+ bbox: object({
22822
+ x: number(),
22823
+ y: number(),
22824
+ w: number(),
22825
+ h: number()
22826
+ }),
22827
+ frameWidth: number().int().nonnegative(),
22828
+ frameHeight: number().int().nonnegative(),
22829
+ /** When the source track was first seen. */
22830
+ firstSeenAt: number().int(),
22831
+ /** When the object was recognised as parked (promotion time). */
22832
+ becameStationaryAt: number().int(),
22833
+ /** Last frame a detection confirmed the object is still there. */
22834
+ lastConfirmedAt: number().int(),
22835
+ /** Enrichment label carried from the source track (identity / plate). */
22836
+ label: string().optional(),
22837
+ /** Native-resolution key-frame media key for the parked object's best image. */
22838
+ keyFrameMediaKey: string().optional()
22839
+ });
22840
+ var CameraOccupancySnapshotSchema = object({
22841
+ /** Frame timestamp of the inference result that produced this snapshot. */
22842
+ ts: number().int(),
22843
+ /** Frame width/height in pixels — let the UI normalize bbox coords. */
22844
+ frameWidth: number().int().nonnegative(),
22845
+ frameHeight: number().int().nonnegative(),
22846
+ /** Per-zone breakdown — one entry per defined zone (user + onboard). */
22847
+ zones: array(ZoneScopeBreakdownSchema).readonly(),
22848
+ /** Frame-wide aggregate (everywhere, regardless of zone membership).
22849
+ * INCLUDES currently-confirmed stationary objects (they are still present). */
22850
+ frame: PerScopeBreakdownSchema,
22851
+ /** Detections that landed outside every zone. Empty when no zones defined. */
22852
+ unzoned: PerScopeBreakdownSchema,
22853
+ /** Parked objects on this camera (additive — absent on legacy snapshots).
22854
+ * Surfaced separately so the UI shows them in a dedicated section rather
22855
+ * than as repeated tracks/events. */
22856
+ stationaryObjects: array(StationaryObjectSchema).readonly().optional()
22857
+ });
22858
+ /**
22859
+ * Time-series resolution. The history methods return one bucket per
22860
+ * step over the requested range. Smaller resolutions cost more
22861
+ * memory + bandwidth; bound to discrete steps so caller cannot ask
22862
+ * for arbitrary fractional buckets.
22863
+ */
22864
+ var HistoryResolutionEnum = _enum([
22865
+ "minute",
22866
+ "5min",
22867
+ "hour"
22868
+ ]);
22869
+ var HistoryRangeSchema = object({
22870
+ /** Range start (epoch ms, inclusive). */
22871
+ from: number().int(),
22872
+ /** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
22873
+ to: number().int(),
22874
+ resolution: HistoryResolutionEnum
22875
+ });
22876
+ var HistoryPointSchema = object({
22877
+ /** Bucket midpoint (epoch ms). */
22878
+ ts: number().int(),
22879
+ /** Object count averaged over the bucket (rounded to nearest integer). */
22880
+ count: number().int().nonnegative()
22881
+ });
22882
+ DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({
22883
+ deviceId: number(),
22884
+ zoneId: string(),
22885
+ className: string().optional()
22886
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
22887
+ deviceId: number(),
22888
+ className: string().optional()
22889
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
22890
+ deviceId: number(),
22891
+ className: string().optional()
22892
+ }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
22893
+ /**
22894
+ * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
22895
+ * cap so a single CRUD surface backs every consumer; each stage has
22896
+ * its own dev-state mirror slice (`motion-zone-rules`,
22897
+ * `detection-zone-rules`, …) so consumer addons subscribe independently.
22898
+ *
22899
+ * Extend the enum here when a new gating consumer comes online (audio
22900
+ * gating, alert filtering, …) — no other surface needs to change.
22901
+ */
22902
+ var ZoneRuleStageEnum = _enum([
22903
+ "motion",
22904
+ "detection",
22905
+ "package"
22906
+ ]);
22907
+ DeviceType.Camera, method(object({
22908
+ deviceId: number(),
22909
+ stage: ZoneRuleStageEnum
22910
+ }), array(ZoneRuleSchema).readonly()), method(object({
22911
+ deviceId: number(),
22912
+ stage: ZoneRuleStageEnum,
22913
+ rules: array(ZoneRuleSchema).readonly()
22914
+ }), _void(), {
23137
22915
  kind: "mutation",
23138
- auth: "admin",
23139
- access: "delete"
22916
+ auth: "admin"
22917
+ }), object({
22918
+ motion: array(ZoneRuleSchema).readonly(),
22919
+ detection: array(ZoneRuleSchema).readonly(),
22920
+ package: array(ZoneRuleSchema).readonly()
23140
22921
  });
23141
22922
  /**
23142
- * Robot-vacuum cap. Models HA `vacuum.*` entities anything with a
23143
- * cleaning lifecycle plus a return-to-base / locate surface and an
23144
- * optional fan-speed selector.
22923
+ * Accessory device helpersshared across drivers.
22924
+ *
22925
+ * Many vendor-specific drivers register accessory child devices on
22926
+ * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
22927
+ * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
22928
+ * driver picks the right `DeviceType` + `DeviceRole` explicitly when
22929
+ * spawning, builds a name derived from the parent, and produces a
22930
+ * stableId tied to the parent so boot-restore can reconstruct the
22931
+ * relationship.
22932
+ *
22933
+ * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
22934
+ * drivers may reasonably disagree on the right type for an accessory
22935
+ * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
22936
+ * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
22937
+ * one canonical mapping was over-prescriptive and added a layer of
22938
+ * indirection without saving meaningful code at call sites — the
22939
+ * driver knows its own hardware best.
22940
+ */
22941
+ /**
22942
+ * Subset of `DeviceRole` values that drivers register as child
22943
+ * accessories of a parent device. Sourced verbatim from `DeviceRole`
22944
+ * — `AccessoryKind` is the alias drivers use when building accessory
22945
+ * children, so the call site reads as
22946
+ * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
22947
+ * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
22948
+ * any role works, including non-accessory ones like Doorbell).
22949
+ */
22950
+ var AccessoryKind = {
22951
+ Siren: DeviceRole.Siren,
22952
+ Floodlight: DeviceRole.Floodlight,
22953
+ Spotlight: DeviceRole.Spotlight,
22954
+ PirSensor: DeviceRole.PirSensor,
22955
+ Chime: DeviceRole.Chime,
22956
+ Autotrack: DeviceRole.Autotrack,
22957
+ Nightvision: DeviceRole.Nightvision,
22958
+ PrivacyMask: DeviceRole.PrivacyMask
22959
+ };
22960
+ AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
22961
+ 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;
22962
+ new Set(Object.values(DeviceType));
22963
+ DeviceFeature.BatteryOperated;
22964
+ /**
22965
+ * Error types for the safe expression engine. Two distinct classes so callers
22966
+ * can tell a compile-time (grammar) failure from a runtime (evaluation)
22967
+ * failure — both are non-fatal to the host: read paths degrade to "skip link".
22968
+ */
22969
+ /** Thrown by the tokenizer / parser. Carries a 0-based source `position` when
22970
+ * the failure is anchored to a character (author-facing inline feedback). */
22971
+ var ExpressionParseError = class extends Error {
22972
+ position;
22973
+ constructor(message, position) {
22974
+ super(message);
22975
+ this.name = "ExpressionParseError";
22976
+ this.position = position;
22977
+ }
22978
+ };
22979
+ /** Thrown by the evaluator (unknown identifier, type mismatch, non-finite
22980
+ * result, unknown builtin, step-budget exceeded). */
22981
+ var ExpressionEvalError = class extends Error {
22982
+ constructor(message) {
22983
+ super(message);
22984
+ this.name = "ExpressionEvalError";
22985
+ }
22986
+ };
22987
+ /**
22988
+ * Frozen, null-prototype builtin function table for the expression engine
22989
+ * (spec §4 rule 4). The table is the SOLE surface of callable functions: the
22990
+ * parser rejects any callee not in it, and the evaluator gates each call on an
22991
+ * own-property check against it.
23145
22992
  *
23146
- * State follows HA's canonical vacuum lifecycle: `idle` / `cleaning` /
23147
- * `paused` / `returning` / `docked` / `error`. `batteryLevel`
23148
- * (0..100) is nullable some vacuums don't report a battery
23149
- * percentage. `fanSpeed` is the current speed token (provider-verbatim,
23150
- * e.g. `'standard'` / `'turbo'`) and `availableFanSpeeds` lists the
23151
- * tokens the hardware accepts so the UI renders only supported choices.
22993
+ * Because the object has a NULL prototype AND is `Object.freeze`d:
22994
+ * - it cannot be polluted (no `__proto__` / `constructor` write reaches it);
22995
+ * - a lookup for `toString` / `hasOwnProperty` / `constructor` finds NOTHING
22996
+ * (there is no `Object.prototype` in the chain), so those names are not
22997
+ * callable they are simply "unknown function" at parse time.
23152
22998
  *
23153
- * The `setFanSpeed` method takes the bare `speed` token the provider
23154
- * validates it against the vacuum's own list. `locate` triggers the
23155
- * find-me chirp; `returnToBase` sends it home.
22999
+ * Every numeric argument is validated as a finite number and every numeric
23000
+ * RESULT is re-checked finite, so `/0`, `sqrt(-1)` (→ NaN) and overflow
23001
+ * (`pow(10,400)` Infinity) all raise `ExpressionEvalError` and fail the link
23002
+ * closed rather than emitting a garbage value.
23003
+ */
23004
+ function asFiniteNumber(value, name, index) {
23005
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a finite number`);
23006
+ return value;
23007
+ }
23008
+ function asString$1(value, name, index) {
23009
+ if (typeof value !== "string") throw new ExpressionEvalError(`${name}: argument ${index + 1} must be a string`);
23010
+ return value;
23011
+ }
23012
+ function finiteResult(value, name) {
23013
+ if (!Number.isFinite(value)) throw new ExpressionEvalError(`${name}: produced a non-finite result`);
23014
+ return value;
23015
+ }
23016
+ function allFiniteNumbers(args, name) {
23017
+ return args.map((a, idx) => asFiniteNumber(a, name, idx));
23018
+ }
23019
+ var INF = Number.POSITIVE_INFINITY;
23020
+ var table = {
23021
+ min: {
23022
+ minArgs: 1,
23023
+ maxArgs: INF,
23024
+ apply: (args) => finiteResult(Math.min(...allFiniteNumbers(args, "min")), "min")
23025
+ },
23026
+ max: {
23027
+ minArgs: 1,
23028
+ maxArgs: INF,
23029
+ apply: (args) => finiteResult(Math.max(...allFiniteNumbers(args, "max")), "max")
23030
+ },
23031
+ abs: {
23032
+ minArgs: 1,
23033
+ maxArgs: 1,
23034
+ apply: (args) => finiteResult(Math.abs(asFiniteNumber(args[0], "abs", 0)), "abs")
23035
+ },
23036
+ floor: {
23037
+ minArgs: 1,
23038
+ maxArgs: 1,
23039
+ apply: (args) => finiteResult(Math.floor(asFiniteNumber(args[0], "floor", 0)), "floor")
23040
+ },
23041
+ ceil: {
23042
+ minArgs: 1,
23043
+ maxArgs: 1,
23044
+ apply: (args) => finiteResult(Math.ceil(asFiniteNumber(args[0], "ceil", 0)), "ceil")
23045
+ },
23046
+ sqrt: {
23047
+ minArgs: 1,
23048
+ maxArgs: 1,
23049
+ apply: (args) => finiteResult(Math.sqrt(asFiniteNumber(args[0], "sqrt", 0)), "sqrt")
23050
+ },
23051
+ round: {
23052
+ minArgs: 1,
23053
+ maxArgs: 2,
23054
+ apply: (args) => {
23055
+ const x = asFiniteNumber(args[0], "round", 0);
23056
+ const digits = args.length > 1 ? Math.trunc(asFiniteNumber(args[1], "round", 1)) : 0;
23057
+ if (digits < 0 || digits > 100) throw new ExpressionEvalError("round: digits must be between 0 and 100");
23058
+ const factor = 10 ** digits;
23059
+ return finiteResult(Math.round(x * factor) / factor, "round");
23060
+ }
23061
+ },
23062
+ pow: {
23063
+ minArgs: 2,
23064
+ maxArgs: 2,
23065
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "pow", 0) ** asFiniteNumber(args[1], "pow", 1), "pow")
23066
+ },
23067
+ clamp: {
23068
+ minArgs: 3,
23069
+ maxArgs: 3,
23070
+ apply: (args) => {
23071
+ const x = asFiniteNumber(args[0], "clamp", 0);
23072
+ const lo = asFiniteNumber(args[1], "clamp", 1);
23073
+ const hi = asFiniteNumber(args[2], "clamp", 2);
23074
+ if (lo > hi) throw new ExpressionEvalError("clamp: lower bound is greater than upper bound");
23075
+ return finiteResult(Math.min(hi, Math.max(lo, x)), "clamp");
23076
+ }
23077
+ },
23078
+ avg: {
23079
+ minArgs: 1,
23080
+ maxArgs: INF,
23081
+ apply: (args) => {
23082
+ const nums = allFiniteNumbers(args, "avg");
23083
+ return finiteResult(nums.reduce((acc, v) => acc + v, 0) / nums.length, "avg");
23084
+ }
23085
+ },
23086
+ sum: {
23087
+ minArgs: 1,
23088
+ maxArgs: INF,
23089
+ apply: (args) => finiteResult(allFiniteNumbers(args, "sum").reduce((acc, v) => acc + v, 0), "sum")
23090
+ },
23091
+ coalesce: {
23092
+ minArgs: 1,
23093
+ maxArgs: INF,
23094
+ apply: (args) => {
23095
+ for (const a of args) if (a !== null) return a;
23096
+ return null;
23097
+ }
23098
+ },
23099
+ age: {
23100
+ minArgs: 2,
23101
+ maxArgs: 2,
23102
+ apply: (args) => finiteResult(asFiniteNumber(args[0], "age", 0) - asFiniteNumber(args[1], "age", 1), "age")
23103
+ },
23104
+ convert: {
23105
+ minArgs: 3,
23106
+ maxArgs: 3,
23107
+ apply: (args, hooks) => {
23108
+ const x = asFiniteNumber(args[0], "convert", 0);
23109
+ const from = asString$1(args[1], "convert", 1).trim();
23110
+ const to = asString$1(args[2], "convert", 2).trim();
23111
+ if (hooks.convert) {
23112
+ const out = hooks.convert(x, from, to);
23113
+ if (out === null) throw new ExpressionEvalError(`convert: cannot convert '${from}' to '${to}'`);
23114
+ return finiteResult(out, "convert");
23115
+ }
23116
+ if (from === to) return x;
23117
+ throw new ExpressionEvalError("convert: unit conversion table not installed");
23118
+ }
23119
+ }
23120
+ };
23121
+ Object.freeze(Object.assign(Object.create(null), table));
23122
+ /** The set of valid builtin names — used by the parser to reject unknown
23123
+ * callees at parse time (immediate author feedback). */
23124
+ var EXPRESSION_BUILTIN_NAMES = new Set(Object.keys(table));
23125
+ /**
23126
+ * Resource-bound constants for the safe expression engine.
23156
23127
  *
23157
- * Consumable / waste tanks: `cleanWater` / `dirtyWater` / `detergent` /
23158
- * `dustBin` each carry a nullable `{ level (0..100 %), status ('ok' |
23159
- * 'low' | 'full') }` reading. Native providers (e.g. Dreame, Roborock)
23160
- * SHOULD populate whichever tanks the hardware has leave a field `null`
23161
- * only when the device has no such tank at all, and use a `TankStatus`
23162
- * with both inner fields `null` when the tank exists but its level is
23163
- * currently unknown. HA `vacuum.*` entities expose no per-tank telemetry,
23164
- * so the HA provider leaves all four `null`.
23128
+ * Every bound is defense-in-depth: the grammar is non-Turing-complete (no
23129
+ * loops, recursion, lambdas or member access see `ast.ts`), so evaluation is
23130
+ * O(nodeCount) by construction. These caps merely put a hard ceiling on the
23131
+ * work a single author-supplied expression can request, so a hostile or
23132
+ * accidental pathological string can never spend unbounded CPU/memory.
23165
23133
  */
23166
- var VacuumStateSchema = _enum([
23167
- "idle",
23168
- "cleaning",
23169
- "paused",
23170
- "returning",
23171
- "docked",
23172
- "drying",
23173
- "error"
23134
+ /** Max source length (chars) — checked BEFORE tokenizing so a huge string is
23135
+ * rejected without allocation. */
23136
+ var MAX_EXPRESSION_SOURCE_LENGTH = 2048;
23137
+ /** A legal binding / identifier name. */
23138
+ var EXPRESSION_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
23139
+ /** Binding names an author may NOT use: `now` is auto-injected; the literal
23140
+ * keywords lex as values, not identifiers, so binding to them is meaningless. */
23141
+ var RESERVED_BINDING_NAMES = new Set([
23142
+ "now",
23143
+ "true",
23144
+ "false",
23145
+ "null"
23174
23146
  ]);
23175
23147
  /**
23176
- * One consumable / waste tank on a robot vacuum (clean-water, dirty-water,
23177
- * detergent or dust-bin). A tank can report a numeric fill `level` (0..100),
23178
- * a discrete `status` (binary-style hardware), or both. Both `null` means the
23179
- * level is currently unknown; the OWNING field being `null` means the
23180
- * hardware has no such tank at all.
23148
+ * Tokenizer for the safe expression mini-language. Hand-rolled, single-pass,
23149
+ * zero-dependency. The grammar is deliberately boring: decimal numbers,
23150
+ * single/double-quoted strings with a tiny escape set, identifiers, the three
23151
+ * value keywords (`true`/`false`/`null`) and a fixed punctuator set. Anything
23152
+ * outside that a bare `.`, `=`, `[`, `]`, `{`, `}`, `;`, backtick, `&`, `|` —
23153
+ * is a parse error with a source position, so member access / assignment /
23154
+ * template literals are lexically impossible.
23181
23155
  */
23182
- var TankStatusSchema = object({
23183
- /** Numeric fill 0..100 when the hardware reports a percentage; null otherwise. */
23184
- level: number().min(0).max(100).nullable(),
23185
- /** Discrete state when the hardware is binary-mode; null otherwise. */
23186
- status: _enum([
23187
- "ok",
23188
- "low",
23189
- "full"
23190
- ]).nullable()
23191
- });
23192
- object({
23193
- /** Lifecycle state of the vacuum. */
23194
- state: VacuumStateSchema,
23195
- /** 0..100 battery percentage. Null when the device has no battery
23196
- * reading. */
23197
- batteryLevel: number().min(0).max(100).nullable(),
23198
- /** Current fan-speed token (provider-verbatim). Null when unknown or
23199
- * the vacuum has no speed control. */
23200
- fanSpeed: string().nullable(),
23201
- /** Speed tokens the hardware accepts — drives the UI selector. */
23202
- availableFanSpeeds: array(string()),
23203
- /** Clean-water (mop) tank. Null when the hardware has no clean-water tank. */
23204
- cleanWater: TankStatusSchema.nullable(),
23205
- /** Dirty-water (recovery) tank. Null when the hardware has no dirty-water tank. */
23206
- dirtyWater: TankStatusSchema.nullable(),
23207
- /** Detergent tank. Null when the hardware has no detergent tank. */
23208
- detergent: TankStatusSchema.nullable(),
23209
- /** Dust bin. Null when the hardware has no dust bin. */
23210
- dustBin: TankStatusSchema.nullable(),
23211
- /** 0..100 cleaning-completion percentage of the current task, or null. */
23212
- progressPercent: number().min(0).max(100).nullable(),
23213
- /** Current error code (0 / null = no error). */
23214
- errorCode: number().nullable(),
23215
- /** Human label for {@link errorCode}, or null when none / undecodable. */
23216
- errorLabel: string().nullable(),
23217
- /** Ms epoch when the slice was last updated. */
23218
- lastChangedAt: number()
23219
- });
23220
- DeviceType.Vacuum, method(object({ deviceId: number().int().nonnegative() }), _void(), {
23221
- kind: "mutation",
23222
- auth: "admin"
23223
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
23224
- kind: "mutation",
23225
- auth: "admin"
23226
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
23227
- kind: "mutation",
23228
- auth: "admin"
23229
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
23230
- kind: "mutation",
23231
- auth: "admin"
23232
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
23233
- kind: "mutation",
23234
- auth: "admin"
23235
- }), method(object({
23236
- deviceId: number().int().nonnegative(),
23237
- speed: string().min(1)
23238
- }), _void(), {
23239
- kind: "mutation",
23240
- auth: "admin"
23241
- });
23242
- object({
23243
- /** Lifecycle state of the valve. */
23244
- state: _enum([
23245
- "open",
23246
- "opening",
23247
- "closing",
23248
- "closed",
23249
- "stopped"
23250
- ]),
23251
- /** 0 = fully closed, 100 = fully open. Null when the device has no
23252
- * intermediate position surface. */
23253
- position: number().min(0).max(100).nullable(),
23254
- /** Ms epoch when the slice was last updated. */
23255
- lastChangedAt: number()
23256
- });
23257
- DeviceType.Valve, method(object({ deviceId: number().int().nonnegative() }), _void(), {
23258
- kind: "mutation",
23259
- auth: "admin"
23260
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
23261
- kind: "mutation",
23262
- auth: "admin"
23263
- }), method(object({ deviceId: number().int().nonnegative() }), _void(), {
23264
- kind: "mutation",
23265
- auth: "admin"
23266
- }), method(object({
23267
- deviceId: number().int().nonnegative(),
23268
- position: number().min(0).max(100)
23269
- }), _void(), {
23270
- kind: "mutation",
23271
- auth: "admin"
23272
- });
23273
- object({
23274
- detected: boolean(),
23275
- /** Ms epoch of the last transition. 0 if never observed. */
23276
- lastChangedAt: number()
23277
- });
23278
- DeviceType.Sensor;
23279
- object({
23280
- /** Current measured temperature. Null when not reported. */
23281
- currentTemp: number().nullable(),
23282
- /** Target temperature setpoint. Null when no setpoint surface. */
23283
- targetTemp: number().nullable(),
23284
- /** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
23285
- * `heat_pump` / `high_demand` / `performance` / `off`). Null when the
23286
- * device reports an unknown state. */
23287
- operationMode: string().nullable(),
23288
- /** Available operation modes = HA `operation_list`. */
23289
- availableModes: array(string()),
23290
- /** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
23291
- * has no away surface. */
23292
- away: boolean().nullable(),
23293
- /** HA `min_temp` attribute. Null when not reported. */
23294
- minTemp: number().nullable(),
23295
- /** HA `max_temp` attribute. Null when not reported. */
23296
- maxTemp: number().nullable(),
23297
- /** Ms epoch when the slice was last updated. */
23298
- lastChangedAt: number()
23299
- });
23300
- DeviceType.WaterHeater, method(object({
23301
- deviceId: number().int().nonnegative(),
23302
- temp: number().finite()
23303
- }), _void(), {
23304
- kind: "mutation",
23305
- auth: "admin"
23306
- }), method(object({
23307
- deviceId: number().int().nonnegative(),
23308
- mode: string().min(1)
23309
- }), _void(), {
23310
- kind: "mutation",
23311
- auth: "admin"
23312
- }), method(object({
23313
- deviceId: number().int().nonnegative(),
23314
- on: boolean()
23315
- }), _void(), {
23316
- kind: "mutation",
23317
- auth: "admin"
23318
- });
23319
- object({
23320
- /** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
23321
- * when no condition has been reported yet. */
23322
- condition: string().nullable(),
23323
- /** Current temperature in the reported unit. Null when not provided. */
23324
- temperature: number().nullable(),
23325
- /** Temperature unit string (e.g. `°C` / `°F`). Null when not provided. */
23326
- temperatureUnit: string().nullable(),
23327
- /** Relative humidity (0..100). Null when not provided. */
23328
- humidity: number().min(0).max(100).nullable(),
23329
- /** Barometric pressure in the reported unit. Null when not provided. */
23330
- pressure: number().nullable(),
23331
- /** Pressure unit string (e.g. `hPa` / `inHg`). Null when not provided. */
23332
- pressureUnit: string().nullable(),
23333
- /** Wind speed in the reported unit. Null when not provided. */
23334
- windSpeed: number().nullable(),
23335
- /** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
23336
- windSpeedUnit: string().nullable(),
23337
- /** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
23338
- windBearing: number().nullable(),
23339
- /** Ms epoch when the slice was last updated. */
23340
- lastFetchedAt: number()
23341
- });
23342
- DeviceType.Weather;
23156
+ var KEYWORDS = new Set([
23157
+ "true",
23158
+ "false",
23159
+ "null"
23160
+ ]);
23161
+ function isDigit(ch) {
23162
+ return ch >= "0" && ch <= "9";
23163
+ }
23164
+ function isIdentStart(ch) {
23165
+ return ch >= "A" && ch <= "Z" || ch >= "a" && ch <= "z" || ch === "_";
23166
+ }
23167
+ function isIdentPart(ch) {
23168
+ return isIdentStart(ch) || isDigit(ch);
23169
+ }
23170
+ function isWhitespace(ch) {
23171
+ return ch === " " || ch === " " || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
23172
+ }
23173
+ /** Tokenize `source` into a flat token list ending with a single `eof` token.
23174
+ * Throws `ExpressionParseError` on any illegal character or unterminated
23175
+ * string. */
23176
+ function tokenize(source) {
23177
+ if (source.length > 2048) throw new ExpressionParseError(`expression too long (${source.length} > ${MAX_EXPRESSION_SOURCE_LENGTH} chars)`, 0);
23178
+ const tokens = [];
23179
+ let i = 0;
23180
+ const n = source.length;
23181
+ while (i < n) {
23182
+ const ch = source[i];
23183
+ if (isWhitespace(ch)) {
23184
+ i += 1;
23185
+ continue;
23186
+ }
23187
+ if (isDigit(ch)) {
23188
+ const start = i;
23189
+ while (i < n && isDigit(source[i])) i += 1;
23190
+ if (i < n && source[i] === ".") {
23191
+ if (i + 1 >= n || !isDigit(source[i + 1])) throw new ExpressionParseError("malformed number: decimal point needs a digit", i);
23192
+ i += 1;
23193
+ while (i < n && isDigit(source[i])) i += 1;
23194
+ }
23195
+ const text = source.slice(start, i);
23196
+ const value = Number(text);
23197
+ if (!Number.isFinite(value)) throw new ExpressionParseError(`malformed number: '${text}'`, start);
23198
+ tokens.push({
23199
+ type: "number",
23200
+ value,
23201
+ pos: start
23202
+ });
23203
+ continue;
23204
+ }
23205
+ if (ch === "'" || ch === "\"") {
23206
+ const quote = ch;
23207
+ const start = i;
23208
+ i += 1;
23209
+ let out = "";
23210
+ let closed = false;
23211
+ while (i < n) {
23212
+ const c = source[i];
23213
+ if (c === "\\") {
23214
+ const next = i + 1 < n ? source[i + 1] : "";
23215
+ if (next === "\\" || next === "'" || next === "\"") {
23216
+ out += next;
23217
+ i += 2;
23218
+ continue;
23219
+ }
23220
+ throw new ExpressionParseError(`invalid string escape: '\\${next}'`, i);
23221
+ }
23222
+ if (c === quote) {
23223
+ closed = true;
23224
+ i += 1;
23225
+ break;
23226
+ }
23227
+ out += c;
23228
+ i += 1;
23229
+ }
23230
+ if (!closed) throw new ExpressionParseError("unterminated string literal", start);
23231
+ tokens.push({
23232
+ type: "string",
23233
+ value: out,
23234
+ pos: start
23235
+ });
23236
+ continue;
23237
+ }
23238
+ if (isIdentStart(ch)) {
23239
+ const start = i;
23240
+ while (i < n && isIdentPart(source[i])) i += 1;
23241
+ const text = source.slice(start, i);
23242
+ if (KEYWORDS.has(text)) tokens.push({
23243
+ type: "keyword",
23244
+ keyword: keywordOf(text),
23245
+ pos: start
23246
+ });
23247
+ else tokens.push({
23248
+ type: "identifier",
23249
+ name: text,
23250
+ pos: start
23251
+ });
23252
+ continue;
23253
+ }
23254
+ const two = i + 1 < n ? source.slice(i, i + 2) : "";
23255
+ if (two === "<=" || two === ">=" || two === "==" || two === "!=" || two === "&&" || two === "||") {
23256
+ tokens.push({
23257
+ type: "punct",
23258
+ punct: two,
23259
+ pos: i
23260
+ });
23261
+ i += 2;
23262
+ continue;
23263
+ }
23264
+ if (isSinglePunct(ch)) {
23265
+ tokens.push({
23266
+ type: "punct",
23267
+ punct: ch,
23268
+ pos: i
23269
+ });
23270
+ i += 1;
23271
+ continue;
23272
+ }
23273
+ throw new ExpressionParseError(`unexpected character '${ch}'`, i);
23274
+ }
23275
+ tokens.push({
23276
+ type: "eof",
23277
+ pos: n
23278
+ });
23279
+ return tokens;
23280
+ }
23281
+ function keywordOf(text) {
23282
+ if (text === "true") return "true";
23283
+ if (text === "false") return "false";
23284
+ return "null";
23285
+ }
23286
+ function isSinglePunct(ch) {
23287
+ return ch === "(" || ch === ")" || ch === "," || ch === "?" || ch === ":" || ch === "+" || ch === "-" || ch === "*" || ch === "/" || ch === "%" || ch === "!" || ch === "<" || ch === ">";
23288
+ }
23343
23289
  /**
23344
- * Per-zone occupancy aggregation produced by the analytics frame
23345
- * processor on every inference result. Covers the full combinatorial
23346
- * matrix the operator UI needs: total objects everywhere, total
23347
- * objects per zone, single class everywhere, single class per zone,
23348
- * objects outside any zone.
23290
+ * Pratt (precedence-climbing) parser for the safe expression mini-language.
23349
23291
  *
23350
- * Counts are derived from the analytics tracker (tracked detections
23351
- * with stable trackIds), not raw detector hits this filters out
23352
- * one-off detector flickers and gives counts that match what the user
23353
- * sees on the live overlay.
23292
+ * Precedence (low high): ternary `?:` (right-assoc) → `||` → `&&` → equality
23293
+ * relational additive multiplicative unary `! -` → call / primary.
23294
+ * Calls are ONLY `IDENT '(' args? ')'` at primary position — the callee is a
23295
+ * string validated against the builtin table at parse time, so an unknown
23296
+ * function is rejected immediately (author feedback) and a persisted expression
23297
+ * that references a since-removed builtin degrades at read.
23354
23298
  *
23355
- * Overlap policy: a detection that intersects two zones counts in
23356
- * BOTH zones' `byClass` and `totalObjects` (count-in-each). The
23357
- * `frame` aggregate de-duplicates trivially since it's frame-wide.
23358
- * `unzoned` counts only detections that landed in zero zones.
23359
- */
23360
- var PerScopeBreakdownSchema = object({
23361
- /** Total tracked objects in this scope (frame / zone / unzoned). */
23362
- totalObjects: number().int().nonnegative(),
23363
- /** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
23364
- byClass: record(string(), number().int().nonnegative())
23365
- });
23366
- var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
23367
- zoneId: string(),
23368
- zoneName: string(),
23369
- /** TrackIds of objects currently inside this zone — for cross-reference
23370
- * with the per-track detail panel and live overlay. */
23371
- trackIds: array(string()).readonly()
23372
- });
23373
- /**
23374
- * A parked ("stationary") object surfaced alongside occupancy — an object that
23375
- * settled and stopped moving. It is NO LONGER a tracked object (the tracker was
23376
- * told to forget it so it stops re-spawning tracks/events), but it IS still
23377
- * physically present, so it keeps counting toward `frame` occupancy and is
23378
- * listed here so the UI can show it in a dedicated "Stationary" section instead
23379
- * of flooding the live event feed.
23299
+ * A node counter caps total AST size (`MAX_EXPRESSION_AST_NODES`) and call
23300
+ * arity is capped (`MAX_EXPRESSION_CALL_ARGS`) both raise `ExpressionParseError`.
23380
23301
  */
23381
- var StationaryObjectSchema = object({
23382
- id: string(),
23383
- className: string(),
23384
- bbox: object({
23385
- x: number(),
23386
- y: number(),
23387
- w: number(),
23388
- h: number()
23389
- }),
23390
- frameWidth: number().int().nonnegative(),
23391
- frameHeight: number().int().nonnegative(),
23392
- /** When the source track was first seen. */
23393
- firstSeenAt: number().int(),
23394
- /** When the object was recognised as parked (promotion time). */
23395
- becameStationaryAt: number().int(),
23396
- /** Last frame a detection confirmed the object is still there. */
23397
- lastConfirmedAt: number().int(),
23398
- /** Enrichment label carried from the source track (identity / plate). */
23399
- label: string().optional(),
23400
- /** Native-resolution key-frame media key for the parked object's best image. */
23401
- keyFrameMediaKey: string().optional()
23402
- });
23403
- var CameraOccupancySnapshotSchema = object({
23404
- /** Frame timestamp of the inference result that produced this snapshot. */
23405
- ts: number().int(),
23406
- /** Frame width/height in pixels — let the UI normalize bbox coords. */
23407
- frameWidth: number().int().nonnegative(),
23408
- frameHeight: number().int().nonnegative(),
23409
- /** Per-zone breakdown — one entry per defined zone (user + onboard). */
23410
- zones: array(ZoneScopeBreakdownSchema).readonly(),
23411
- /** Frame-wide aggregate (everywhere, regardless of zone membership).
23412
- * INCLUDES currently-confirmed stationary objects (they are still present). */
23413
- frame: PerScopeBreakdownSchema,
23414
- /** Detections that landed outside every zone. Empty when no zones defined. */
23415
- unzoned: PerScopeBreakdownSchema,
23416
- /** Parked objects on this camera (additive — absent on legacy snapshots).
23417
- * Surfaced separately so the UI shows them in a dedicated section rather
23418
- * than as repeated tracks/events. */
23419
- stationaryObjects: array(StationaryObjectSchema).readonly().optional()
23420
- });
23302
+ /** Binary/logical operator precedence (higher binds tighter). */
23303
+ var BINARY_PRECEDENCE = {
23304
+ "||": 1,
23305
+ "&&": 2,
23306
+ "==": 3,
23307
+ "!=": 3,
23308
+ "<": 4,
23309
+ "<=": 4,
23310
+ ">": 4,
23311
+ ">=": 4,
23312
+ "+": 5,
23313
+ "-": 5,
23314
+ "*": 6,
23315
+ "/": 6,
23316
+ "%": 6
23317
+ };
23318
+ function isLogicalOp(op) {
23319
+ return op === "&&" || op === "||";
23320
+ }
23321
+ function isBinaryOp(op) {
23322
+ return op === "+" || op === "-" || op === "*" || op === "/" || op === "%" || op === "==" || op === "!=" || op === "<" || op === "<=" || op === ">" || op === ">=";
23323
+ }
23324
+ var Parser = class {
23325
+ tokens;
23326
+ pos = 0;
23327
+ nodeCount = 0;
23328
+ identifiers = /* @__PURE__ */ new Set();
23329
+ callees = /* @__PURE__ */ new Set();
23330
+ constructor(tokens) {
23331
+ this.tokens = tokens;
23332
+ }
23333
+ parse() {
23334
+ const ast = this.parseTernary();
23335
+ const tok = this.peek();
23336
+ if (tok.type !== "eof") throw new ExpressionParseError("unexpected trailing input", tok.pos);
23337
+ return {
23338
+ ast,
23339
+ identifiers: this.identifiers,
23340
+ callees: this.callees,
23341
+ nodeCount: this.nodeCount
23342
+ };
23343
+ }
23344
+ peek() {
23345
+ return this.tokens[this.pos];
23346
+ }
23347
+ next() {
23348
+ return this.tokens[this.pos++];
23349
+ }
23350
+ /** Consume a punctuator token, erroring if the next token isn't it. */
23351
+ expectPunct(punct) {
23352
+ const tok = this.peek();
23353
+ if (tok.type !== "punct" || tok.punct !== punct) throw new ExpressionParseError(`expected '${punct}'`, tok.pos);
23354
+ this.pos += 1;
23355
+ }
23356
+ matchPunct(punct) {
23357
+ const tok = this.peek();
23358
+ if (tok.type === "punct" && tok.punct === punct) {
23359
+ this.pos += 1;
23360
+ return true;
23361
+ }
23362
+ return false;
23363
+ }
23364
+ countNode() {
23365
+ this.nodeCount += 1;
23366
+ if (this.nodeCount > 256) throw new ExpressionParseError("expression too complex", this.peek().pos);
23367
+ }
23368
+ parseTernary() {
23369
+ const test = this.parseBinary(1);
23370
+ if (this.matchPunct("?")) {
23371
+ const consequent = this.parseTernary();
23372
+ this.expectPunct(":");
23373
+ const alternate = this.parseTernary();
23374
+ this.countNode();
23375
+ return {
23376
+ kind: "conditional",
23377
+ test,
23378
+ consequent,
23379
+ alternate
23380
+ };
23381
+ }
23382
+ return test;
23383
+ }
23384
+ parseBinary(minPrec) {
23385
+ let left = this.parseUnary();
23386
+ for (;;) {
23387
+ const tok = this.peek();
23388
+ if (tok.type !== "punct") break;
23389
+ const prec = BINARY_PRECEDENCE[tok.punct];
23390
+ if (prec === void 0 || prec < minPrec) break;
23391
+ const op = tok.punct;
23392
+ this.pos += 1;
23393
+ const right = this.parseBinary(prec + 1);
23394
+ this.countNode();
23395
+ if (isLogicalOp(op)) left = {
23396
+ kind: "logical",
23397
+ op,
23398
+ left,
23399
+ right
23400
+ };
23401
+ else if (isBinaryOp(op)) left = {
23402
+ kind: "binary",
23403
+ op,
23404
+ left,
23405
+ right
23406
+ };
23407
+ else throw new ExpressionParseError(`unexpected operator '${op}'`, tok.pos);
23408
+ }
23409
+ return left;
23410
+ }
23411
+ parseUnary() {
23412
+ const tok = this.peek();
23413
+ if (tok.type === "punct" && (tok.punct === "!" || tok.punct === "-")) {
23414
+ const op = tok.punct;
23415
+ this.pos += 1;
23416
+ const operand = this.parseUnary();
23417
+ this.countNode();
23418
+ return {
23419
+ kind: "unary",
23420
+ op,
23421
+ operand
23422
+ };
23423
+ }
23424
+ return this.parsePrimary();
23425
+ }
23426
+ parsePrimary() {
23427
+ const tok = this.next();
23428
+ switch (tok.type) {
23429
+ case "number":
23430
+ this.countNode();
23431
+ return {
23432
+ kind: "literal",
23433
+ value: tok.value
23434
+ };
23435
+ case "string":
23436
+ this.countNode();
23437
+ return {
23438
+ kind: "literal",
23439
+ value: tok.value
23440
+ };
23441
+ case "keyword":
23442
+ this.countNode();
23443
+ return {
23444
+ kind: "literal",
23445
+ value: tok.keyword === "null" ? null : tok.keyword === "true"
23446
+ };
23447
+ case "identifier": {
23448
+ const nextTok = this.peek();
23449
+ if (nextTok.type === "punct" && nextTok.punct === "(") return this.parseCall(tok.name, tok.pos);
23450
+ this.identifiers.add(tok.name);
23451
+ this.countNode();
23452
+ return {
23453
+ kind: "identifier",
23454
+ name: tok.name
23455
+ };
23456
+ }
23457
+ case "punct":
23458
+ if (tok.punct === "(") {
23459
+ const inner = this.parseTernary();
23460
+ this.expectPunct(")");
23461
+ return inner;
23462
+ }
23463
+ throw new ExpressionParseError(`unexpected token '${tok.punct}'`, tok.pos);
23464
+ case "eof": throw new ExpressionParseError("unexpected end of expression", tok.pos);
23465
+ }
23466
+ }
23467
+ parseCall(callee, pos) {
23468
+ if (!EXPRESSION_BUILTIN_NAMES.has(callee)) throw new ExpressionParseError(`unknown function '${callee}'`, pos);
23469
+ this.expectPunct("(");
23470
+ const args = [];
23471
+ if (!this.matchPunct(")")) for (;;) {
23472
+ args.push(this.parseTernary());
23473
+ if (args.length > 16) throw new ExpressionParseError(`too many arguments to '${callee}'`, pos);
23474
+ if (this.matchPunct(",")) continue;
23475
+ this.expectPunct(")");
23476
+ break;
23477
+ }
23478
+ this.callees.add(callee);
23479
+ this.countNode();
23480
+ return {
23481
+ kind: "call",
23482
+ callee,
23483
+ args
23484
+ };
23485
+ }
23486
+ };
23487
+ /** Tokenize + parse `source` into a validated `ParsedExpression`. Throws
23488
+ * `ExpressionParseError` on any lexical or grammatical failure. */
23489
+ function parseExpression(source) {
23490
+ return new Parser(tokenize(source)).parse();
23491
+ }
23421
23492
  /**
23422
- * Time-series resolution. The history methods return one bucket per
23423
- * step over the requested range. Smaller resolutions cost more
23424
- * memory + bandwidth; bound to discrete steps so caller cannot ask
23425
- * for arbitrary fractional buckets.
23493
+ * LRU compile cache for parsed expressions (spec §2.4 "parse once … LRU keyed
23494
+ * by expr"). The cache stores BOTH successes and failures (negative caching),
23495
+ * so a corrupt persisted string costs exactly one tokenize+parse total — not
23496
+ * one per read on a hot resolve path.
23497
+ *
23498
+ * The cache is a module-level singleton: entries are pure, content-addressed
23499
+ * ASTs keyed by the raw source string, so sharing one instance across all
23500
+ * callers is safe and maximises hit rate.
23426
23501
  */
23427
- var HistoryResolutionEnum = _enum([
23428
- "minute",
23429
- "5min",
23430
- "hour"
23431
- ]);
23432
- var HistoryRangeSchema = object({
23433
- /** Range start (epoch ms, inclusive). */
23434
- from: number().int(),
23435
- /** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
23436
- to: number().int(),
23437
- resolution: HistoryResolutionEnum
23438
- });
23439
- var HistoryPointSchema = object({
23440
- /** Bucket midpoint (epoch ms). */
23441
- ts: number().int(),
23442
- /** Object count averaged over the bucket (rounded to nearest integer). */
23443
- count: number().int().nonnegative()
23444
- });
23445
- DeviceType.Camera, method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()), method(object({
23446
- deviceId: number(),
23447
- zoneId: string(),
23448
- className: string().optional()
23449
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
23450
- deviceId: number(),
23451
- className: string().optional()
23452
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly()), method(object({
23453
- deviceId: number(),
23454
- className: string().optional()
23455
- }).extend(HistoryRangeSchema.shape), array(HistoryPointSchema).readonly());
23502
+ var cache = /* @__PURE__ */ new Map();
23503
+ function getCached(source) {
23504
+ const hit = cache.get(source);
23505
+ if (hit !== void 0) {
23506
+ cache.delete(source);
23507
+ cache.set(source, hit);
23508
+ return hit;
23509
+ }
23510
+ let result;
23511
+ try {
23512
+ result = {
23513
+ ok: true,
23514
+ parsed: parseExpression(source)
23515
+ };
23516
+ } catch (err) {
23517
+ result = {
23518
+ ok: false,
23519
+ error: err instanceof ExpressionParseError ? err.message : String(err)
23520
+ };
23521
+ }
23522
+ cache.set(source, result);
23523
+ if (cache.size > 256) {
23524
+ const oldest = cache.keys().next().value;
23525
+ if (oldest !== void 0) cache.delete(oldest);
23526
+ }
23527
+ return result;
23528
+ }
23529
+ /** Compile `source`, returning a discriminated result instead of throwing.
23530
+ * Used by read paths that must degrade rather than raise. LRU/negative-cached. */
23531
+ function compileExpressionSafe(source) {
23532
+ return getCached(source);
23533
+ }
23534
+ Object.freeze({});
23456
23535
  /**
23457
- * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
23458
- * cap so a single CRUD surface backs every consumer; each stage has
23459
- * its own dev-state mirror slice (`motion-zone-rules`,
23460
- * `detection-zone-rules`, …) so consumer addons subscribe independently.
23461
- *
23462
- * Extend the enum here when a new gating consumer comes online (audio
23463
- * gating, alert filtering, …) — no other surface needs to change.
23536
+ * Author-time validation. Returns `null` when the source is valid, else a
23537
+ * human-readable error message. Checks: the expression compiles; binding count
23538
+ * is within `MAX_EXPRESSION_BINDINGS`; every binding name is a legal identifier,
23539
+ * is not reserved (`now`/keywords) and does not shadow a builtin; and every
23540
+ * FREE identifier of the AST is covered by a binding or the injected `now`.
23464
23541
  */
23465
- var ZoneRuleStageEnum = _enum([
23466
- "motion",
23467
- "detection",
23468
- "package"
23542
+ function validateExpressionSource(src) {
23543
+ const names = Object.keys(src.bindings);
23544
+ if (names.length > 32) return `too many bindings (${names.length} > 32)`;
23545
+ for (const name of names) {
23546
+ if (!EXPRESSION_IDENTIFIER_RE.test(name)) return `invalid binding name '${name}'`;
23547
+ if (RESERVED_BINDING_NAMES.has(name)) return `binding name '${name}' is reserved`;
23548
+ if (EXPRESSION_BUILTIN_NAMES.has(name)) return `binding name '${name}' shadows a builtin function`;
23549
+ }
23550
+ const compiled = compileExpressionSafe(src.expr);
23551
+ if (!compiled.ok) return compiled.error;
23552
+ const bound = new Set(names);
23553
+ for (const id of compiled.parsed.identifiers) {
23554
+ if (id === "now") continue;
23555
+ if (!bound.has(id)) return `expression references unbound identifier '${id}'`;
23556
+ }
23557
+ return null;
23558
+ }
23559
+ var ExpressionBindingSourceSchema = union([
23560
+ object({
23561
+ kind: literal("field").optional(),
23562
+ sourceKey: string(),
23563
+ cap: string(),
23564
+ fieldPath: string()
23565
+ }),
23566
+ object({
23567
+ kind: literal("literal"),
23568
+ value: union([
23569
+ string(),
23570
+ number(),
23571
+ boolean(),
23572
+ _null()
23573
+ ])
23574
+ }),
23575
+ object({
23576
+ kind: literal("global"),
23577
+ sourceStableId: string(),
23578
+ cap: string(),
23579
+ fieldPath: string()
23580
+ })
23469
23581
  ]);
23470
- DeviceType.Camera, method(object({
23471
- deviceId: number(),
23472
- stage: ZoneRuleStageEnum
23473
- }), array(ZoneRuleSchema).readonly()), method(object({
23474
- deviceId: number(),
23475
- stage: ZoneRuleStageEnum,
23476
- rules: array(ZoneRuleSchema).readonly()
23477
- }), _void(), {
23478
- kind: "mutation",
23479
- auth: "admin"
23480
- }), object({
23481
- motion: array(ZoneRuleSchema).readonly(),
23482
- detection: array(ZoneRuleSchema).readonly(),
23483
- package: array(ZoneRuleSchema).readonly()
23582
+ object({
23583
+ expr: string().min(1).max(MAX_EXPRESSION_SOURCE_LENGTH),
23584
+ bindings: record(string().regex(EXPRESSION_IDENTIFIER_RE), ExpressionBindingSourceSchema)
23585
+ }).superRefine((src, ctx) => {
23586
+ const err = validateExpressionSource(src);
23587
+ if (err !== null) ctx.addIssue({
23588
+ code: "custom",
23589
+ message: err,
23590
+ path: ["expr"]
23591
+ });
23484
23592
  });
23485
- /**
23486
- * Accessory device helpers — shared across drivers.
23487
- *
23488
- * Many vendor-specific drivers register accessory child devices on
23489
- * top of a parent (Reolink: siren / floodlight / PIR / autotrack /
23490
- * chime; ONVIF: relay outputs; future: Tapo Hub child devices). Each
23491
- * driver picks the right `DeviceType` + `DeviceRole` explicitly when
23492
- * spawning, builds a name derived from the parent, and produces a
23493
- * stableId tied to the parent so boot-restore can reconstruct the
23494
- * relationship.
23495
- *
23496
- * Centralised `(kind → DeviceType)` mapping was dropped on purpose:
23497
- * drivers may reasonably disagree on the right type for an accessory
23498
- * (a Reolink PIR exposes a switch on/off + sensitivity, while a hypothetical
23499
- * read-only motion-only sensor might be `DeviceType.Sensor`). Forcing
23500
- * one canonical mapping was over-prescriptive and added a layer of
23501
- * indirection without saving meaningful code at call sites — the
23502
- * driver knows its own hardware best.
23503
- */
23504
- /**
23505
- * Subset of `DeviceRole` values that drivers register as child
23506
- * accessories of a parent device. Sourced verbatim from `DeviceRole`
23507
- * — `AccessoryKind` is the alias drivers use when building accessory
23508
- * children, so the call site reads as
23509
- * `accessoryStableId(parent, AccessoryKind.Siren)` rather than
23510
- * `accessoryStableId(parent, DeviceRole.Siren)` (which would imply
23511
- * any role works, including non-accessory ones like Doorbell).
23512
- */
23513
- var AccessoryKind = {
23514
- Siren: DeviceRole.Siren,
23515
- Floodlight: DeviceRole.Floodlight,
23516
- Spotlight: DeviceRole.Spotlight,
23517
- PirSensor: DeviceRole.PirSensor,
23518
- Chime: DeviceRole.Chime,
23519
- Autotrack: DeviceRole.Autotrack,
23520
- Nightvision: DeviceRole.Nightvision,
23521
- PrivacyMask: DeviceRole.PrivacyMask
23522
- };
23523
- AccessoryKind.Siren, AccessoryKind.Floodlight, AccessoryKind.Spotlight, AccessoryKind.PirSensor, AccessoryKind.Chime, AccessoryKind.Autotrack, AccessoryKind.Nightvision, AccessoryKind.PrivacyMask;
23524
- 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;
23525
- new Set(Object.values(DeviceType));
23526
- DeviceFeature.BatteryOperated;
23527
23593
  Object.freeze({
23528
23594
  "accessories.setChildHidden": {
23529
23595
  capName: "accessories",
@@ -24347,6 +24413,12 @@ Object.freeze({
24347
24413
  addonId: null,
24348
24414
  access: "view"
24349
24415
  },
24416
+ "coreBlocks.restart": {
24417
+ capName: "core-blocks",
24418
+ capScope: "system",
24419
+ addonId: null,
24420
+ access: "create"
24421
+ },
24350
24422
  "coreBlocks.setEnabled": {
24351
24423
  capName: "core-blocks",
24352
24424
  capScope: "system",
@@ -24983,12 +25055,6 @@ Object.freeze({
24983
25055
  addonId: null,
24984
25056
  access: "create"
24985
25057
  },
24986
- "deviceManager.setDeviceLinks": {
24987
- capName: "device-manager",
24988
- capScope: "system",
24989
- addonId: null,
24990
- access: "create"
24991
- },
24992
25058
  "deviceManager.setDisabled": {
24993
25059
  capName: "device-manager",
24994
25060
  capScope: "system",
@@ -28133,6 +28199,12 @@ Object.freeze({
28133
28199
  addonId: null,
28134
28200
  access: "create"
28135
28201
  },
28202
+ "streamBroker.fetchEventMedia": {
28203
+ capName: "stream-broker",
28204
+ capScope: "system",
28205
+ addonId: null,
28206
+ access: "create"
28207
+ },
28136
28208
  "streamBroker.getAllRtspEntries": {
28137
28209
  capName: "stream-broker",
28138
28210
  capScope: "system",
@@ -28217,6 +28289,12 @@ Object.freeze({
28217
28289
  addonId: null,
28218
28290
  access: "create"
28219
28291
  },
28292
+ "streamBroker.produceEventMedia": {
28293
+ capName: "stream-broker",
28294
+ capScope: "system",
28295
+ addonId: null,
28296
+ access: "create"
28297
+ },
28220
28298
  "streamBroker.publishCameraStream": {
28221
28299
  capName: "stream-broker",
28222
28300
  capScope: "system",
@@ -29138,7 +29216,7 @@ var AdminUIAddon = class extends BaseAddon {
29138
29216
  capability: adminUiCapability,
29139
29217
  provider: {
29140
29218
  getStaticDir: async () => ({ staticDir: resolveAdminUiDistDir() }),
29141
- getVersion: async () => ({ version: "1.2.35" })
29219
+ getVersion: async () => ({ version: "1.2.37" })
29142
29220
  }
29143
29221
  }];
29144
29222
  }