@camstack/system 1.0.2

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 (262) hide show
  1. package/dist/addon/addon-api-factory.d.ts +35 -0
  2. package/dist/addon-routes/addon-route-registry.d.ts +37 -0
  3. package/dist/addon-runner.js +599 -0
  4. package/dist/addon-runner.mjs +597 -0
  5. package/dist/auth/api-key-manager.d.ts +26 -0
  6. package/dist/auth/auth-manager.d.ts +109 -0
  7. package/dist/auth/parse-record.d.ts +18 -0
  8. package/dist/auth/scope-matcher.d.ts +7 -0
  9. package/dist/auth/scoped-token-manager.d.ts +40 -0
  10. package/dist/auth/totp-manager.d.ts +51 -0
  11. package/dist/auth/user-manager.d.ts +34 -0
  12. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.d.ts +53 -0
  13. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +259 -0
  14. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +251 -0
  15. package/dist/builtins/addon-pages-aggregator/dedupe-pages.d.ts +6 -0
  16. package/dist/builtins/addon-pages-aggregator/index.d.ts +1 -0
  17. package/dist/builtins/addon-pages-aggregator/index.js +8 -0
  18. package/dist/builtins/addon-pages-aggregator/index.mjs +2 -0
  19. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.d.ts +47 -0
  20. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +228 -0
  21. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +220 -0
  22. package/dist/builtins/addon-widgets-aggregator/index.d.ts +1 -0
  23. package/dist/builtins/addon-widgets-aggregator/index.js +8 -0
  24. package/dist/builtins/addon-widgets-aggregator/index.mjs +2 -0
  25. package/dist/builtins/alerts/alerts.addon.d.ts +81 -0
  26. package/dist/builtins/alerts/alerts.addon.js +601 -0
  27. package/dist/builtins/alerts/alerts.addon.mjs +595 -0
  28. package/dist/builtins/alerts/index.d.ts +1 -0
  29. package/dist/builtins/alerts/index.js +4 -0
  30. package/dist/builtins/alerts/index.mjs +2 -0
  31. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.d.ts +147 -0
  32. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2229 -0
  33. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2220 -0
  34. package/dist/builtins/backup-orchestrator/cron-helpers.d.ts +23 -0
  35. package/dist/builtins/backup-orchestrator/destination-policy.d.ts +72 -0
  36. package/dist/builtins/backup-orchestrator/download-helpers.d.ts +12 -0
  37. package/dist/builtins/backup-orchestrator/index.d.ts +2 -0
  38. package/dist/builtins/backup-orchestrator/index.js +8 -0
  39. package/dist/builtins/backup-orchestrator/index.mjs +2 -0
  40. package/dist/builtins/backup-orchestrator/manifest-store.d.ts +77 -0
  41. package/dist/builtins/console-logging/console-destination.d.ts +13 -0
  42. package/dist/builtins/console-logging/console-logging.addon.d.ts +25 -0
  43. package/dist/builtins/console-logging/index.d.ts +3 -0
  44. package/dist/builtins/console-logging/index.js +104 -0
  45. package/dist/builtins/console-logging/index.mjs +95 -0
  46. package/dist/builtins/device-manager/device-config-contribution.d.ts +32 -0
  47. package/dist/builtins/device-manager/device-event-propagator.d.ts +26 -0
  48. package/dist/builtins/device-manager/device-link-overlay.d.ts +23 -0
  49. package/dist/builtins/device-manager/device-link-resolver.d.ts +15 -0
  50. package/dist/builtins/device-manager/device-manager.addon.d.ts +452 -0
  51. package/dist/builtins/device-manager/device-manager.addon.js +3299 -0
  52. package/dist/builtins/device-manager/device-manager.addon.mjs +3292 -0
  53. package/dist/builtins/device-manager/index.d.ts +2 -0
  54. package/dist/builtins/device-manager/index.js +8 -0
  55. package/dist/builtins/device-manager/index.mjs +2 -0
  56. package/dist/builtins/hub-forwarder/hub-forwarder-destination.d.ts +44 -0
  57. package/dist/builtins/hub-forwarder/hub-forwarder.addon.d.ts +15 -0
  58. package/dist/builtins/hub-forwarder/index.d.ts +3 -0
  59. package/dist/builtins/hub-forwarder/index.js +154 -0
  60. package/dist/builtins/hub-forwarder/index.mjs +145 -0
  61. package/dist/builtins/local-auth/auth-schema.d.ts +26 -0
  62. package/dist/builtins/local-auth/index.d.ts +1 -0
  63. package/dist/builtins/local-auth/index.js +4 -0
  64. package/dist/builtins/local-auth/index.mjs +2 -0
  65. package/dist/builtins/local-auth/local-auth.addon.d.ts +18 -0
  66. package/dist/builtins/local-auth/local-auth.addon.js +8094 -0
  67. package/dist/builtins/local-auth/local-auth.addon.mjs +8063 -0
  68. package/dist/builtins/local-auth/oauth-grants.d.ts +45 -0
  69. package/dist/builtins/local-auth/oauth-session-manager.d.ts +50 -0
  70. package/dist/builtins/local-network/index.d.ts +2 -0
  71. package/dist/builtins/local-network/index.js +10 -0
  72. package/dist/builtins/local-network/index.mjs +2 -0
  73. package/dist/builtins/local-network/local-network.addon.d.ts +150 -0
  74. package/dist/builtins/local-network/local-network.addon.js +489 -0
  75. package/dist/builtins/local-network/local-network.addon.mjs +477 -0
  76. package/dist/builtins/native-metrics/index.d.ts +2 -0
  77. package/dist/builtins/native-metrics/native-metrics-provider.d.ts +48 -0
  78. package/dist/builtins/native-metrics/native-metrics.addon.d.ts +73 -0
  79. package/dist/builtins/native-metrics/native-metrics.addon.js +922 -0
  80. package/dist/builtins/native-metrics/native-metrics.addon.mjs +914 -0
  81. package/dist/builtins/platform-probe/hardware-decode-accel-probe.d.ts +37 -0
  82. package/dist/builtins/platform-probe/hardware-encoder-probe.d.ts +13 -0
  83. package/dist/builtins/platform-probe/index.d.ts +22 -0
  84. package/dist/builtins/platform-probe/index.js +834 -0
  85. package/dist/builtins/platform-probe/index.mjs +822 -0
  86. package/dist/builtins/platform-probe/inference-config-resolver.d.ts +29 -0
  87. package/dist/builtins/platform-probe/intel-accelerators.d.ts +11 -0
  88. package/dist/builtins/platform-probe/platform-scorer.d.ts +30 -0
  89. package/dist/builtins/platform-probe/runtime-packages.d.ts +6 -0
  90. package/dist/builtins/remote-access-orchestrator/enabled-providers-reconcile.d.ts +96 -0
  91. package/dist/builtins/remote-access-orchestrator/index.d.ts +1 -0
  92. package/dist/builtins/remote-access-orchestrator/index.js +8 -0
  93. package/dist/builtins/remote-access-orchestrator/index.mjs +2 -0
  94. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.d.ts +40 -0
  95. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +214 -0
  96. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +208 -0
  97. package/dist/builtins/shared/settle-sources.d.ts +22 -0
  98. package/dist/builtins/snapshot/index.d.ts +2 -0
  99. package/dist/builtins/snapshot/index.js +494 -0
  100. package/dist/builtins/snapshot/index.mjs +488 -0
  101. package/dist/builtins/snapshot/snapshot.addon.d.ts +120 -0
  102. package/dist/builtins/sqlite-storage/config-store.d.ts +8 -0
  103. package/dist/builtins/sqlite-storage/device-store.d.ts +23 -0
  104. package/dist/builtins/sqlite-storage/filesystem-browse-provider.d.ts +25 -0
  105. package/dist/builtins/sqlite-storage/filesystem-storage-provider.d.ts +83 -0
  106. package/dist/builtins/sqlite-storage/filesystem-storage.addon.d.ts +32 -0
  107. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +396 -0
  108. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +388 -0
  109. package/dist/builtins/sqlite-storage/index.d.ts +8 -0
  110. package/dist/builtins/sqlite-storage/index.js +62 -0
  111. package/dist/builtins/sqlite-storage/index.mjs +49 -0
  112. package/dist/builtins/sqlite-storage/integration-registry.d.ts +27 -0
  113. package/dist/builtins/sqlite-storage/path-guard.d.ts +4 -0
  114. package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +102 -0
  115. package/dist/builtins/sqlite-storage/sqlite-settings.addon.d.ts +14 -0
  116. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +644 -0
  117. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +636 -0
  118. package/dist/builtins/storage-orchestrator/index.d.ts +6 -0
  119. package/dist/builtins/storage-orchestrator/index.js +10 -0
  120. package/dist/builtins/storage-orchestrator/index.mjs +2 -0
  121. package/dist/builtins/storage-orchestrator/location-store.d.ts +49 -0
  122. package/dist/builtins/storage-orchestrator/provider-discovery.d.ts +10 -0
  123. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +103 -0
  124. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1138 -0
  125. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1128 -0
  126. package/dist/builtins/storage-orchestrator/storage-orchestrator.service.d.ts +236 -0
  127. package/dist/builtins/storage-orchestrator/storage-pressure-manager.d.ts +38 -0
  128. package/dist/builtins/system-backup/system-backup.service.d.ts +137 -0
  129. package/dist/builtins/system-config/index.d.ts +1 -0
  130. package/dist/builtins/system-config/index.js +8 -0
  131. package/dist/builtins/system-config/index.mjs +2 -0
  132. package/dist/builtins/system-config/system-config.addon.d.ts +10 -0
  133. package/dist/builtins/system-config/system-config.addon.js +232 -0
  134. package/dist/builtins/system-config/system-config.addon.mjs +226 -0
  135. package/dist/builtins/winston-logging/index.d.ts +3 -0
  136. package/dist/builtins/winston-logging/index.js +156 -0
  137. package/dist/builtins/winston-logging/index.mjs +144 -0
  138. package/dist/builtins/winston-logging/winston-destination.d.ts +21 -0
  139. package/dist/builtins/winston-logging/winston-logging.addon.d.ts +19 -0
  140. package/dist/chunk-CNf5ZN-e.mjs +37 -0
  141. package/dist/chunk-Cek0wNdY.js +64 -0
  142. package/dist/download/model-download-service.d.ts +41 -0
  143. package/dist/download/model-downloader.d.ts +31 -0
  144. package/dist/events/event-bus.d.ts +10 -0
  145. package/dist/events/system-event-bus.d.ts +14 -0
  146. package/dist/feature/feature-manager.d.ts +11 -0
  147. package/dist/formatter-B7qW8bPJ.mjs +162 -0
  148. package/dist/formatter-DqAKDlvN.js +167 -0
  149. package/dist/http/authenticated-file-server.d.ts +53 -0
  150. package/dist/http/data-plane-registry.d.ts +23 -0
  151. package/dist/http/file-data-plane.d.ts +10 -0
  152. package/dist/http/reverse-proxy.d.ts +15 -0
  153. package/dist/index.d.ts +82 -0
  154. package/dist/index.js +93485 -0
  155. package/dist/index.mjs +93179 -0
  156. package/dist/intel-accelerators-Gg0P5mnl.js +20 -0
  157. package/dist/intel-accelerators-hGgpZ0pX.mjs +19 -0
  158. package/dist/kernel/addon-class-resolver.d.ts +4 -0
  159. package/dist/kernel/addon-engine-manager.d.ts +22 -0
  160. package/dist/kernel/addon-health-monitor.d.ts +154 -0
  161. package/dist/kernel/addon-installer.d.ts +208 -0
  162. package/dist/kernel/addon-loader.d.ts +106 -0
  163. package/dist/kernel/addon-manifest.d.ts +77 -0
  164. package/dist/kernel/capability-handle.d.ts +46 -0
  165. package/dist/kernel/capability-registry.d.ts +412 -0
  166. package/dist/kernel/config-manager.d.ts +212 -0
  167. package/dist/kernel/config-schema.d.ts +93 -0
  168. package/dist/kernel/custom-action-registry.d.ts +23 -0
  169. package/dist/kernel/deps/addon-deps-manager.d.ts +19 -0
  170. package/dist/kernel/deps/manifest-native-deps.d.ts +25 -0
  171. package/dist/kernel/deps/manifest-python-deps.d.ts +20 -0
  172. package/dist/kernel/device-registry.d.ts +29 -0
  173. package/dist/kernel/fs-utils.d.ts +41 -0
  174. package/dist/kernel/hwaccel/hwaccel-resolver.d.ts +19 -0
  175. package/dist/kernel/hwaccel/hwaccel-service.d.ts +4 -0
  176. package/dist/kernel/index.d.ts +74 -0
  177. package/dist/kernel/infra-capabilities.d.ts +13 -0
  178. package/dist/kernel/moleculer/addon-context-factory.d.ts +91 -0
  179. package/dist/kernel/moleculer/addon-data-plane-facility.d.ts +19 -0
  180. package/dist/kernel/moleculer/addon-runner.d.ts +1 -0
  181. package/dist/kernel/moleculer/addon-service-factory.d.ts +50 -0
  182. package/dist/kernel/moleculer/broker-factory.d.ts +50 -0
  183. package/dist/kernel/moleculer/cap-usage-registry.d.ts +46 -0
  184. package/dist/kernel/moleculer/capabilities-access.d.ts +21 -0
  185. package/dist/kernel/moleculer/child-addon-call-dispatch.d.ts +46 -0
  186. package/dist/kernel/moleculer/child-cap-dispatch.d.ts +20 -0
  187. package/dist/kernel/moleculer/cluster-secret.d.ts +15 -0
  188. package/dist/kernel/moleculer/core-cap-service.d.ts +50 -0
  189. package/dist/kernel/moleculer/crash-supervisor.d.ts +50 -0
  190. package/dist/kernel/moleculer/device-cap-proxy.d.ts +79 -0
  191. package/dist/kernel/moleculer/event-bus-core.d.ts +53 -0
  192. package/dist/kernel/moleculer/event-bus.d.ts +53 -0
  193. package/dist/kernel/moleculer/hub-log-forwarder.d.ts +36 -0
  194. package/dist/kernel/moleculer/hub-service.d.ts +35 -0
  195. package/dist/kernel/moleculer/node-registry.d.ts +126 -0
  196. package/dist/kernel/moleculer/process-context.d.ts +4 -0
  197. package/dist/kernel/moleculer/process-service.d.ts +72 -0
  198. package/dist/kernel/moleculer/provider-registry.d.ts +28 -0
  199. package/dist/kernel/moleculer/readiness-context.d.ts +62 -0
  200. package/dist/kernel/moleculer/readiness-service.d.ts +7 -0
  201. package/dist/kernel/moleculer/register-node-client.d.ts +35 -0
  202. package/dist/kernel/moleculer/remote-logger.d.ts +43 -0
  203. package/dist/kernel/moleculer/resilient-cap-call.d.ts +28 -0
  204. package/dist/kernel/moleculer/stream-probe-service.d.ts +9 -0
  205. package/dist/kernel/moleculer/trpc-links.d.ts +189 -0
  206. package/dist/kernel/moleculer/typed-array-serde.d.ts +25 -0
  207. package/dist/kernel/moleculer/worker-device-restore.d.ts +10 -0
  208. package/dist/kernel/provider-kind-drift.d.ts +12 -0
  209. package/dist/kernel/restart-coordinator.d.ts +90 -0
  210. package/dist/kernel/storage-location-registry.d.ts +40 -0
  211. package/dist/kernel/transport/cap-action-name.d.ts +100 -0
  212. package/dist/kernel/transport/cap-route-resolver.d.ts +148 -0
  213. package/dist/kernel/transport/cap-route.d.ts +148 -0
  214. package/dist/kernel/transport/child-cap-protocol.d.ts +136 -0
  215. package/dist/kernel/transport/create-local-transport.d.ts +7 -0
  216. package/dist/kernel/transport/frame-codec.d.ts +7 -0
  217. package/dist/kernel/transport/index.d.ts +27 -0
  218. package/dist/kernel/transport/local-child-client.d.ts +136 -0
  219. package/dist/kernel/transport/local-child-registry.d.ts +179 -0
  220. package/dist/kernel/transport/local-endpoint-path.d.ts +6 -0
  221. package/dist/kernel/transport/local-transport.d.ts +46 -0
  222. package/dist/kernel/transport/parent-unowned-call.d.ts +75 -0
  223. package/dist/kernel/transport/socket-channel.d.ts +27 -0
  224. package/dist/kernel/transport/uds-event-bridge.d.ts +36 -0
  225. package/dist/kernel/transport/uds-event-bus.d.ts +22 -0
  226. package/dist/kernel/transport/uds-local-transport.d.ts +18 -0
  227. package/dist/kernel/transport/uds-log-ingest.d.ts +28 -0
  228. package/dist/kernel/transport/uds-logger.d.ts +44 -0
  229. package/dist/kernel/utils/ring-buffer.d.ts +15 -0
  230. package/dist/kernel/workspace-detect.d.ts +9 -0
  231. package/dist/lifecycle/lifecycle-state-machine.d.ts +28 -0
  232. package/dist/logging/formatter.d.ts +30 -0
  233. package/dist/logging/log-manager.d.ts +54 -0
  234. package/dist/logging/log-ring-buffer.d.ts +47 -0
  235. package/dist/logging/partitioned-log-buffer.d.ts +35 -0
  236. package/dist/logging/scoped-logger.d.ts +17 -0
  237. package/dist/main-DNnMW7Z2.js +9983 -0
  238. package/dist/main-rtjOwPBR.mjs +9976 -0
  239. package/dist/manifest-python-deps-D1DbAQEv.js +6724 -0
  240. package/dist/manifest-python-deps-DZsKTbs1.mjs +6315 -0
  241. package/dist/network/network-quality.d.ts +11 -0
  242. package/dist/notification/notification-service.d.ts +37 -0
  243. package/dist/notification/toast-service.d.ts +22 -0
  244. package/dist/pipeline/engine-manager-resolver.d.ts +15 -0
  245. package/dist/pipeline/pipeline-runner.d.ts +8 -0
  246. package/dist/pipeline/pipeline-validator.d.ts +13 -0
  247. package/dist/process/resource-monitor.d.ts +11 -0
  248. package/dist/python/python-env-manager.d.ts +12 -0
  249. package/dist/repl/interfaces.d.ts +31 -0
  250. package/dist/repl/repl-engine.d.ts +8 -0
  251. package/dist/resource-monitor-ClDGFyf6.mjs +57 -0
  252. package/dist/resource-monitor-IIEanuJt.js +74 -0
  253. package/dist/settle-sources-Bhsy57y-.js +38 -0
  254. package/dist/settle-sources-CDtNC8ub.mjs +33 -0
  255. package/dist/storage/fs-storage-backend.d.ts +40 -0
  256. package/dist/storage/storage-location-manager.d.ts +23 -0
  257. package/dist/storage/storage-manager.d.ts +83 -0
  258. package/dist/tar-BgAEMRBR.js +5434 -0
  259. package/dist/tar-ByMOPNM0.mjs +5429 -0
  260. package/dist/tls/cert-manager.d.ts +26 -0
  261. package/dist/tls/index.d.ts +1 -0
  262. package/package.json +343 -0
@@ -0,0 +1,3292 @@
1
+ import { canonicalDeviceFingerprint } from "@camstack/types/node";
2
+ import { BaseAddon, CAP_NAMES_WITH_STATUS, DeviceFeature, DeviceRole, DeviceStatusSchema, DeviceType, EventCategory, STREAM_PROFILE_META, WELL_KNOWN_TAB_MAP, applyTransform, buildStreamParamsConfigSchema, deviceManagerCapability, deviceStateCapability, deviceStatusCapability, enumerateSchemaFields, errMsg, getByPath, isDeviceConfigCap, parseStreamParamsFormPatch, setByPath, sleep } from "@camstack/types";
3
+ import { randomUUID } from "node:crypto";
4
+ import { z } from "zod";
5
+ //#region src/builtins/device-manager/device-event-propagator.ts
6
+ /**
7
+ * Walks the parent chain for every device-sourced event and re-emits a
8
+ * copy on each ancestor scope with `via[]` populated.
9
+ *
10
+ * Design goals:
11
+ * - Transparent: drivers emit once on their own device scope; the
12
+ * framework handles fan-out. Zero provider boilerplate.
13
+ * - Anti-loop: events that already carry `via[]` are skipped (we only
14
+ * propagate ORIGINAL emissions).
15
+ * - Anti-cycle: the parent chain is bounded — if the device registry
16
+ * is corrupt and has a cycle, the walker caps at `MAX_CHAIN_DEPTH`
17
+ * and logs a warning.
18
+ * - Lazy: parent chain is resolved on-demand per event (no cached
19
+ * topology). The lookup is O(depth) which is ≤2 in practice.
20
+ *
21
+ * `via` contract (from SystemEvent.via JSDoc):
22
+ * - `via[0]` is the originating source (the device that produced the
23
+ * event). Subsequent entries walk up the parent chain.
24
+ * - On the re-emission, `source` is the ancestor at that level and
25
+ * `via[0..i]` is the prefix of the chain up to and including the
26
+ * first N ancestors below the current one.
27
+ *
28
+ * Example (grandchild → parent → grandparent):
29
+ * Original: { source: {id: 7}, data: {...}, via: undefined }
30
+ * Re-emit 1: { source: {id: 4}, data: {...}, via: [{id: 7}] }
31
+ * Re-emit 2: { source: {id: 1}, data: {...}, via: [{id: 7}, {id: 4}] }
32
+ *
33
+ * A consumer listening at `source.id === 1` receives re-emit 2 (with
34
+ * `via` showing the chain). A consumer listening at `source.id === 7`
35
+ * with `via === undefined` receives the original only.
36
+ */
37
+ /** Bounded walk — paranoia against corrupt device registries with cycles. */
38
+ var MAX_CHAIN_DEPTH = 16;
39
+ var DeviceEventPropagator = class {
40
+ opts;
41
+ unsubscribe = null;
42
+ constructor(opts) {
43
+ this.opts = opts;
44
+ }
45
+ start() {
46
+ if (this.unsubscribe) return;
47
+ const unsub = this.opts.eventBus.subscribe({}, (ev) => this.handle(ev));
48
+ this.unsubscribe = unsub;
49
+ }
50
+ stop() {
51
+ if (!this.unsubscribe) return;
52
+ this.unsubscribe();
53
+ this.unsubscribe = null;
54
+ }
55
+ /** Exposed for tests — lets them inject events without the full bus. */
56
+ handle(ev) {
57
+ if (ev.via !== void 0) return;
58
+ if (ev.source.type !== "device") return;
59
+ const rawId = ev.source.id;
60
+ const deviceId = typeof rawId === "number" ? rawId : Number(rawId);
61
+ if (!Number.isFinite(deviceId)) return;
62
+ const chain = this.resolveParentChain(deviceId);
63
+ if (chain.length === 0) return;
64
+ const via = [ev.source];
65
+ for (const ancestorId of chain) {
66
+ const reEmission = {
67
+ ...ev,
68
+ source: {
69
+ type: "device",
70
+ id: ancestorId
71
+ },
72
+ via: [...via]
73
+ };
74
+ this.opts.eventBus.emit(reEmission);
75
+ via.push({
76
+ type: "device",
77
+ id: ancestorId
78
+ });
79
+ }
80
+ }
81
+ resolveParentChain(deviceId) {
82
+ const chain = [];
83
+ const seen = new Set([deviceId]);
84
+ let current = this.opts.getParentOf(deviceId);
85
+ while (current != null) {
86
+ if (seen.has(current)) {
87
+ this.opts.logger.warn("device-event-propagator: cycle detected in parent chain — aborting propagation", {
88
+ tags: { deviceId },
89
+ meta: {
90
+ cycleAt: current,
91
+ chainSoFar: [...chain]
92
+ }
93
+ });
94
+ return chain;
95
+ }
96
+ seen.add(current);
97
+ chain.push(current);
98
+ if (chain.length >= MAX_CHAIN_DEPTH) {
99
+ this.opts.logger.warn("device-event-propagator: chain depth limit hit — truncating", {
100
+ tags: { deviceId },
101
+ meta: {
102
+ depth: chain.length,
103
+ max: MAX_CHAIN_DEPTH
104
+ }
105
+ });
106
+ break;
107
+ }
108
+ current = this.opts.getParentOf(current);
109
+ }
110
+ return chain;
111
+ }
112
+ };
113
+ /**
114
+ * Build the device-detail form section for a `derived-form` device-config
115
+ * cap. Returns null when the camera exposes no configurable property.
116
+ */
117
+ function deriveFormContribution(builderId, options, status) {
118
+ if (builderId !== "stream-params") throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
119
+ const schema = buildStreamParamsConfigSchema(options, status ?? null);
120
+ if (!schema) return null;
121
+ return { sections: schema.sections.map((s) => ({
122
+ id: s.id,
123
+ title: s.title,
124
+ ...s.tab !== void 0 ? { tab: s.tab } : {},
125
+ ...s.order !== void 0 ? { order: s.order } : {},
126
+ ...s.description !== void 0 ? { description: s.description } : {},
127
+ ...s.columns !== void 0 ? { columns: s.columns } : {},
128
+ fields: [...s.fields]
129
+ })) };
130
+ }
131
+ /**
132
+ * Route a flat form patch back through the cap's per-profile setter.
133
+ */
134
+ async function applyDerivedFormPatch(builderId, patch, setProfile) {
135
+ if (builderId !== "stream-params") throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
136
+ for (const meta of STREAM_PROFILE_META) {
137
+ const profilePatch = parseStreamParamsFormPatch(patch, meta.prefix);
138
+ if (profilePatch) await setProfile(meta.profile, profilePatch);
139
+ }
140
+ }
141
+ //#endregion
142
+ //#region src/builtins/device-manager/device-link-resolver.ts
143
+ /** Returns true when `x` is a non-null, non-array plain object. */
144
+ function isRecord$1(x) {
145
+ return x !== null && typeof x === "object" && !Array.isArray(x);
146
+ }
147
+ /** Narrow Zod v4's structural `$ZodType` (returned by `.unwrap()`) back to the
148
+ * concrete classic `z.ZodType`. Every runtime schema is a `z.ZodType`, so this
149
+ * is a true `instanceof` guard rather than a cast. */
150
+ function asZodType(schema) {
151
+ return schema instanceof z.ZodType ? schema : null;
152
+ }
153
+ /** Unwrap ZodNullable / ZodOptional / ZodDefault wrappers to reach the inner
154
+ * type. This lets the repair logic recognise a `TankStatus.nullable()` field
155
+ * as a ZodObject so it can fill in missing nullable keys. */
156
+ function unwrapSchema(schema) {
157
+ if (schema instanceof z.ZodNullable || schema instanceof z.ZodOptional || schema instanceof z.ZodDefault) {
158
+ const inner = asZodType(schema.unwrap());
159
+ return inner ? unwrapSchema(inner) : schema;
160
+ }
161
+ return schema;
162
+ }
163
+ /** For a ZodObject schema, ensure every nullable key present under `value` is
164
+ * filled — a missing key whose field accepts `null` is set to `null`. Generic
165
+ * repair for the common "set one leaf of a previously-null structured field"
166
+ * case (e.g. TankStatus.level). Recurses into nested object fields. */
167
+ function fillNullableDefaults(schema, value) {
168
+ const inner = unwrapSchema(schema);
169
+ if (!(inner instanceof z.ZodObject) || !isRecord$1(value)) return value;
170
+ const shape = inner.shape;
171
+ const out = { ...value };
172
+ for (const [key, field] of Object.entries(shape)) if (out[key] === void 0) {
173
+ if (field.safeParse(null).success) out[key] = null;
174
+ } else out[key] = fillNullableDefaults(field, out[key]);
175
+ return out;
176
+ }
177
+ /**
178
+ * Overlay transformed source values onto `base` by dot-path, then validate the
179
+ * result against the target cap's `statusSchema`. On validation failure the
180
+ * overlay is discarded and `base` is returned unchanged (a misconfigured link
181
+ * must never corrupt a cap response). Pure — all I/O happens in the caller.
182
+ */
183
+ function mergeLinkedStatus(base, resolved, statusSchema) {
184
+ let draft = base;
185
+ let touched = false;
186
+ for (const { link, sourceValue } of resolved) {
187
+ if (sourceValue === void 0) continue;
188
+ draft = setByPath(draft, link.target.fieldPath, sourceValue);
189
+ touched = true;
190
+ }
191
+ if (!touched) return base;
192
+ if (!statusSchema) return draft;
193
+ const repaired = fillNullableDefaults(statusSchema, draft);
194
+ const parsed = statusSchema.safeParse(repaired);
195
+ if (!parsed.success) return base;
196
+ return isRecord$1(parsed.data) ? parsed.data : base;
197
+ }
198
+ //#endregion
199
+ //#region src/builtins/device-manager/device-link-overlay.ts
200
+ /** Build both lookup maps from resolved link entries. Pure — order-preserving. */
201
+ function buildLinkIndexes(entries) {
202
+ const targets = /* @__PURE__ */ new Map();
203
+ const dependents = /* @__PURE__ */ new Map();
204
+ for (const { targetDeviceId, link, sourceDeviceId } of entries) {
205
+ const tKey = `${targetDeviceId}:${link.target.cap}`;
206
+ const tList = targets.get(tKey) ?? [];
207
+ tList.push({
208
+ link,
209
+ sourceDeviceId
210
+ });
211
+ targets.set(tKey, tList);
212
+ const sKey = `${sourceDeviceId}:${link.source.cap}`;
213
+ const sList = dependents.get(sKey) ?? [];
214
+ sList.push({
215
+ targetDeviceId,
216
+ targetCap: link.target.cap
217
+ });
218
+ dependents.set(sKey, sList);
219
+ }
220
+ return {
221
+ targets,
222
+ dependents
223
+ };
224
+ }
225
+ //#endregion
226
+ //#region src/builtins/device-manager/device-manager.addon.ts
227
+ /**
228
+ * Device Manager addon — hub-side singleton that unifies device persistence,
229
+ * live registry queries, and all management operations into a single
230
+ * tRPC-routable capability.
231
+ *
232
+ * Persistence strategy: all device data is stored via `ctx.settings`, the same
233
+ * settings API every other addon uses. No raw SQLite access.
234
+ *
235
+ * Addon store layout:
236
+ * deviceIndex → Record<addonId, stableId[]> (which devices exist per addon)
237
+ * deviceMeta → Record<"addonId:stableId", DeviceMeta> (type, name, parentDeviceId, id)
238
+ *
239
+ * Device store (per-device config):
240
+ * readDeviceStore(numericDeviceId) → config blob
241
+ * writeDeviceStore(numericDeviceId, patch)
242
+ *
243
+ * Live registry: resolved from the kernel capability registry after Phase 2.
244
+ * This gives direct access to in-memory IDevice instances registered by provider addons.
245
+ * The DeviceManagerAddon is the single owner of the live device operations API.
246
+ *
247
+ * Replaces:
248
+ * - `device-persistence` capability (absorbed here)
249
+ * - live operations previously served by `device-management.router.ts`
250
+ */
251
+ /**
252
+ * Return true when `err` is a transient Moleculer error that is worth
253
+ * retrying — specifically any `MoleculerRetryableError` subclass
254
+ * (ServiceNotAvailableError, ServiceNotFoundError, BrokerDisconnectedError,
255
+ * RequestTimeoutError, …). Moleculer sets `retryable: true` on all of them.
256
+ *
257
+ * Falls back to a message-substring check for serialised errors that arrive
258
+ * across the Moleculer transport as plain objects rather than real instances.
259
+ */
260
+ function isTransientMoleculerError(err) {
261
+ if (err !== null && typeof err === "object") {
262
+ const e = err;
263
+ if (e["retryable"] === true) return true;
264
+ const code = typeof e["code"] === "string" ? e["code"] : "";
265
+ if (code === "SERVICE_NOT_FOUND" || code === "SERVICE_NOT_AVAILABLE" || code === "REQUEST_TIMEOUT" || code === "BAD_GATEWAY") return true;
266
+ }
267
+ if (err instanceof Error) {
268
+ const msg = err.message;
269
+ if (msg.includes("is not available") || msg.includes("is not found") || msg.includes("transporter has disconnected") || msg.includes("Request timed out")) return true;
270
+ }
271
+ return false;
272
+ }
273
+ function shallowEqual(a, b) {
274
+ const ak = Object.keys(a);
275
+ const bk = Object.keys(b);
276
+ if (ak.length !== bk.length) return false;
277
+ for (const k of ak) if (a[k] !== b[k]) return false;
278
+ return true;
279
+ }
280
+ function deviceKey(addonId, stableId) {
281
+ return `${addonId}:${stableId}`;
282
+ }
283
+ /** Returns true when `x` is a non-null, non-array plain object. */
284
+ function isRecord(x) {
285
+ return x !== null && typeof x === "object" && !Array.isArray(x);
286
+ }
287
+ function isCameraDevice(device) {
288
+ return "getStreamSources" in device && typeof device.getStreamSources === "function";
289
+ }
290
+ var DEVICE_FEATURE_VALUES = new Set(Object.values(DeviceFeature));
291
+ /**
292
+ * Validate persisted feature strings against the `DeviceFeature` enum
293
+ * — workers serialise the live `device.features` array (so every entry
294
+ * is a valid enum value at write time) but the persisted blob is loose
295
+ * `string[]` on the wire. The narrow keeps unknown values out of the
296
+ * `getDevice` response without losing the enum-typed contract.
297
+ */
298
+ function persistedFeatures(features) {
299
+ if (!features) return [];
300
+ const out = [];
301
+ for (const f of features) if (DEVICE_FEATURE_VALUES.has(f)) out.push(f);
302
+ return out;
303
+ }
304
+ /**
305
+ * Build an identity-only `SourceInfo` from the persisted device config blob.
306
+ *
307
+ * Forked-worker accessory children (e.g. HA sensor entities) persist
308
+ * `entityId` and `system` in their config blob at spawn time. The hub has no
309
+ * live `IDevice` instance for these devices, so the persisted-fallback paths
310
+ * in `listAll` / `getDevice` / `getChildren` must reconstruct the identity
311
+ * `SourceInfo` from the config so dispatch routing keeps working.
312
+ *
313
+ * Rendering metadata (unit, precision) flows live through the cap STATUS SLICE
314
+ * and must NOT be derived here. Only `id` + `system` (+ `uniqueId` when
315
+ * present) are projected — purely identity, never rendering hints.
316
+ *
317
+ * Returns `undefined` when no identity anchor is resolvable (pure identity
318
+ * devices like cameras/hubs that don't carry `entityId`/`system` in their
319
+ * config blob) — the hub synthetic fallback applies in that case.
320
+ */
321
+ function buildSourceInfoFromConfig(persistedConfig, stableId, addonId) {
322
+ const id = typeof persistedConfig["entityId"] === "string" ? persistedConfig["entityId"] : void 0;
323
+ const system = typeof persistedConfig["system"] === "string" ? persistedConfig["system"] : void 0;
324
+ if (id === void 0 && system === void 0) return void 0;
325
+ const uniqueId = typeof persistedConfig["uniqueId"] === "string" ? persistedConfig["uniqueId"] : void 0;
326
+ return {
327
+ id: id ?? stableId,
328
+ system: system ?? addonId,
329
+ ...uniqueId !== void 0 ? { uniqueId } : {}
330
+ };
331
+ }
332
+ var DEVICE_ROLE_VALUES = new Set(Object.values(DeviceRole));
333
+ /** Type guard: a string is a known `DeviceRole` enum member. */
334
+ function isDeviceRole(value) {
335
+ return DEVICE_ROLE_VALUES.has(value);
336
+ }
337
+ /** Narrow a persisted role string (sqlite TEXT column) to a `DeviceRole`.
338
+ * Unknown / null values resolve to `null` so a stale or unrecognised role
339
+ * never leaks an off-enum string onto the wire shape. */
340
+ function toDeviceRole(value) {
341
+ return value != null && isDeviceRole(value) ? value : null;
342
+ }
343
+ function toDeviceInfo(addonId, device, metadata = null, metaRow = null) {
344
+ const configValues = {};
345
+ for (const entry of device.config.entries()) configValues[entry.key] = entry.value;
346
+ const name = metaRow?.name ?? device.name;
347
+ const location = metaRow?.location !== void 0 ? metaRow.location : device.location;
348
+ const disabled = metaRow?.disabled ?? device.disabled;
349
+ const probeSlice = device.runtimeState?.getCapState("feature-probe");
350
+ const probed = probeSlice === void 0 ? true : (probeSlice.lastProbedAt ?? 0) > 0;
351
+ return {
352
+ id: device.id,
353
+ stableId: device.stableId,
354
+ addonId,
355
+ type: device.type,
356
+ name,
357
+ location,
358
+ disabled,
359
+ parentDeviceId: device.parentDeviceId,
360
+ role: device.role ?? null,
361
+ online: device.online,
362
+ probed,
363
+ features: device.features.length > 0 ? [...device.features] : persistedFeatures(metaRow?.features),
364
+ isCamera: isCameraDevice(device),
365
+ config: configValues,
366
+ metadata,
367
+ ...metaRow?.integrationId !== void 0 ? { integrationId: metaRow.integrationId } : {},
368
+ ...metaRow?.linkDeviceId !== void 0 ? { linkDeviceId: metaRow.linkDeviceId } : {},
369
+ ...metaRow?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: metaRow.primaryChildEntityId } : {},
370
+ ...metaRow?.childLayout !== void 0 ? { childLayout: metaRow.childLayout } : {},
371
+ ...metaRow?.deviceLinks !== void 0 ? { deviceLinks: metaRow.deviceLinks } : {}
372
+ };
373
+ }
374
+ function resolveDeviceById(registry, deviceId) {
375
+ const device = registry.getById(deviceId);
376
+ if (!device) return null;
377
+ const addonId = registry.getAddonId(deviceId);
378
+ if (!addonId) return null;
379
+ return {
380
+ addonId,
381
+ device
382
+ };
383
+ }
384
+ /**
385
+ * Walk the sections/fields of a contribution and inject `writerCapName` +
386
+ * `writerAddonId` + `source` on each editable field. Readonly fields and
387
+ * structural fields (separator/info/button) pass through untouched. The
388
+ * aggregator is the single place that knows provenance — provider schemas
389
+ * stay clean, UI-bound metadata is attached once at the boundary.
390
+ */
391
+ function tagContribution(contribution, capName, addonId, kind) {
392
+ const source = kind === "settings" ? "settings" : "live";
393
+ return {
394
+ ...contribution.tabs ? { tabs: [...contribution.tabs] } : {},
395
+ sections: contribution.sections.map((section) => ({
396
+ ...section,
397
+ fields: section.fields.map((field) => tagField(field, capName, addonId, source, kind))
398
+ }))
399
+ };
400
+ }
401
+ function isFieldRecord(value) {
402
+ return value !== null && typeof value === "object" && !Array.isArray(value);
403
+ }
404
+ /**
405
+ * Convert a strict `ConfigUISchemaWithValues` (readonly arrays, typed
406
+ * field union) into the cap wire shape `ContributionShape` (mutable
407
+ * arrays, opaque field records). Required because the cap method z.infer
408
+ * uses mutable arrays — readonly arrays are not assignable to mutable
409
+ * even when structurally identical, so a structural copy bridges the gap
410
+ * without disabling the type checker.
411
+ */
412
+ function toWireShape(input) {
413
+ const out = { sections: input.sections.map((s) => ({
414
+ id: s.id,
415
+ title: s.title,
416
+ ...s.description !== void 0 ? { description: s.description } : {},
417
+ ...s.style !== void 0 ? { style: s.style } : {},
418
+ ...s.defaultCollapsed !== void 0 ? { defaultCollapsed: s.defaultCollapsed } : {},
419
+ ...s.columns !== void 0 ? { columns: s.columns } : {},
420
+ ...s.tab !== void 0 ? { tab: s.tab } : {},
421
+ ...s.location !== void 0 ? { location: s.location } : {},
422
+ ...s.order !== void 0 ? { order: s.order } : {},
423
+ fields: [...s.fields]
424
+ })) };
425
+ if (input.tabs) out.tabs = [...input.tabs];
426
+ return out;
427
+ }
428
+ function tagField(field, capName, addonId, source, kind) {
429
+ if (!isFieldRecord(field)) return field;
430
+ const f = field;
431
+ const structuralTypes = new Set([
432
+ "separator",
433
+ "info",
434
+ "button"
435
+ ]);
436
+ if (typeof f.type === "string" && structuralTypes.has(f.type)) return field;
437
+ const tagged = {
438
+ ...f,
439
+ source
440
+ };
441
+ if (kind === "live" || f.readonlyField === true) tagged.readonlyField = true;
442
+ else {
443
+ tagged.writerCapName = capName;
444
+ tagged.writerAddonId = addonId;
445
+ }
446
+ if (f.type === "group") {
447
+ const children = Array.isArray(f.fields) ? f.fields : [];
448
+ if (children.length > 0) tagged.fields = children.map((child) => tagField(child, capName, addonId, source, kind));
449
+ } else if (f.type === "sub-tabs") {
450
+ const rawTabs = Array.isArray(f.tabs) ? f.tabs : [];
451
+ if (rawTabs.length > 0) tagged.tabs = rawTabs.map((tab) => {
452
+ if (!isFieldRecord(tab)) return tab;
453
+ const tabChildren = Array.isArray(tab.fields) ? tab.fields : [];
454
+ return {
455
+ ...tab,
456
+ fields: tabChildren.map((child) => tagField(child, capName, addonId, source, kind))
457
+ };
458
+ });
459
+ }
460
+ return tagged;
461
+ }
462
+ function mergeAggregates(parts) {
463
+ const tabDecls = /* @__PURE__ */ new Map();
464
+ const sections = [];
465
+ const seenSectionIds = /* @__PURE__ */ new Set();
466
+ for (const part of parts) {
467
+ if (part.tabs) {
468
+ for (const t of part.tabs) if (!tabDecls.has(t.id)) tabDecls.set(t.id, t);
469
+ }
470
+ for (const s of part.sections) {
471
+ if (s.id !== void 0) {
472
+ if (seenSectionIds.has(s.id)) continue;
473
+ seenSectionIds.add(s.id);
474
+ }
475
+ sections.push(s);
476
+ }
477
+ }
478
+ for (const s of sections) {
479
+ const tabId = s.tab ?? "general";
480
+ if (tabDecls.has(tabId)) continue;
481
+ const known = WELL_KNOWN_TAB_MAP[tabId];
482
+ if (known) tabDecls.set(tabId, {
483
+ id: known.id,
484
+ label: known.label,
485
+ icon: known.icon,
486
+ order: known.order
487
+ });
488
+ else tabDecls.set(tabId, {
489
+ id: tabId,
490
+ label: tabId,
491
+ icon: "wrench",
492
+ order: 100
493
+ });
494
+ }
495
+ sections.sort((a, b) => {
496
+ const tabA = a.tab ?? "general";
497
+ const tabB = b.tab ?? "general";
498
+ if (tabA !== tabB) {
499
+ const orderA = tabDecls.get(tabA)?.order ?? 100;
500
+ const orderB = tabDecls.get(tabB)?.order ?? 100;
501
+ if (orderA !== orderB) return orderA - orderB;
502
+ return tabA.localeCompare(tabB);
503
+ }
504
+ return (a.order ?? 0) - (b.order ?? 0);
505
+ });
506
+ const sortedTabs = [...tabDecls.values()].toSorted((a, b) => (a.order ?? 100) - (b.order ?? 100));
507
+ const out = { sections };
508
+ if (sortedTabs.length > 0) out.tabs = sortedTabs;
509
+ return out;
510
+ }
511
+ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
512
+ constructor() {
513
+ super({});
514
+ }
515
+ /** Shorthand for the kernel-injected capability registry. */
516
+ get capabilityRegistry() {
517
+ return this.ctx.kernel.capabilityRegistry;
518
+ }
519
+ /**
520
+ * Parent-chain event propagator. Started in `onInitialize` once the
521
+ * hub's `deviceRegistry` is available; listens to every device-sourced
522
+ * event and re-emits a copy on each ancestor scope with `via[]`
523
+ * populated. Stopped in `onShutdown`.
524
+ */
525
+ propagator = null;
526
+ /**
527
+ * Hub-side mirror of every device's cap-keyed runtime state.
528
+ * Populated whenever any caller writes via `deviceState.setCapSlice`
529
+ * (the canonical cross-layer write entrypoint) and on first load
530
+ * via `loadRuntimeState`. Cross-process consumers reach the mirror
531
+ * through the `deviceState` cap router; per-cap event subscribers
532
+ * (e.g. `battery.onStatusChanged`) get the same data via
533
+ * cap-specific events still emitted by the owning device.
534
+ *
535
+ * Key: deviceId. Value: per-cap slice map. Empty by default —
536
+ * slices show up as `setCapSlice` calls trickle in.
537
+ */
538
+ stateMirror = /* @__PURE__ */ new Map();
539
+ /**
540
+ * Per-device disk-write debouncer for runtime-state. `setCapSlice`
541
+ * updates the in-memory mirror synchronously and emits the change
542
+ * event immediately, but the disk write is coalesced — frequent
543
+ * back-to-back writes (motion phase transitions, battery pushes,
544
+ * etc.) collapse to one `writeDeviceRuntimeState` per
545
+ * `RUNTIME_STATE_DEBOUNCE_MS` window. `flushRuntimeStateWrites`
546
+ * awaits any in-flight write + scheduled flush so shutdown is
547
+ * lossless.
548
+ */
549
+ runtimeStateDebounce = /* @__PURE__ */ new Map();
550
+ static RUNTIME_STATE_DEBOUNCE_MS = 1e3;
551
+ /**
552
+ * Cross-process native-provider cache: deviceId (numeric) → capName → { addonId, nodeId }.
553
+ * Kept in sync with `DeviceBindingsChanged` push events emitted by forked
554
+ * workers on `ctx.registerNativeCap` / device removal. Union'd into
555
+ * `getBindings` so hub-side consumers see every native cap regardless of
556
+ * which process owns the IDevice.
557
+ *
558
+ * No persistence — entries re-populate when the worker re-handshakes or
559
+ * re-emits its native-cap registrations after restart. Entries that were
560
+ * lost in the Moleculer transport handshake window are recovered lazily:
561
+ * `resolveNativeCapOwnerSync` and `getBindings` fall through to
562
+ * `ctx.kernel.listClusterNativeCaps()` (the handshake-fed
563
+ * `HubNodeRegistry`) when the push-based cache misses.
564
+ *
565
+ * The previous pull-based recovery (`syncWorkerNativeCaps`, driven by
566
+ * `$node.connected` + `addon.restarted`) has been removed in Task 13 —
567
+ * the D3 re-handshake after device restore is the reliable replacement.
568
+ */
569
+ remoteNativeCaps = /* @__PURE__ */ new Map();
570
+ /** Device ids that currently have at least one `deviceLinks` entry. O(1) gate
571
+ * consulted by `resolveLinkedStatus` so the cap-mount overlay is a no-op for
572
+ * the vast majority of devices that have no links. Rebuilt on restore. */
573
+ devicesWithLinks = /* @__PURE__ */ new Set();
574
+ /** Whether a device currently has cross-device links. */
575
+ devicesWithLinksHas(deviceId) {
576
+ return this.devicesWithLinks.has(deviceId);
577
+ }
578
+ /** `${targetDeviceId}:${targetCap}` → resolved links feeding that cap's slice. */
579
+ linkTargets = /* @__PURE__ */ new Map();
580
+ /** `${sourceDeviceId}:${sourceCap}` → targets to recompute when that source changes. */
581
+ linkDependents = /* @__PURE__ */ new Map();
582
+ /** Loop/churn guard: last overlaid slice emitted per `${deviceId}:${cap}`. */
583
+ lastEmittedOverlay = /* @__PURE__ */ new Map();
584
+ /** Expected source `stableId`s (`${container}-${sourceKey}`) across all links,
585
+ * resolved or not — gates the `registerDevice` rebuild so only a registering
586
+ * device that IS a link source triggers a reindex (not every boot restore). */
587
+ expectedSourceStableIds = /* @__PURE__ */ new Set();
588
+ /** Test/diagnostic accessors. */
589
+ linkTargetKeys() {
590
+ return [...this.linkTargets.keys()];
591
+ }
592
+ linkDependentsKeys() {
593
+ return [...this.linkDependents.keys()];
594
+ }
595
+ /** Wait for a device-provider by addonId, returning null on timeout. */
596
+ async waitDeviceProvider(addonId, timeoutMs = 5e3) {
597
+ const provider = await this.capabilityRegistry?.waitForProvider("device-provider", addonId, timeoutMs);
598
+ return provider ? provider : null;
599
+ }
600
+ /** Require a device-provider by addonId — throws if not found. */
601
+ async requireDeviceProvider(addonId) {
602
+ const dp = await this.waitDeviceProvider(addonId);
603
+ if (!dp) throw new Error(`Device provider "${addonId}" not found or not registered`);
604
+ return dp;
605
+ }
606
+ /** Require a device-adoption provider by addonId — throws if not found. */
607
+ async requireDeviceAdoptionProvider(addonId) {
608
+ const provider = await this.capabilityRegistry?.waitForProvider("device-adoption", addonId, 5e3);
609
+ if (!provider) throw new Error(`Device-adoption provider "${addonId}" not found or not registered`);
610
+ return provider;
611
+ }
612
+ async readBindingsStore() {
613
+ return { deviceBindings: (await this.ctx.settings.readAddonStore()).deviceBindings ?? {} };
614
+ }
615
+ async writeBindingsStore(next) {
616
+ await this.ctx.settings.writeAddonStore({ deviceBindings: next.deviceBindings });
617
+ }
618
+ resolveWrapperNodeId(_wrapperAddonId) {
619
+ return "hub";
620
+ }
621
+ /**
622
+ * Reduce a provider node id to the routable form `DeviceProxy` can pin.
623
+ *
624
+ * Every addon runs in its own `addon-runner` with the composite node id
625
+ * `${parentNodeId}/${runnerId}` (see `addon-runner.ts`) — e.g.
626
+ * `hub/provider-reolink` or `dev-agent-0/provider-reolink`. That composite is
627
+ * an IDENTITY, not a routable target: the `CapRouteResolver` reaches a child
628
+ * only THROUGH its parent (the hub resolves a hub-local-uds child by
629
+ * cap+device; an agent forwards to its own child). `DeviceProxy` pins
630
+ * `entry.providerNodeId` on every cap call, so a binding entry must expose the
631
+ * parent node id — otherwise the explicit pin classifies as `remote-moleculer`
632
+ * to an unknown node → `no-provider`, which surfaces as
633
+ * "this camera doesn't expose …" for client-proxy-driven widget caps
634
+ * (motion-zones, privacy-mask). Wrappers already report the parent via
635
+ * `resolveWrapperNodeId`; this aligns natives with the same contract.
636
+ *
637
+ * A flat node id (a genuine standalone node with no `/`) is returned
638
+ * unchanged.
639
+ */
640
+ toRoutableProviderNodeId(nodeId) {
641
+ const slash = nodeId.indexOf("/");
642
+ return slash === -1 ? nodeId : nodeId.slice(0, slash);
643
+ }
644
+ /**
645
+ * Resolve a remote native cap entry for a given `(capName, deviceId)` by
646
+ * consulting the handshake-fed `HubNodeRegistry` via
647
+ * `ctx.kernel.listClusterNativeCaps()`. Called when the push-based
648
+ * `remoteNativeCaps` cache misses — covers the Moleculer transport
649
+ * handshake window where `DeviceBindingsChanged` events were lost but the
650
+ * D3 re-handshake (post device restore) has already populated the registry.
651
+ *
652
+ * Returns `null` when the entry is genuinely not present in the cluster
653
+ * view (cap not registered on any worker for that device).
654
+ */
655
+ resolveRemoteNativeCapFromRegistry(capName, deviceId) {
656
+ const clusterCaps = this.ctx.kernel.listClusterNativeCapsForDevice?.(deviceId) ?? this.ctx.kernel.listClusterNativeCaps?.();
657
+ if (!clusterCaps) return null;
658
+ for (const entry of clusterCaps) if (entry.capName === capName && entry.deviceId === deviceId && entry.addonId) return {
659
+ addonId: entry.addonId,
660
+ nodeId: entry.nodeId
661
+ };
662
+ return null;
663
+ }
664
+ async getBindings(input) {
665
+ const storeKey = String(input.deviceId);
666
+ const perDevice = (await this.readBindingsStore()).deviceBindings[storeKey] ?? {};
667
+ const entries = [];
668
+ const seenCaps = /* @__PURE__ */ new Set();
669
+ const resolveRemote = (capName) => this.remoteNativeCaps.get(input.deviceId)?.get(capName) ?? this.resolveRemoteNativeCapFromRegistry(capName, input.deviceId);
670
+ for (const [capName, { wrapperAddonId }] of Object.entries(perDevice)) {
671
+ const hubLocalNative = this.capabilityRegistry?.getNativeAddonId(capName, input.deviceId) ?? null;
672
+ const remoteNative = resolveRemote(capName);
673
+ const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
674
+ const nativeNodeId = hubLocalNative ? this.ctx.kernel.localNodeId ?? "hub" : remoteNative?.nodeId ?? this.ctx.kernel.localNodeId ?? "hub";
675
+ if (wrapperAddonId === null && !nativeAddonId) {
676
+ seenCaps.add(capName);
677
+ continue;
678
+ }
679
+ entries.push({
680
+ capName,
681
+ kind: wrapperAddonId ? "wrapped" : "native",
682
+ providerAddonId: wrapperAddonId ?? nativeAddonId,
683
+ providerNodeId: wrapperAddonId ? this.resolveWrapperNodeId(wrapperAddonId) : this.toRoutableProviderNodeId(nativeNodeId),
684
+ nativeAddonId
685
+ });
686
+ seenCaps.add(capName);
687
+ }
688
+ if (this.capabilityRegistry) for (const capName of this.capabilityRegistry.getCapsWithDefaultWrapper()) {
689
+ if (seenCaps.has(capName)) continue;
690
+ const defaultWrapperAddonId = this.capabilityRegistry.getDefaultWrapperForCap(capName);
691
+ if (!defaultWrapperAddonId) continue;
692
+ const hubLocalNative = this.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? null;
693
+ const remoteNative = resolveRemote(capName);
694
+ const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
695
+ entries.push({
696
+ capName,
697
+ kind: "wrapped",
698
+ providerAddonId: defaultWrapperAddonId,
699
+ providerNodeId: this.resolveWrapperNodeId(defaultWrapperAddonId),
700
+ nativeAddonId
701
+ });
702
+ seenCaps.add(capName);
703
+ }
704
+ if (this.capabilityRegistry) for (const capName of this.capabilityRegistry.getNativeCapsForDevice(input.deviceId)) {
705
+ if (seenCaps.has(capName)) continue;
706
+ const nativeAddonId = this.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? "";
707
+ entries.push({
708
+ capName,
709
+ kind: "native",
710
+ providerAddonId: nativeAddonId,
711
+ providerNodeId: this.ctx.kernel.localNodeId ?? "hub",
712
+ nativeAddonId
713
+ });
714
+ seenCaps.add(capName);
715
+ }
716
+ const pushFed = this.remoteNativeCaps.get(input.deviceId);
717
+ if (pushFed) for (const [capName, info] of pushFed) {
718
+ if (seenCaps.has(capName)) continue;
719
+ entries.push({
720
+ capName,
721
+ kind: "native",
722
+ providerAddonId: info.addonId,
723
+ providerNodeId: this.toRoutableProviderNodeId(info.nodeId),
724
+ nativeAddonId: info.addonId
725
+ });
726
+ seenCaps.add(capName);
727
+ }
728
+ const clusterCaps = this.ctx.kernel.listClusterNativeCapsForDevice?.(input.deviceId) ?? this.ctx.kernel.listClusterNativeCaps?.();
729
+ if (clusterCaps) for (const entry of clusterCaps) {
730
+ if (entry.deviceId !== input.deviceId) continue;
731
+ if (seenCaps.has(entry.capName)) continue;
732
+ if (!entry.addonId) continue;
733
+ const localNodeId = this.ctx.kernel.localNodeId ?? "hub";
734
+ if (entry.nodeId === localNodeId) continue;
735
+ entries.push({
736
+ capName: entry.capName,
737
+ kind: "native",
738
+ providerAddonId: entry.addonId,
739
+ providerNodeId: this.toRoutableProviderNodeId(entry.nodeId),
740
+ nativeAddonId: entry.addonId
741
+ });
742
+ seenCaps.add(entry.capName);
743
+ }
744
+ return {
745
+ deviceId: input.deviceId,
746
+ entries
747
+ };
748
+ }
749
+ /**
750
+ * Whole-fleet binding dump. Iterates every device known to the
751
+ * deviceRegistry and reuses the per-device `getBindings` resolver
752
+ * for each — same routing rules, single round-trip. Used by
753
+ * `SystemManager.init()` for warm-boot.
754
+ *
755
+ * Bindings change rarely (wrapper toggle, device add/remove) so
756
+ * clients invalidate via the existing
757
+ * `capability.binding-changed` event rather than re-fetching this
758
+ * payload periodically.
759
+ */
760
+ async getAllBindings() {
761
+ const hubRegistry = this.ctx.kernel?.deviceRegistry;
762
+ if (!hubRegistry) return [];
763
+ const out = [];
764
+ for (const device of hubRegistry.getAll()) out.push(await this.getBindings({ deviceId: device.id }));
765
+ return out;
766
+ }
767
+ /**
768
+ * Resolve a numeric deviceId to a stableId via persisted meta.
769
+ * Used only by the device-identity section of the device-details
770
+ * aggregator (see `buildBaseDeviceSection`) to surface the stableId as
771
+ * a readonly display field. All runtime/registry lookups are keyed by
772
+ * numeric deviceId; this helper is display-only.
773
+ */
774
+ async lookupPersistedStableId(deviceId) {
775
+ const meta = (await this.ctx.settings.readAddonStore()).deviceMeta ?? {};
776
+ for (const [key, m] of Object.entries(meta)) if (m.id === deviceId) {
777
+ const sep = key.indexOf(":");
778
+ if (sep < 0) continue;
779
+ return key.slice(sep + 1);
780
+ }
781
+ }
782
+ async getDeviceAggregate(deviceId, kind) {
783
+ const registry = this.capabilityRegistry;
784
+ if (!registry) {
785
+ this.ctx.logger.debug("capability registry unavailable — aggregate empty", { meta: { kind } });
786
+ return null;
787
+ }
788
+ const method = kind === "settings" ? "getDeviceSettingsContribution" : "getDeviceLiveContribution";
789
+ const { entries: bindingEntries } = await this.getBindings({ deviceId });
790
+ const seenCaps = new Set(bindingEntries.map((e) => e.capName));
791
+ const results = await Promise.all(bindingEntries.map(async (entry) => {
792
+ const def = registry.getDefinition(entry.capName);
793
+ if (isDeviceConfigCap(def)) return this.deriveDeviceConfigContribution(registry, def, entry, deviceId, kind);
794
+ if (!def?.exposesDeviceSettings) return null;
795
+ const provider = registry.getProviderByAddon(entry.capName, entry.providerAddonId);
796
+ if (!provider) return null;
797
+ try {
798
+ const contribution = await provider[method]({ deviceId });
799
+ if (!contribution) return null;
800
+ return tagContribution(toWireShape(contribution), entry.capName, entry.providerAddonId, kind);
801
+ } catch (err) {
802
+ const msg = err instanceof Error ? err.message : String(err);
803
+ this.ctx.logger.warn("contribution method failed", {
804
+ tags: {
805
+ deviceId,
806
+ addonId: entry.providerAddonId
807
+ },
808
+ meta: {
809
+ capName: entry.capName,
810
+ method,
811
+ error: msg
812
+ }
813
+ });
814
+ return null;
815
+ }
816
+ }));
817
+ const base = kind === "settings" ? await this.buildBaseDeviceSection(deviceId) : null;
818
+ const systemResults = await this.collectSystemDeviceContributions(deviceId, kind, seenCaps);
819
+ const parts = [
820
+ ...base ? [tagContribution(base, "device-manager", "device-manager", kind)] : [],
821
+ ...results.filter((r) => r !== null),
822
+ ...systemResults
823
+ ];
824
+ if (parts.length === 0) return null;
825
+ return mergeAggregates(parts);
826
+ }
827
+ /**
828
+ * System-scoped per-device contributions — the deliberate exception to
829
+ * binding-driven aggregation (D12).
830
+ *
831
+ * A `scope: 'system'` cap with `exposesDeviceSettings: true` holds per-device
832
+ * state but is NOT bound per-device: it is a system addon, not a BaseDevice,
833
+ * so it never calls `registerNativeCap` and never appears in
834
+ * `getBindings(deviceId)`. The binding-driven loop therefore skips it and its
835
+ * per-device panel never renders. Two such caps ship today:
836
+ * - `stream-broker` (singleton) — the RTSP-restream / pre-buffer settings
837
+ * tab, scoped to the cameras whose streams it serves.
838
+ * - `device-export` (collection) — the "Export" panel; a device can be
839
+ * exposed to MULTIPLE exporters at once (Alexa + HomeKit + HA-MQTT).
840
+ *
841
+ * D12 bans fanning a per-device call out to every same-named provider of a
842
+ * `scope: 'device'` cap, because vendor providers SHARE cap names
843
+ * (`stream-params`/`ptz` from reolink AND hikvision). System caps are NOT
844
+ * vendor-shared — a system cap name maps to one active provider (singleton)
845
+ * or one declared collection — so consulting the cap's own provider(s)
846
+ * directly is safe. Each provider gates internally by deviceId and returns
847
+ * null for devices it does not own.
848
+ *
849
+ * `seenCaps` skips any system cap that already contributed via a binding so
850
+ * its section is never duplicated.
851
+ */
852
+ async collectSystemDeviceContributions(deviceId, kind, seenCaps) {
853
+ const registry = this.capabilityRegistry;
854
+ if (!registry) return [];
855
+ const method = kind === "settings" ? "getDeviceSettingsContribution" : "getDeviceLiveContribution";
856
+ const tasks = [];
857
+ for (const info of registry.listCapabilities()) {
858
+ const def = registry.getDefinition(info.name);
859
+ if (!def || def.scope !== "system" || !def.exposesDeviceSettings) continue;
860
+ if (seenCaps.has(info.name)) continue;
861
+ const addonIds = def.mode === "collection" ? info.providers : info.activeProvider ? [info.activeProvider] : [];
862
+ for (const addonId of addonIds) tasks.push((async () => {
863
+ const provider = registry.getProviderByAddon(info.name, addonId);
864
+ if (!provider) return null;
865
+ try {
866
+ const contribution = await provider[method]({ deviceId });
867
+ if (!contribution) return null;
868
+ return tagContribution(toWireShape(contribution), info.name, addonId, kind);
869
+ } catch (err) {
870
+ this.ctx.logger.warn("system device-settings contribution skipped", {
871
+ tags: {
872
+ deviceId,
873
+ addonId
874
+ },
875
+ meta: {
876
+ capName: info.name,
877
+ method,
878
+ error: err instanceof Error ? err.message : String(err)
879
+ }
880
+ });
881
+ return null;
882
+ }
883
+ })());
884
+ }
885
+ return (await Promise.all(tasks)).filter((r) => r !== null);
886
+ }
887
+ /**
888
+ * D14: framework-derived device-config contribution.
889
+ *
890
+ * `kind === 'live'` — device-config caps contribute nothing to the live
891
+ * aggregate (they hold editable config, not live observables).
892
+ *
893
+ * `ui.kind === 'widget'` — emits a single structural `type:'widget'`
894
+ * section; the widget self-persists via the cap's own mutations.
895
+ *
896
+ * `ui.kind === 'derived-form'` — calls `getOptions`/`getStatus` on the
897
+ * bound provider, runs the registered pure builder, and returns the
898
+ * derived form sections. Returns null when the camera exposes nothing
899
+ * configurable or the provider is not yet registered.
900
+ */
901
+ async deriveDeviceConfigContribution(registry, def, entry, deviceId, kind) {
902
+ if (kind === "live") return null;
903
+ const ui = def.deviceConfig.ui;
904
+ if (ui.kind === "widget") return tagContribution({ sections: [{
905
+ id: `${entry.capName}-widget`,
906
+ title: ui.label,
907
+ tab: ui.tab,
908
+ order: ui.order ?? 0,
909
+ ...ui.topTab ? { location: "top-tab" } : {},
910
+ fields: [{
911
+ type: "widget",
912
+ key: `${entry.capName}Widget`,
913
+ label: ui.label,
914
+ widgetId: ui.widgetId
915
+ }]
916
+ }] }, entry.capName, entry.providerAddonId, kind);
917
+ const provider = registry.getProviderForDevice(entry.capName, deviceId);
918
+ if (!provider || typeof provider.getOptions !== "function") return null;
919
+ try {
920
+ const options = await provider.getOptions({ deviceId });
921
+ const status = typeof provider.getStatus === "function" ? await provider.getStatus({ deviceId }) : null;
922
+ const derived = deriveFormContribution(ui.builderId, options, status ?? null);
923
+ if (!derived) return null;
924
+ return tagContribution({
925
+ ...derived.tabs ? { tabs: derived.tabs.map((t) => ({ ...t })) } : {},
926
+ sections: derived.sections.map((s) => ({
927
+ id: s.id,
928
+ title: s.title,
929
+ ...s.tab !== void 0 ? { tab: s.tab } : {},
930
+ ...s.order !== void 0 ? { order: s.order } : {},
931
+ ...s.description !== void 0 ? { description: s.description } : {},
932
+ ...s.columns !== void 0 ? { columns: s.columns } : {},
933
+ fields: [...s.fields]
934
+ }))
935
+ }, entry.capName, entry.providerAddonId, kind);
936
+ } catch (err) {
937
+ const msg = err instanceof Error ? err.message : String(err);
938
+ this.ctx.logger.warn("device-config derivation failed", {
939
+ tags: {
940
+ deviceId,
941
+ addonId: entry.providerAddonId
942
+ },
943
+ meta: {
944
+ capName: entry.capName,
945
+ error: msg
946
+ }
947
+ });
948
+ return null;
949
+ }
950
+ }
951
+ /**
952
+ * Build the device-manager's own contribution to the aggregator — the
953
+ * device identity (id, stableId, addonId, type, online) + the
954
+ * driver-specific config exposed by the device class via
955
+ * `zodEntriesToConfigUI`.
956
+ *
957
+ * Two paths, deliberately symmetric with `getSettingsSchema`:
958
+ *
959
+ * - Hub-local: device's IDevice instance lives in this process'
960
+ * DeviceRegistry, we read config + schema directly by reference.
961
+ * - Cross-process: device lives in a forked worker (RtspCamera on
962
+ * provider-rtsp, ONVIF on provider-onvif, …). We ask the worker's
963
+ * `device-ops.getSettingsSchema` native provider for a wire-
964
+ * serializable ConfigUISchema and merge it in under the same
965
+ * "Driver Config" section, so the UI sees the same shape regardless
966
+ * of where the IDevice physically runs.
967
+ *
968
+ * Returns `null` only when the device genuinely doesn't exist anywhere
969
+ * (no hub-local, no persisted ownership, no device-ops native). The
970
+ * aggregator falls back to contributor sections only in that case.
971
+ */
972
+ async buildBaseDeviceSection(deviceId) {
973
+ const hubRegistry = this.ctx.kernel?.deviceRegistry;
974
+ const hubLocal = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
975
+ const stableId = hubLocal?.device.stableId ?? await this.lookupPersistedStableId(deviceId);
976
+ const nativeOwner = this.resolveNativeDeviceOwner(deviceId);
977
+ const addonId = hubLocal?.addonId ?? nativeOwner?.addonId ?? null;
978
+ if (!hubLocal && !nativeOwner) return null;
979
+ const sections = [{
980
+ id: "device-identity",
981
+ title: "Identity",
982
+ tab: "general",
983
+ order: 0,
984
+ fields: [
985
+ {
986
+ type: "text",
987
+ key: "_deviceId",
988
+ label: "Device ID",
989
+ readonlyField: true,
990
+ value: String(deviceId)
991
+ },
992
+ {
993
+ type: "text",
994
+ key: "_stableId",
995
+ label: "Stable ID",
996
+ readonlyField: true,
997
+ value: stableId ?? ""
998
+ },
999
+ {
1000
+ type: "text",
1001
+ key: "_addonId",
1002
+ label: "Driver",
1003
+ readonlyField: true,
1004
+ value: addonId ?? "unknown"
1005
+ },
1006
+ ...hubLocal ? [{
1007
+ type: "text",
1008
+ key: "_type",
1009
+ label: "Type",
1010
+ readonlyField: true,
1011
+ value: hubLocal.device.type
1012
+ }, {
1013
+ type: "text",
1014
+ key: "_online",
1015
+ label: "Online",
1016
+ readonlyField: true,
1017
+ value: hubLocal.device.online ? "yes" : "no"
1018
+ }] : []
1019
+ ]
1020
+ }];
1021
+ const driverResult = await this.resolveDriverConfigSchema(deviceId, hubLocal);
1022
+ if (driverResult.status === "ok") for (const section of driverResult.schema.sections) sections.push({
1023
+ id: section.id,
1024
+ title: section.title,
1025
+ tab: section.tab ?? "general",
1026
+ order: section.order ?? 1,
1027
+ fields: [...section.fields],
1028
+ ...section.description !== void 0 ? { description: section.description } : {},
1029
+ ...section.columns !== void 0 ? { columns: section.columns } : {}
1030
+ });
1031
+ else if (driverResult.status === "unavailable") sections.push({
1032
+ id: "driver-settings-unavailable",
1033
+ title: "Driver Settings",
1034
+ tab: "general",
1035
+ order: 1,
1036
+ fields: [{
1037
+ type: "info",
1038
+ key: "driver-settings-unavailable-notice",
1039
+ label: "Temporarily unavailable",
1040
+ content: "Driver settings are temporarily unavailable — the addon may be restarting. Refresh the page in a moment.",
1041
+ variant: "warning"
1042
+ }]
1043
+ });
1044
+ return {
1045
+ sections,
1046
+ ...driverResult.status === "ok" && driverResult.schema.tabs ? { tabs: [...driverResult.schema.tabs] } : {}
1047
+ };
1048
+ }
1049
+ /**
1050
+ * Lookup the native owner for `device-ops` on `deviceId` — the native-cap
1051
+ * registry (hub-local and remote) is keyed by numeric id.
1052
+ */
1053
+ resolveNativeDeviceOwner(deviceId) {
1054
+ const local = this.capabilityRegistry?.getNativeAddonId("device-ops", deviceId) ?? null;
1055
+ if (local) return {
1056
+ addonId: local,
1057
+ nodeId: this.ctx.kernel.localNodeId ?? "hub"
1058
+ };
1059
+ const remote = this.remoteNativeCaps.get(deviceId)?.get("device-ops") ?? this.resolveRemoteNativeCapFromRegistry("device-ops", deviceId);
1060
+ return remote ? {
1061
+ addonId: remote.addonId,
1062
+ nodeId: remote.nodeId
1063
+ } : null;
1064
+ }
1065
+ /**
1066
+ * Aggregate `status` across every registered cap for a device.
1067
+ *
1068
+ * Walks the supplied cap list (or `CAP_NAMES_WITH_STATUS` when
1069
+ * omitted), looks up a native provider per cap via the capability
1070
+ * registry, calls `provider.getStatus({ deviceId })`, and validates
1071
+ * the return against the cap's own `status.schema`. Validation
1072
+ * failures log a warning and yield `null` for that cap so the
1073
+ * overall aggregate stays usable — a single misbehaving provider
1074
+ * must not blank out a device's entire status view.
1075
+ *
1076
+ * Returned shape is `Record<capName, unknown | null>`; the client-
1077
+ * side hook tightens this to `CapStatusTypeMap` via the generated
1078
+ * `cap-status-types.ts`.
1079
+ */
1080
+ async getDeviceStatusAggregate(input) {
1081
+ const capNames = input.caps ?? CAP_NAMES_WITH_STATUS;
1082
+ const registry = this.capabilityRegistry;
1083
+ const out = {};
1084
+ if (!registry) {
1085
+ for (const name of capNames) out[name] = null;
1086
+ return out;
1087
+ }
1088
+ await Promise.all(capNames.map(async (capName) => {
1089
+ try {
1090
+ const def = registry.getDefinition(capName);
1091
+ if (!def?.status) {
1092
+ out[capName] = null;
1093
+ return;
1094
+ }
1095
+ const provider = registry.getProviderForDevice(capName, input.deviceId);
1096
+ if (!provider || typeof provider.getStatus !== "function") {
1097
+ out[capName] = null;
1098
+ return;
1099
+ }
1100
+ const raw = await provider.getStatus({ deviceId: input.deviceId });
1101
+ if (raw == null) {
1102
+ out[capName] = null;
1103
+ return;
1104
+ }
1105
+ const parsed = def.status.schema.safeParse(raw);
1106
+ if (!parsed.success) {
1107
+ this.ctx.logger.warn("getDeviceStatusAggregate: provider returned invalid status, dropping", {
1108
+ tags: { deviceId: input.deviceId },
1109
+ meta: {
1110
+ capName,
1111
+ issues: parsed.error.issues.slice(0, 3)
1112
+ }
1113
+ });
1114
+ out[capName] = null;
1115
+ return;
1116
+ }
1117
+ out[capName] = parsed.data;
1118
+ } catch (err) {
1119
+ this.ctx.logger.warn("getDeviceStatusAggregate: provider threw, dropping", {
1120
+ tags: { deviceId: input.deviceId },
1121
+ meta: {
1122
+ capName,
1123
+ error: errMsg(err)
1124
+ }
1125
+ });
1126
+ out[capName] = null;
1127
+ }
1128
+ }));
1129
+ return out;
1130
+ }
1131
+ /**
1132
+ * Return the driver-specific device-settings contribution. Hub-local
1133
+ * devices call `getSettingsUISchema()` directly; forked-worker devices
1134
+ * go through the `device-ops.getSettingsSchema` cap method on the
1135
+ * numeric-id-keyed native registry.
1136
+ *
1137
+ * Returns a discriminated result so callers can distinguish three states:
1138
+ * 'ok' – schema obtained successfully
1139
+ * 'none' – driver genuinely has no settings schema
1140
+ * 'unavailable' – worker was unreachable after retries (transient)
1141
+ */
1142
+ async resolveDriverConfigSchema(deviceId, hubLocal) {
1143
+ if (hubLocal) {
1144
+ const schema = hubLocal.device.getSettingsUISchema();
1145
+ return schema.sections.length === 0 ? { status: "none" } : {
1146
+ status: "ok",
1147
+ schema: toWireShape(schema)
1148
+ };
1149
+ }
1150
+ const ops = this.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
1151
+ if (!ops) return { status: "none" };
1152
+ const MAX_RETRIES = 2;
1153
+ const RETRY_DELAY_MS = 750;
1154
+ let lastErr;
1155
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
1156
+ if (attempt > 0) await sleep(RETRY_DELAY_MS);
1157
+ try {
1158
+ const schema = await ops.getSettingsSchema({ deviceId });
1159
+ if (!schema) return { status: "none" };
1160
+ const wire = schema;
1161
+ return wire.sections.length === 0 ? { status: "none" } : {
1162
+ status: "ok",
1163
+ schema: toWireShape(wire)
1164
+ };
1165
+ } catch (err) {
1166
+ lastErr = err;
1167
+ if (!isTransientMoleculerError(err)) break;
1168
+ if (attempt < MAX_RETRIES) this.ctx.logger.debug("cross-process getSettingsSchema transient failure, retrying", {
1169
+ tags: { deviceId },
1170
+ meta: {
1171
+ attempt: attempt + 1,
1172
+ maxRetries: MAX_RETRIES,
1173
+ error: errMsg(err)
1174
+ }
1175
+ });
1176
+ }
1177
+ }
1178
+ const msg = errMsg(lastErr);
1179
+ this.ctx.logger.warn("cross-process getSettingsSchema failed after retries — driver settings temporarily unavailable", {
1180
+ tags: { deviceId },
1181
+ meta: { error: msg }
1182
+ });
1183
+ return { status: "unavailable" };
1184
+ }
1185
+ async updateDeviceField(input) {
1186
+ if (input.writerCapName === "device-manager") {
1187
+ const hubRegistry = this.ctx.kernel?.deviceRegistry;
1188
+ const found = hubRegistry ? resolveDeviceById(hubRegistry, input.deviceId) : null;
1189
+ if (found) {
1190
+ await found.device.applySettingsPatch({ [input.key]: input.value });
1191
+ return { success: true };
1192
+ }
1193
+ const ops = this.capabilityRegistry?.getNativeProvider("device-ops", input.deviceId);
1194
+ if (!ops) throw new Error(`[device-manager] device "${input.deviceId}" not found (no hub-local entry, no device-ops native provider)`);
1195
+ await ops.setConfig({
1196
+ deviceId: input.deviceId,
1197
+ values: { [input.key]: input.value }
1198
+ });
1199
+ return { success: true };
1200
+ }
1201
+ const registry = this.capabilityRegistry;
1202
+ if (!registry) throw new Error("[device-manager] updateDeviceField requires capability registry — unavailable on this node");
1203
+ const def = registry.getDefinition(input.writerCapName);
1204
+ if (isDeviceConfigCap(def)) {
1205
+ if (def.deviceConfig.ui.kind === "widget") return { success: true };
1206
+ const dcProvider = registry.getProviderForDevice(input.writerCapName, input.deviceId);
1207
+ if (!dcProvider) throw new Error(`[device-manager] no provider for device-config cap "${input.writerCapName}" on device ${input.deviceId}`);
1208
+ await applyDerivedFormPatch(def.deviceConfig.ui.builderId, { [input.key]: input.value }, (profile, patch) => dcProvider.setProfile({
1209
+ deviceId: input.deviceId,
1210
+ profile,
1211
+ patch
1212
+ }));
1213
+ return { success: true };
1214
+ }
1215
+ if (!def?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${input.writerCapName}" does not expose device settings`);
1216
+ await this.resolveContributionProvider(registry, def, input.writerCapName, input.writerAddonId, input.deviceId).applyDeviceSettingsPatch({
1217
+ deviceId: input.deviceId,
1218
+ patch: { [input.key]: input.value }
1219
+ });
1220
+ return { success: true };
1221
+ }
1222
+ /**
1223
+ * Dispatch a device custom action. Hub-local devices run it directly;
1224
+ * forked/remote devices route through the `device-ops` native cap
1225
+ * (`runAction`) — the same hub-local-then-device-ops fan-out that
1226
+ * `updateDeviceField` uses for `applySettingsPatch`.
1227
+ */
1228
+ async dispatchDeviceAction(deviceId, action, input) {
1229
+ const hubRegistry = this.ctx.kernel?.deviceRegistry;
1230
+ const found = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
1231
+ if (found) return found.device.runDeviceAction(action, input);
1232
+ const ops = this.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
1233
+ if (!ops) throw new Error(`[device-manager] device "${deviceId}" not found for runDeviceAction (no hub-local entry, no device-ops native provider)`);
1234
+ return ops.runAction({
1235
+ deviceId,
1236
+ action,
1237
+ input
1238
+ });
1239
+ }
1240
+ /**
1241
+ * Resolve the `DeviceSettingsContribution` provider that owns a tagged
1242
+ * field. The `writerAddonId` on a tagged field equals `entry.providerAddonId`
1243
+ * from the binding that produced it — for `kind:'wrapped'` entries, that is
1244
+ * the wrapper addon id (e.g. 'snapshot-addon'); for native-only entries,
1245
+ * the system addon id.
1246
+ *
1247
+ * Resolution order (Bug-3 fix):
1248
+ * 1. `getProviderByAddon(capName, writerAddonId)` — resolves the
1249
+ * system-registered provider by the addon id from the tagged field.
1250
+ * For wrapper bindings (snapshot, motion-detection, etc.) this directly
1251
+ * returns the wrapper provider, bypassing the native-first resolution
1252
+ * order of `getProviderForDevice` that caused "method not found".
1253
+ * 2. `getSingleton(capName)` — fallback for stale/mismatched writerAddonIds.
1254
+ * The active singleton handles the contribution even if the addonId
1255
+ * stored in the field is out of date (e.g. after an addon rename).
1256
+ *
1257
+ * `getProviderForDevice` is intentionally NOT used here: it returns the
1258
+ * per-device native first when present (e.g. Reolink/ONVIF), and the native
1259
+ * does NOT implement contribution methods — the root cause of Bug-3.
1260
+ */
1261
+ resolveContributionProvider(registry, _def, writerCapName, writerAddonId, deviceId) {
1262
+ const byAddon = registry.getProviderByAddon(writerCapName, writerAddonId);
1263
+ if (byAddon) return byAddon;
1264
+ const singleton = registry.getSingleton(writerCapName);
1265
+ if (singleton) return singleton;
1266
+ throw new Error(`[device-manager] no provider for cap "${writerCapName}" (addon "${writerAddonId}") on device ${deviceId}`);
1267
+ }
1268
+ /**
1269
+ * Batched counterpart of `updateDeviceField`. Groups changes by
1270
+ * `(writerCapName, writerAddonId)` so each contributor receives a
1271
+ * single `applyDeviceSettingsPatch` with all of its updates merged —
1272
+ * avoids N round-trips for simultaneous edits in the same save.
1273
+ *
1274
+ * Per-provider failures are captured in the `failures[]` output so the
1275
+ * admin UI can highlight which sections didn't persist; a failure on
1276
+ * one provider does NOT abort the others.
1277
+ */
1278
+ async updateDeviceFieldsBatch(input) {
1279
+ const groups = /* @__PURE__ */ new Map();
1280
+ for (const change of input.changes) {
1281
+ const key = `${change.writerCapName}::${change.writerAddonId}`;
1282
+ const existing = groups.get(key);
1283
+ if (existing) existing.patch[change.key] = change.value;
1284
+ else groups.set(key, {
1285
+ writerCapName: change.writerCapName,
1286
+ writerAddonId: change.writerAddonId,
1287
+ patch: { [change.key]: change.value }
1288
+ });
1289
+ }
1290
+ const failures = [];
1291
+ for (const group of groups.values()) try {
1292
+ await this.applyGroupPatch(input.deviceId, group);
1293
+ } catch (err) {
1294
+ failures.push({
1295
+ writerCapName: group.writerCapName,
1296
+ writerAddonId: group.writerAddonId,
1297
+ error: err instanceof Error ? err.message : String(err)
1298
+ });
1299
+ }
1300
+ return {
1301
+ success: true,
1302
+ failures
1303
+ };
1304
+ }
1305
+ /** Apply a single grouped patch to the appropriate provider. Mirrors
1306
+ * `updateDeviceField` routing (special-case device-manager, else
1307
+ * registry lookup). Used by `updateDeviceFieldsBatch`. */
1308
+ async applyGroupPatch(deviceId, group) {
1309
+ if (group.writerCapName === "device-manager") {
1310
+ const hubRegistry = this.ctx.kernel?.deviceRegistry;
1311
+ const found = hubRegistry ? resolveDeviceById(hubRegistry, deviceId) : null;
1312
+ if (found) {
1313
+ await found.device.applySettingsPatch(group.patch);
1314
+ return;
1315
+ }
1316
+ const ops = this.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
1317
+ if (!ops) throw new Error(`[device-manager] device "${deviceId}" not found (no hub-local entry, no device-ops native provider)`);
1318
+ await ops.setConfig({
1319
+ deviceId,
1320
+ values: group.patch
1321
+ });
1322
+ return;
1323
+ }
1324
+ const registry = this.capabilityRegistry;
1325
+ if (!registry) throw new Error("[device-manager] capability registry unavailable");
1326
+ const def = registry.getDefinition(group.writerCapName);
1327
+ if (isDeviceConfigCap(def)) {
1328
+ if (def.deviceConfig.ui.kind === "widget") return;
1329
+ const dcProvider = registry.getProviderForDevice(group.writerCapName, deviceId);
1330
+ if (!dcProvider) throw new Error(`[device-manager] no provider for device-config cap "${group.writerCapName}" on device ${deviceId}`);
1331
+ await applyDerivedFormPatch(def.deviceConfig.ui.builderId, group.patch, (profile, patch) => dcProvider.setProfile({
1332
+ deviceId,
1333
+ profile,
1334
+ patch
1335
+ }));
1336
+ return;
1337
+ }
1338
+ if (!def?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${group.writerCapName}" does not expose device settings`);
1339
+ await this.resolveContributionProvider(registry, def, group.writerCapName, group.writerAddonId, deviceId).applyDeviceSettingsPatch({
1340
+ deviceId,
1341
+ patch: group.patch
1342
+ });
1343
+ }
1344
+ async listWrappersForCap(input) {
1345
+ return [...this.capabilityRegistry?.getWrappersForCap(input.capName) ?? []];
1346
+ }
1347
+ async listBindableCapsForDeviceType(input) {
1348
+ const registry = this.capabilityRegistry;
1349
+ if (!registry) return [];
1350
+ return registry.listDeviceScopedCapsForType(input.deviceType).map((capName) => ({
1351
+ capName,
1352
+ wrappers: [...registry.getWrappersForCap(capName)]
1353
+ }));
1354
+ }
1355
+ async setWrapperActive(input) {
1356
+ const storeKey = String(input.deviceId);
1357
+ const store = await this.readBindingsStore();
1358
+ const perDevice = { ...store.deviceBindings[storeKey] };
1359
+ if (input.active) perDevice[input.capName] = { wrapperAddonId: input.wrapperAddonId };
1360
+ else perDevice[input.capName] = { wrapperAddonId: null };
1361
+ const nextDeviceBindings = Object.keys(perDevice).length > 0 ? {
1362
+ ...store.deviceBindings,
1363
+ [storeKey]: perDevice
1364
+ } : (() => {
1365
+ const { [storeKey]: _drop, ...rest } = store.deviceBindings;
1366
+ return rest;
1367
+ })();
1368
+ await this.writeBindingsStore({ deviceBindings: nextDeviceBindings });
1369
+ this.ctx.eventBus.emit({
1370
+ id: randomUUID(),
1371
+ timestamp: /* @__PURE__ */ new Date(),
1372
+ source: {
1373
+ type: "addon",
1374
+ id: this.ctx.id
1375
+ },
1376
+ category: EventCategory.DeviceBindingsChanged,
1377
+ data: {
1378
+ deviceId: input.deviceId,
1379
+ capName: input.capName,
1380
+ reason: input.active ? "wrapper-activated" : "wrapper-deactivated",
1381
+ addonId: input.wrapperAddonId,
1382
+ nodeId: this.resolveWrapperNodeId(input.wrapperAddonId)
1383
+ }
1384
+ });
1385
+ }
1386
+ async onInitialize() {
1387
+ const settings = this.ctx.settings;
1388
+ if (!settings) {
1389
+ this.ctx.logger.warn("ctx.settings not available — device persistence unavailable");
1390
+ return;
1391
+ }
1392
+ const registry = this.ctx.kernel.deviceRegistry ?? null;
1393
+ if (!registry) this.ctx.logger.warn("device-registry not available — live operations will use persisted data only");
1394
+ const localNodeId = this.ctx.kernel.localNodeId ?? "hub";
1395
+ this.ctx.eventBus.subscribe({ category: EventCategory.DeviceBindingsChanged }, (event) => {
1396
+ const { deviceId, capName, reason, addonId, nodeId } = event.data;
1397
+ if (nodeId === localNodeId) return;
1398
+ if (reason === "native-registered") {
1399
+ let perDevice = this.remoteNativeCaps.get(deviceId);
1400
+ if (!perDevice) {
1401
+ perDevice = /* @__PURE__ */ new Map();
1402
+ this.remoteNativeCaps.set(deviceId, perDevice);
1403
+ }
1404
+ perDevice.set(capName, {
1405
+ addonId,
1406
+ nodeId
1407
+ });
1408
+ } else if (reason === "native-unregistered") {
1409
+ const perDevice = this.remoteNativeCaps.get(deviceId);
1410
+ if (!perDevice) return;
1411
+ perDevice.delete(capName);
1412
+ if (perDevice.size === 0) this.remoteNativeCaps.delete(deviceId);
1413
+ }
1414
+ });
1415
+ const cluster = this.ctx.kernel.cluster;
1416
+ if (cluster) cluster.broker.localBus.on("$node.disconnected", (payload) => {
1417
+ const gone = payload.node.id;
1418
+ const emptyDevices = [];
1419
+ for (const [deviceId, perDevice] of this.remoteNativeCaps) {
1420
+ const toDelete = [];
1421
+ for (const [capName, entry] of perDevice) if (entry.nodeId === gone) toDelete.push(capName);
1422
+ for (const capName of toDelete) perDevice.delete(capName);
1423
+ if (perDevice.size === 0) emptyDevices.push(deviceId);
1424
+ }
1425
+ for (const deviceId of emptyDevices) this.remoteNativeCaps.delete(deviceId);
1426
+ });
1427
+ const requireDeviceOps = (deviceId) => {
1428
+ const ops = this.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
1429
+ if (!ops) throw new Error(`[device-manager] device-ops native provider not found for '${deviceId}'`);
1430
+ return ops;
1431
+ };
1432
+ const readStore = async () => {
1433
+ return await settings.readAddonStore();
1434
+ };
1435
+ const readIndex = async () => {
1436
+ return (await readStore()).deviceIndex ?? {};
1437
+ };
1438
+ const readMeta = async () => {
1439
+ return (await readStore()).deviceMeta ?? {};
1440
+ };
1441
+ /** Hardware-identity metadata map. Lives in a sibling key on the
1442
+ * device-manager addon store so its writers (`setMetadata`) never
1443
+ * collide with the lifecycle writers on `deviceMeta`
1444
+ * (`registerDevice` / `setName` / `setLocation` / `setDisabled`).
1445
+ * Single-writer per row eliminates the "writer X clobbers writer
1446
+ * Y's field" bug class — `setMetadata` is the only producer. */
1447
+ const readMetadataMap = async () => {
1448
+ return (await readStore()).deviceMetadata ?? {};
1449
+ };
1450
+ let metaWriteChain = Promise.resolve();
1451
+ const withMetaWriteLock = async (fn) => {
1452
+ const previous = metaWriteChain;
1453
+ let release = () => {};
1454
+ metaWriteChain = new Promise((resolve) => {
1455
+ release = resolve;
1456
+ });
1457
+ try {
1458
+ await previous.catch(() => {});
1459
+ return await fn();
1460
+ } finally {
1461
+ release();
1462
+ }
1463
+ };
1464
+ /**
1465
+ * Resolve a numeric deviceId to the owning `(addonId, stableId)` pair.
1466
+ * Scans persisted meta — live IDevice lookup (hub registry) is handled
1467
+ * separately per call site so callers can decide whether to route to
1468
+ * an in-process driver or to the cross-process `device-ops` bridge.
1469
+ * Returns null when no device with that id is known to the hub.
1470
+ */
1471
+ const resolvePersistedById = async (deviceId) => {
1472
+ const meta = await readMeta();
1473
+ for (const [key, m] of Object.entries(meta)) if (m.id === deviceId) {
1474
+ const sep = key.indexOf(":");
1475
+ if (sep < 0) continue;
1476
+ return {
1477
+ addonId: key.slice(0, sep),
1478
+ stableId: key.slice(sep + 1),
1479
+ meta: m
1480
+ };
1481
+ }
1482
+ return null;
1483
+ };
1484
+ /** Resolve a link's `sourceKey` to a live device id: the sibling accessory
1485
+ * whose stableId is `${parentStableId}-${sourceKey}`. Null when absent.
1486
+ * Pure over a pre-read meta map so a multi-link resolve reads the store once. */
1487
+ const resolveSourceDeviceId = (parentStableId, sourceKey, meta) => {
1488
+ const wanted = `${parentStableId}-${sourceKey}`;
1489
+ for (const [key, m] of Object.entries(meta)) {
1490
+ const sep = key.indexOf(":");
1491
+ if (sep < 0) continue;
1492
+ if (key.slice(sep + 1) === wanted) return m.id;
1493
+ }
1494
+ return null;
1495
+ };
1496
+ /** Rebuild the `linkTargets` / `linkDependents` reverse-index maps from the
1497
+ * current persisted meta. Called at boot (once the `devicesWithLinks` seed
1498
+ * has run) and whenever the link topology can change: `setDeviceLinks`,
1499
+ * `registerDevice` (a new sibling source may resolve previously-dangling
1500
+ * links), and `removeDevice` (its entries must be evicted). */
1501
+ const rebuildLinkDependents = async () => {
1502
+ if (this.devicesWithLinks.size === 0) {
1503
+ this.linkTargets = /* @__PURE__ */ new Map();
1504
+ this.linkDependents = /* @__PURE__ */ new Map();
1505
+ this.expectedSourceStableIds = /* @__PURE__ */ new Set();
1506
+ return;
1507
+ }
1508
+ const allMeta = await readMeta();
1509
+ const stableIdById = /* @__PURE__ */ new Map();
1510
+ const idByStableId = /* @__PURE__ */ new Map();
1511
+ for (const [key, m] of Object.entries(allMeta)) {
1512
+ const sep = key.indexOf(":");
1513
+ if (sep < 0) continue;
1514
+ const sid = key.slice(sep + 1);
1515
+ stableIdById.set(m.id, sid);
1516
+ idByStableId.set(sid, m.id);
1517
+ }
1518
+ const entries = [];
1519
+ const expectedSources = /* @__PURE__ */ new Set();
1520
+ for (const targetId of this.devicesWithLinks) {
1521
+ const targetMeta = Object.values(allMeta).find((m) => m.id === targetId);
1522
+ if (!targetMeta) continue;
1523
+ const container = targetMeta.parentDeviceId !== null ? stableIdById.get(targetMeta.parentDeviceId) ?? stableIdById.get(targetId) : stableIdById.get(targetId);
1524
+ if (container === void 0) continue;
1525
+ for (const link of targetMeta.deviceLinks ?? []) {
1526
+ expectedSources.add(`${container}-${link.source.sourceKey}`);
1527
+ const srcId = idByStableId.get(`${container}-${link.source.sourceKey}`);
1528
+ if (srcId === void 0) continue;
1529
+ entries.push({
1530
+ targetDeviceId: targetId,
1531
+ link,
1532
+ sourceDeviceId: srcId
1533
+ });
1534
+ }
1535
+ }
1536
+ const { targets, dependents } = buildLinkIndexes(entries);
1537
+ this.linkTargets = targets;
1538
+ this.linkDependents = dependents;
1539
+ this.expectedSourceStableIds = expectedSources;
1540
+ };
1541
+ const idToAddonId = /* @__PURE__ */ new Map();
1542
+ {
1543
+ const meta = await readMeta();
1544
+ for (const [key, m] of Object.entries(meta)) {
1545
+ const sep = key.indexOf(":");
1546
+ if (sep < 0) continue;
1547
+ idToAddonId.set(m.id, key.slice(0, sep));
1548
+ if (Array.isArray(m.deviceLinks) && m.deviceLinks.length > 0) this.devicesWithLinks.add(m.id);
1549
+ }
1550
+ }
1551
+ await rebuildLinkDependents();
1552
+ const allocateNextDeviceId = async () => {
1553
+ const current = (await readStore()).nextDeviceId ?? 1;
1554
+ await settings.writeAddonStore({ nextDeviceId: current + 1 });
1555
+ return current;
1556
+ };
1557
+ const stampIntegrationId = async (deviceId, integrationId) => {
1558
+ await withMetaWriteLock(async () => {
1559
+ const persisted = await resolvePersistedById(deviceId);
1560
+ if (!persisted) throw new Error(`[device-manager] stampIntegrationId: unknown device id=${deviceId}`);
1561
+ const { addonId: aid, stableId, meta: m } = persisted;
1562
+ const key = deviceKey(aid, stableId);
1563
+ const allMeta = await readMeta();
1564
+ await settings.writeAddonStore({ deviceMeta: {
1565
+ ...allMeta,
1566
+ [key]: {
1567
+ ...m,
1568
+ integrationId
1569
+ }
1570
+ } });
1571
+ });
1572
+ this.ctx.eventBus.emit({
1573
+ id: randomUUID(),
1574
+ timestamp: /* @__PURE__ */ new Date(),
1575
+ source: {
1576
+ type: "device",
1577
+ id: deviceId
1578
+ },
1579
+ category: EventCategory.DeviceMetaChanged,
1580
+ data: {
1581
+ deviceId,
1582
+ field: "integrationId",
1583
+ value: integrationId
1584
+ }
1585
+ });
1586
+ };
1587
+ const provider = {
1588
+ /** Sync ownership lookup backing persistence fallbacks (e.g. remove()
1589
+ * when the owning worker is offline). Ownership is keyed by numeric
1590
+ * deviceId → owning addonId as recorded in the persisted meta. NOT
1591
+ * a native-cap lookup: an addon can own a device without registering
1592
+ * every possible cap natively (e.g. RtspCamera without snapshotUrl
1593
+ * doesn't register the snapshot cap). Use
1594
+ * `resolveNativeCapOwnerSync` for cap-resolution paths.
1595
+ */
1596
+ resolveDeviceOwnerSync: (deviceId) => {
1597
+ return idToAddonId.get(deviceId) ?? null;
1598
+ },
1599
+ /** Sync lookup for the addon that registered a native provider for
1600
+ * `(capName, deviceId)`. Backs `CapabilityRegistry`'s native fallback
1601
+ * so the hub only synthesizes a cross-process proxy when the cap is
1602
+ * actually published — never on speculative device ownership.
1603
+ *
1604
+ * Resolution order:
1605
+ * 1. Hub-local `capabilityRegistry` (in-process natives — fastest path).
1606
+ * 2. Push-fed `remoteNativeCaps` cache (`DeviceBindingsChanged` events
1607
+ * from forked workers — accurate in steady state).
1608
+ * 3. Handshake-fed `HubNodeRegistry` via `listClusterNativeCaps()`
1609
+ * (D3 re-handshake after device restore — covers the Moleculer
1610
+ * transport window where push events were lost). This is the
1611
+ * reliable replacement for the deleted `syncWorkerNativeCaps` pull.
1612
+ */
1613
+ resolveNativeCapOwnerSync: (capName, deviceId) => {
1614
+ const localAddonId = this.capabilityRegistry?.getNativeAddonId(capName, deviceId) ?? null;
1615
+ if (localAddonId) return {
1616
+ addonId: localAddonId,
1617
+ nodeId: this.ctx.kernel.localNodeId ?? "hub"
1618
+ };
1619
+ const remote = this.remoteNativeCaps.get(deviceId)?.get(capName) ?? null;
1620
+ if (remote) return {
1621
+ addonId: remote.addonId,
1622
+ nodeId: remote.nodeId
1623
+ };
1624
+ return this.resolveRemoteNativeCapFromRegistry(capName, deviceId);
1625
+ },
1626
+ /** Read-time overlay: merge cross-device linked values onto a target cap's
1627
+ * status. Returns null when the device has no links for `cap` (caller uses
1628
+ * the base status untouched). Reads each source cap via the hub registry's
1629
+ * `getProviderForDevice` (routes cross-process); merge is pure. */
1630
+ resolveLinkedStatus: async ({ deviceId, cap, baseStatus }) => {
1631
+ if (!this.devicesWithLinks.has(deviceId)) return null;
1632
+ const persisted = await resolvePersistedById(deviceId);
1633
+ if (!persisted) return null;
1634
+ const links = (persisted.meta.deviceLinks ?? []).filter((l) => l.target.cap === cap);
1635
+ if (links.length === 0) return null;
1636
+ const registry = this.capabilityRegistry;
1637
+ const schema = registry?.getDefinition(cap)?.status?.schema;
1638
+ if (!schema) return null;
1639
+ const allMeta = await readMeta();
1640
+ let containerStableId = persisted.stableId;
1641
+ const parentId = persisted.meta.parentDeviceId;
1642
+ if (parentId !== null) {
1643
+ for (const [key, m] of Object.entries(allMeta)) if (m.id === parentId) {
1644
+ containerStableId = key.slice(key.indexOf(":") + 1);
1645
+ break;
1646
+ }
1647
+ }
1648
+ const resolved = [];
1649
+ for (const link of links) {
1650
+ const srcId = resolveSourceDeviceId(containerStableId, link.source.sourceKey, allMeta);
1651
+ if (srcId === null) continue;
1652
+ try {
1653
+ const srcProvider = registry?.getProviderForDevice(link.source.cap, srcId);
1654
+ const raw = getByPath(typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0, link.source.fieldPath);
1655
+ resolved.push({
1656
+ link,
1657
+ sourceValue: applyTransform(raw, link.transform)
1658
+ });
1659
+ } catch (err) {
1660
+ this.ctx.logger.warn("resolveLinkedStatus: source read failed", {
1661
+ tags: {
1662
+ deviceId,
1663
+ capName: cap
1664
+ },
1665
+ meta: {
1666
+ sourceKey: link.source.sourceKey,
1667
+ error: err instanceof Error ? err.message : String(err)
1668
+ }
1669
+ });
1670
+ }
1671
+ }
1672
+ if (resolved.length === 0) return null;
1673
+ return mergeLinkedStatus(isRecord(baseStatus) ? baseStatus : {}, resolved, schema);
1674
+ },
1675
+ /** Idempotent numeric-id reservation. Callers invoke this before
1676
+ * constructing the owning `IDevice` so `DeviceContext.id` is bound
1677
+ * at construction time. A repeat call for the same `(addonId,
1678
+ * stableId)` returns the already-persisted id — same physical
1679
+ * device reconnecting after a driver restart keeps its original
1680
+ * number. Fresh pairs burn one slot from the monotonic
1681
+ * `nextDeviceId` counter and seed a meta placeholder so the
1682
+ * `deviceMeta` → `id` invariant holds even before
1683
+ * `registerDevice` completes. */
1684
+ allocateDeviceId: async (input) => {
1685
+ const { addonId, stableId } = input;
1686
+ const key = deviceKey(addonId, stableId);
1687
+ return await withMetaWriteLock(async () => {
1688
+ const meta = await readMeta();
1689
+ const existing = meta[key];
1690
+ if (existing) return { id: existing.id };
1691
+ const id = await allocateNextDeviceId();
1692
+ await settings.writeAddonStore({ deviceMeta: {
1693
+ ...meta,
1694
+ [key]: {
1695
+ type: "generic",
1696
+ name: stableId,
1697
+ location: null,
1698
+ disabled: false,
1699
+ parentDeviceId: null,
1700
+ id
1701
+ }
1702
+ } });
1703
+ return { id };
1704
+ });
1705
+ },
1706
+ registerDevice: async (input) => {
1707
+ const { addonId, stableId, id, type, name, parentDeviceId, features, config } = input;
1708
+ const key = deviceKey(addonId, stableId);
1709
+ const featuresArr = Array.isArray(features) ? [...features] : [];
1710
+ const { isFirstRegistration, exportFingerprint, fingerprintChanged } = await withMetaWriteLock(async () => {
1711
+ const index = await readIndex();
1712
+ const existing = index[addonId] ?? [];
1713
+ const wasInIndex = existing.includes(stableId);
1714
+ if (!wasInIndex) await settings.writeAddonStore({ deviceIndex: {
1715
+ ...index,
1716
+ [addonId]: [...existing, stableId]
1717
+ } });
1718
+ const meta = await readMeta();
1719
+ const existingMeta = meta[key];
1720
+ const isFirst = !existingMeta || !wasInIndex;
1721
+ const fingerprint = canonicalDeviceFingerprint({
1722
+ deviceId: id,
1723
+ deviceType: type,
1724
+ features: featuresArr
1725
+ });
1726
+ await settings.writeAddonStore({ deviceMeta: {
1727
+ ...meta,
1728
+ [key]: {
1729
+ type,
1730
+ name: existingMeta && existingMeta.name !== stableId ? existingMeta.name : name,
1731
+ location: existingMeta?.location ?? null,
1732
+ disabled: existingMeta?.disabled ?? false,
1733
+ ...existingMeta?.integrationId !== void 0 ? { integrationId: existingMeta.integrationId } : {},
1734
+ ...existingMeta?.linkDeviceId !== void 0 ? { linkDeviceId: existingMeta.linkDeviceId } : {},
1735
+ ...existingMeta?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: existingMeta.primaryChildEntityId } : {},
1736
+ ...existingMeta?.childLayout !== void 0 ? { childLayout: existingMeta.childLayout } : {},
1737
+ ...existingMeta?.deviceLinks !== void 0 ? { deviceLinks: existingMeta.deviceLinks } : {},
1738
+ ...existingMeta?.role !== void 0 ? { role: existingMeta.role } : {},
1739
+ parentDeviceId,
1740
+ id,
1741
+ features: featuresArr,
1742
+ exportFingerprint: fingerprint
1743
+ }
1744
+ } });
1745
+ return {
1746
+ isFirstRegistration: isFirst,
1747
+ exportFingerprint: fingerprint,
1748
+ fingerprintChanged: existingMeta?.exportFingerprint !== fingerprint
1749
+ };
1750
+ });
1751
+ if (Object.keys(config).length > 0) await settings.writeDeviceStore(id, config);
1752
+ idToAddonId.set(id, addonId);
1753
+ if (isFirstRegistration) this.ctx.eventBus.emit({
1754
+ id: randomUUID(),
1755
+ timestamp: /* @__PURE__ */ new Date(),
1756
+ source: {
1757
+ type: "device",
1758
+ id
1759
+ },
1760
+ category: EventCategory.DeviceRegistered,
1761
+ data: {
1762
+ deviceId: id,
1763
+ name: name.length > 0 ? name : stableId,
1764
+ providerId: addonId,
1765
+ parentDeviceId: parentDeviceId ?? null
1766
+ }
1767
+ });
1768
+ else this.ctx.eventBus.emit({
1769
+ id: randomUUID(),
1770
+ timestamp: /* @__PURE__ */ new Date(),
1771
+ source: {
1772
+ type: "device",
1773
+ id
1774
+ },
1775
+ category: EventCategory.DeviceMetaChanged,
1776
+ data: {
1777
+ deviceId: id,
1778
+ name: name.length > 0 ? name : stableId,
1779
+ providerId: addonId,
1780
+ parentDeviceId: parentDeviceId ?? null,
1781
+ features: featuresArr
1782
+ }
1783
+ });
1784
+ if (isFirstRegistration || fingerprintChanged) this.ctx.eventBus.emit({
1785
+ id: randomUUID(),
1786
+ timestamp: /* @__PURE__ */ new Date(),
1787
+ source: {
1788
+ type: "device",
1789
+ id
1790
+ },
1791
+ category: EventCategory.DeviceProvisioned,
1792
+ data: {
1793
+ deviceId: id,
1794
+ fingerprint: exportFingerprint,
1795
+ generation: Date.now()
1796
+ }
1797
+ });
1798
+ if (this.devicesWithLinks.size > 0 && this.expectedSourceStableIds.has(stableId)) await rebuildLinkDependents();
1799
+ },
1800
+ removeDevice: async (input) => {
1801
+ const { deviceId } = input;
1802
+ const persisted = await resolvePersistedById(deviceId);
1803
+ if (!persisted) return;
1804
+ const { addonId, stableId, meta: persistedMeta } = persisted;
1805
+ const key = deviceKey(addonId, stableId);
1806
+ const deviceName = persistedMeta.name;
1807
+ await withMetaWriteLock(async () => {
1808
+ const index = await readIndex();
1809
+ const remaining = (index[addonId] ?? []).filter((sid) => sid !== stableId);
1810
+ const updatedIndex = remaining.length > 0 ? {
1811
+ ...index,
1812
+ [addonId]: remaining
1813
+ } : (() => {
1814
+ const { [addonId]: _removed, ...rest } = index;
1815
+ return rest;
1816
+ })();
1817
+ await settings.writeAddonStore({ deviceIndex: updatedIndex });
1818
+ const { [key]: _removedMeta, ...restMeta } = await readMeta();
1819
+ await settings.writeAddonStore({ deviceMeta: restMeta });
1820
+ const map = await readMetadataMap();
1821
+ if (key in map) {
1822
+ const { [key]: _removedMetadata, ...restMap } = map;
1823
+ await settings.writeAddonStore({ deviceMetadata: restMap });
1824
+ }
1825
+ });
1826
+ await settings.clearDeviceStore(deviceId);
1827
+ await settings.clearDeviceRuntimeState(deviceId);
1828
+ const bindingsStore = await this.readBindingsStore();
1829
+ const bindingKey = String(deviceId);
1830
+ if (bindingsStore.deviceBindings[bindingKey]) {
1831
+ const { [bindingKey]: _removedBindings, ...restBindings } = bindingsStore.deviceBindings;
1832
+ await this.writeBindingsStore({ deviceBindings: restBindings });
1833
+ }
1834
+ this.remoteNativeCaps.delete(deviceId);
1835
+ this.capabilityRegistry?.unregisterAllNativeForDevice(deviceId);
1836
+ idToAddonId.delete(deviceId);
1837
+ this.devicesWithLinks.delete(deviceId);
1838
+ for (const key of this.lastEmittedOverlay.keys()) if (key.startsWith(`${deviceId}:`)) this.lastEmittedOverlay.delete(key);
1839
+ await rebuildLinkDependents();
1840
+ this.ctx.logger.info("removed device", { tags: {
1841
+ deviceId,
1842
+ deviceName: deviceName.length > 0 ? deviceName : stableId
1843
+ } });
1844
+ this.ctx.eventBus.emit({
1845
+ id: randomUUID(),
1846
+ timestamp: /* @__PURE__ */ new Date(),
1847
+ source: {
1848
+ type: "device",
1849
+ id: deviceId
1850
+ },
1851
+ category: EventCategory.DeviceUnregistered,
1852
+ data: {
1853
+ deviceId,
1854
+ providerId: addonId,
1855
+ parentDeviceId: persistedMeta.parentDeviceId ?? null
1856
+ }
1857
+ });
1858
+ },
1859
+ persistConfig: async (input) => {
1860
+ const { deviceId, data } = input;
1861
+ if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] persistConfig: unknown device id=${deviceId}`);
1862
+ await settings.writeDeviceStore(deviceId, data);
1863
+ },
1864
+ loadConfig: async (input) => {
1865
+ const { deviceId } = input;
1866
+ if (!await resolvePersistedById(deviceId)) return {};
1867
+ return settings.readDeviceStore(deviceId);
1868
+ },
1869
+ /**
1870
+ * Load the operator-organisational meta surface for one device
1871
+ * (`name` / `location` / `disabled` / `type` / `parentDeviceId`
1872
+ * / `addonId` + `id` / `stableId`). Used by the kernel proxy's
1873
+ * device-context factory to populate `ctx.deviceMeta` before
1874
+ * the device class constructor runs. Returns `null` when no
1875
+ * persisted row exists for the id.
1876
+ *
1877
+ * Reads default `location` to `null` and `disabled` to `false`
1878
+ * for legacy rows that predate the field — production code
1879
+ * relies on the IDevice type contract that both are present.
1880
+ */
1881
+ loadMeta: async (input) => {
1882
+ const { deviceId } = input;
1883
+ const persisted = await resolvePersistedById(deviceId);
1884
+ if (!persisted) return null;
1885
+ const { addonId, stableId, meta: m } = persisted;
1886
+ const key = deviceKey(addonId, stableId);
1887
+ const metadata = (await readMetadataMap())[key] ?? null;
1888
+ return {
1889
+ id: m.id,
1890
+ stableId,
1891
+ addonId,
1892
+ type: m.type,
1893
+ name: m.name,
1894
+ location: m.location ?? null,
1895
+ disabled: m.disabled ?? false,
1896
+ parentDeviceId: m.parentDeviceId,
1897
+ metadata,
1898
+ ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1899
+ ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
1900
+ ...m.role !== void 0 ? { role: m.role } : {}
1901
+ };
1902
+ },
1903
+ /**
1904
+ * Update the operator-edited display name. Writes the meta
1905
+ * row, emits a `DeviceMetaChanged` event so live consumers
1906
+ * (UI device list, alert center) see the rename without
1907
+ * polling. The live `IDevice.name` mirror is updated by the
1908
+ * kernel proxy on its side (`device-cap-proxy.ts`).
1909
+ */
1910
+ setName: async (input) => {
1911
+ const { deviceId, name } = input;
1912
+ await withMetaWriteLock(async () => {
1913
+ const persisted = await resolvePersistedById(deviceId);
1914
+ if (!persisted) throw new Error(`[device-manager] setName: unknown device id=${deviceId}`);
1915
+ const { addonId, stableId, meta: m } = persisted;
1916
+ const key = deviceKey(addonId, stableId);
1917
+ const allMeta = await readMeta();
1918
+ await settings.writeAddonStore({ deviceMeta: {
1919
+ ...allMeta,
1920
+ [key]: {
1921
+ ...m,
1922
+ name
1923
+ }
1924
+ } });
1925
+ });
1926
+ this.ctx.eventBus.emit({
1927
+ id: randomUUID(),
1928
+ timestamp: /* @__PURE__ */ new Date(),
1929
+ source: {
1930
+ type: "device",
1931
+ id: deviceId
1932
+ },
1933
+ category: EventCategory.DeviceMetaChanged,
1934
+ data: {
1935
+ deviceId,
1936
+ field: "name",
1937
+ value: name
1938
+ }
1939
+ });
1940
+ },
1941
+ /**
1942
+ * Update the operator-organisational location label. `null`
1943
+ * clears it. Mirrors the same persist-then-emit shape as
1944
+ * `setName`; consumers subscribe to `DeviceMetaChanged` and
1945
+ * filter on `field: 'location'`.
1946
+ */
1947
+ setLocation: async (input) => {
1948
+ const { deviceId, location } = input;
1949
+ await withMetaWriteLock(async () => {
1950
+ const persisted = await resolvePersistedById(deviceId);
1951
+ if (!persisted) throw new Error(`[device-manager] setLocation: unknown device id=${deviceId}`);
1952
+ const { addonId, stableId, meta: m } = persisted;
1953
+ const key = deviceKey(addonId, stableId);
1954
+ const allMeta = await readMeta();
1955
+ await settings.writeAddonStore({ deviceMeta: {
1956
+ ...allMeta,
1957
+ [key]: {
1958
+ ...m,
1959
+ location
1960
+ }
1961
+ } });
1962
+ });
1963
+ this.ctx.eventBus.emit({
1964
+ id: randomUUID(),
1965
+ timestamp: /* @__PURE__ */ new Date(),
1966
+ source: {
1967
+ type: "device",
1968
+ id: deviceId
1969
+ },
1970
+ category: EventCategory.DeviceMetaChanged,
1971
+ data: {
1972
+ deviceId,
1973
+ field: "location",
1974
+ value: location
1975
+ }
1976
+ });
1977
+ },
1978
+ /**
1979
+ * Update the device type. Writes the meta row, emits a
1980
+ * `DeviceMetaChanged` event. Used by the kernel to apply
1981
+ * `initialMeta.type` before device construction so the device
1982
+ * constructs with its real type instead of the `allocateDeviceId`
1983
+ * placeholder `'generic'`.
1984
+ */
1985
+ setType: async (input) => {
1986
+ const { deviceId, type } = input;
1987
+ await withMetaWriteLock(async () => {
1988
+ const persisted = await resolvePersistedById(deviceId);
1989
+ if (!persisted) throw new Error(`[device-manager] setType: unknown device id=${deviceId}`);
1990
+ const { addonId, stableId, meta: m } = persisted;
1991
+ const key = deviceKey(addonId, stableId);
1992
+ const allMeta = await readMeta();
1993
+ await settings.writeAddonStore({ deviceMeta: {
1994
+ ...allMeta,
1995
+ [key]: {
1996
+ ...m,
1997
+ type
1998
+ }
1999
+ } });
2000
+ });
2001
+ this.ctx.eventBus.emit({
2002
+ id: randomUUID(),
2003
+ timestamp: /* @__PURE__ */ new Date(),
2004
+ source: {
2005
+ type: "device",
2006
+ id: deviceId
2007
+ },
2008
+ category: EventCategory.DeviceMetaChanged,
2009
+ data: {
2010
+ deviceId,
2011
+ field: "type",
2012
+ value: type
2013
+ }
2014
+ });
2015
+ },
2016
+ /**
2017
+ * Stamp (or update) the owning integration id on the device's
2018
+ * meta row. Called by the kernel's `create()` pre-seed path
2019
+ * when `initialMeta.integrationId` is set (analogous to
2020
+ * `setName` / `setType`). Idempotent.
2021
+ */
2022
+ setIntegrationId: async (input) => {
2023
+ await stampIntegrationId(input.deviceId, input.integrationId);
2024
+ },
2025
+ /**
2026
+ * Stamp (or update) the linked device id on the device's meta
2027
+ * row. Called by the kernel's `create()` pre-seed path when
2028
+ * `initialMeta.linkDeviceId` is set (analogous to `setIntegrationId`).
2029
+ * Idempotent. `null` explicitly clears a previous link.
2030
+ */
2031
+ setLinkDeviceId: async (input) => {
2032
+ const { deviceId, linkDeviceId } = input;
2033
+ await withMetaWriteLock(async () => {
2034
+ const persisted = await resolvePersistedById(deviceId);
2035
+ if (!persisted) throw new Error(`[device-manager] setLinkDeviceId: unknown device id=${deviceId}`);
2036
+ const { addonId: aid, stableId, meta: m } = persisted;
2037
+ const key = deviceKey(aid, stableId);
2038
+ const allMeta = await readMeta();
2039
+ await settings.writeAddonStore({ deviceMeta: {
2040
+ ...allMeta,
2041
+ [key]: {
2042
+ ...m,
2043
+ linkDeviceId
2044
+ }
2045
+ } });
2046
+ });
2047
+ this.ctx.eventBus.emit({
2048
+ id: randomUUID(),
2049
+ timestamp: /* @__PURE__ */ new Date(),
2050
+ source: {
2051
+ type: "device",
2052
+ id: deviceId
2053
+ },
2054
+ category: EventCategory.DeviceMetaChanged,
2055
+ data: {
2056
+ deviceId,
2057
+ field: "linkDeviceId",
2058
+ value: linkDeviceId
2059
+ }
2060
+ });
2061
+ },
2062
+ /**
2063
+ * Set (or clear) the durable primary-child override on a CONTAINER
2064
+ * device's meta row, keyed on the chosen child's re-sync/rename-stable
2065
+ * `entityId`. Mirrors `setLinkDeviceId` but persists a string entityId
2066
+ * (survives a re-sync that reallocates the child's numeric id) instead
2067
+ * of the numeric child id. `null` clears the override → priority default.
2068
+ */
2069
+ setPrimaryChildEntityId: async (input) => {
2070
+ const { deviceId, primaryChildEntityId } = input;
2071
+ await withMetaWriteLock(async () => {
2072
+ const persisted = await resolvePersistedById(deviceId);
2073
+ if (!persisted) throw new Error(`[device-manager] setPrimaryChildEntityId: unknown device id=${deviceId}`);
2074
+ const { addonId: aid, stableId, meta: m } = persisted;
2075
+ const key = deviceKey(aid, stableId);
2076
+ const allMeta = await readMeta();
2077
+ await settings.writeAddonStore({ deviceMeta: {
2078
+ ...allMeta,
2079
+ [key]: {
2080
+ ...m,
2081
+ primaryChildEntityId
2082
+ }
2083
+ } });
2084
+ });
2085
+ this.ctx.eventBus.emit({
2086
+ id: randomUUID(),
2087
+ timestamp: /* @__PURE__ */ new Date(),
2088
+ source: {
2089
+ type: "device",
2090
+ id: deviceId
2091
+ },
2092
+ category: EventCategory.DeviceMetaChanged,
2093
+ data: {
2094
+ deviceId,
2095
+ field: "primaryChildEntityId",
2096
+ value: primaryChildEntityId
2097
+ }
2098
+ });
2099
+ },
2100
+ /**
2101
+ * Set (or replace) the container-level `childLayout` on a device's
2102
+ * meta row. Mirrors `setPrimaryChildEntityId` but persists the
2103
+ * accordion-section layout array (parent-only). Persisted, projected,
2104
+ * and preserved across re-register/restore.
2105
+ */
2106
+ setChildLayout: async (input) => {
2107
+ const { deviceId, childLayout } = input;
2108
+ await withMetaWriteLock(async () => {
2109
+ const persisted = await resolvePersistedById(deviceId);
2110
+ if (!persisted) throw new Error(`[device-manager] setChildLayout: unknown device id=${deviceId}`);
2111
+ const { addonId: aid, stableId, meta: m } = persisted;
2112
+ const key = deviceKey(aid, stableId);
2113
+ const allMeta = await readMeta();
2114
+ await settings.writeAddonStore({ deviceMeta: {
2115
+ ...allMeta,
2116
+ [key]: {
2117
+ ...m,
2118
+ childLayout
2119
+ }
2120
+ } });
2121
+ });
2122
+ this.ctx.eventBus.emit({
2123
+ id: randomUUID(),
2124
+ timestamp: /* @__PURE__ */ new Date(),
2125
+ source: {
2126
+ type: "device",
2127
+ id: deviceId
2128
+ },
2129
+ category: EventCategory.DeviceMetaChanged,
2130
+ data: {
2131
+ deviceId,
2132
+ field: "childLayout",
2133
+ value: childLayout
2134
+ }
2135
+ });
2136
+ },
2137
+ /**
2138
+ * Set (or replace) the operator-authored cross-device field wirings on
2139
+ * a device's meta row. Mirrors `setChildLayout` but persists the
2140
+ * `deviceLinks` array. Persisted, projected, and preserved across
2141
+ * re-register/restore. Also maintains the in-memory `devicesWithLinks`
2142
+ * Set used as an O(1) gate by `resolveLinkedStatus`.
2143
+ */
2144
+ setDeviceLinks: async (input) => {
2145
+ const { deviceId, deviceLinks } = input;
2146
+ await withMetaWriteLock(async () => {
2147
+ const persisted = await resolvePersistedById(deviceId);
2148
+ if (!persisted) throw new Error(`[device-manager] setDeviceLinks: unknown device id=${deviceId}`);
2149
+ const { addonId: aid, stableId, meta: m } = persisted;
2150
+ const key = deviceKey(aid, stableId);
2151
+ const allMeta = await readMeta();
2152
+ await settings.writeAddonStore({ deviceMeta: {
2153
+ ...allMeta,
2154
+ [key]: {
2155
+ ...m,
2156
+ deviceLinks
2157
+ }
2158
+ } });
2159
+ });
2160
+ if (deviceLinks.length > 0) this.devicesWithLinks.add(deviceId);
2161
+ else this.devicesWithLinks.delete(deviceId);
2162
+ await rebuildLinkDependents();
2163
+ this.ctx.eventBus.emit({
2164
+ id: randomUUID(),
2165
+ timestamp: /* @__PURE__ */ new Date(),
2166
+ source: {
2167
+ type: "device",
2168
+ id: deviceId
2169
+ },
2170
+ category: EventCategory.DeviceMetaChanged,
2171
+ data: {
2172
+ deviceId,
2173
+ field: "deviceLinks",
2174
+ value: deviceLinks
2175
+ }
2176
+ });
2177
+ },
2178
+ /**
2179
+ * Stamp (or update) the semantic role on the device's meta row.
2180
+ * Called by the kernel's `create()` / `spawnAccessoryChild`
2181
+ * pre-seed when `initialMeta.role` is set (analogous to
2182
+ * `setIntegrationId`). Idempotent. `null` clears a previous role.
2183
+ */
2184
+ setRole: async (input) => {
2185
+ const { deviceId, role } = input;
2186
+ await withMetaWriteLock(async () => {
2187
+ const persisted = await resolvePersistedById(deviceId);
2188
+ if (!persisted) throw new Error(`[device-manager] setRole: unknown device id=${deviceId}`);
2189
+ const { addonId: aid, stableId, meta: m } = persisted;
2190
+ const key = deviceKey(aid, stableId);
2191
+ const allMeta = await readMeta();
2192
+ await settings.writeAddonStore({ deviceMeta: {
2193
+ ...allMeta,
2194
+ [key]: {
2195
+ ...m,
2196
+ role
2197
+ }
2198
+ } });
2199
+ });
2200
+ this.ctx.eventBus.emit({
2201
+ id: randomUUID(),
2202
+ timestamp: /* @__PURE__ */ new Date(),
2203
+ source: {
2204
+ type: "device",
2205
+ id: deviceId
2206
+ },
2207
+ category: EventCategory.DeviceMetaChanged,
2208
+ data: {
2209
+ deviceId,
2210
+ field: "role",
2211
+ value: role
2212
+ }
2213
+ });
2214
+ },
2215
+ /**
2216
+ * Batched meta pre-seed. Applies every provided field to the
2217
+ * device's meta row in ONE read-modify-write under a single
2218
+ * `withMetaWriteLock` acquisition (one `deviceMeta` blob write),
2219
+ * then emits one `DeviceMetaChanged` event per field that was
2220
+ * supplied — preserving the exact semantics of the individual
2221
+ * setters (`setName` / `setLocation` / `setType` /
2222
+ * `setIntegrationId` / `setLinkDeviceId` / `setRole`). Omitted
2223
+ * fields are left untouched; `null` clears `location` /
2224
+ * `linkDeviceId` / `role`. Idempotent.
2225
+ *
2226
+ * Collapses the per-child meta pre-seed (up to 6 individual
2227
+ * setter round-trips, each its own lock + write) into one — the
2228
+ * dominant cost when the kernel's `spawnAccessoryChild` reconciles
2229
+ * a many-entity container.
2230
+ */
2231
+ applyInitialMeta: async (input) => {
2232
+ const { deviceId, name, location, type, integrationId, linkDeviceId, role } = input;
2233
+ await withMetaWriteLock(async () => {
2234
+ const persisted = await resolvePersistedById(deviceId);
2235
+ if (!persisted) throw new Error(`[device-manager] applyInitialMeta: unknown device id=${deviceId}`);
2236
+ const { addonId: aid, stableId, meta: m } = persisted;
2237
+ const key = deviceKey(aid, stableId);
2238
+ const allMeta = await readMeta();
2239
+ const merged = {
2240
+ ...m,
2241
+ ...name !== void 0 ? { name } : {},
2242
+ ...location !== void 0 ? { location } : {},
2243
+ ...type !== void 0 ? { type } : {},
2244
+ ...integrationId !== void 0 ? { integrationId } : {},
2245
+ ...linkDeviceId !== void 0 ? { linkDeviceId } : {},
2246
+ ...role !== void 0 ? { role } : {}
2247
+ };
2248
+ await settings.writeAddonStore({ deviceMeta: {
2249
+ ...allMeta,
2250
+ [key]: merged
2251
+ } });
2252
+ });
2253
+ const emitMetaChanged = (field, value) => {
2254
+ this.ctx.eventBus.emit({
2255
+ id: randomUUID(),
2256
+ timestamp: /* @__PURE__ */ new Date(),
2257
+ source: {
2258
+ type: "device",
2259
+ id: deviceId
2260
+ },
2261
+ category: EventCategory.DeviceMetaChanged,
2262
+ data: {
2263
+ deviceId,
2264
+ field,
2265
+ value
2266
+ }
2267
+ });
2268
+ };
2269
+ if (name !== void 0) emitMetaChanged("name", name);
2270
+ if (location !== void 0) emitMetaChanged("location", location);
2271
+ if (type !== void 0) emitMetaChanged("type", type);
2272
+ if (integrationId !== void 0) emitMetaChanged("integrationId", integrationId);
2273
+ if (linkDeviceId !== void 0) emitMetaChanged("linkDeviceId", linkDeviceId);
2274
+ if (role !== void 0) emitMetaChanged("role", role);
2275
+ },
2276
+ /**
2277
+ * Patch the device's hardware-identity metadata blob. Shallow
2278
+ * merge — `null` removes a key, anything else overwrites.
2279
+ * Drivers populate factual fields on first probe; operators
2280
+ * augment via the Device Info tab. Idempotent: a no-op patch
2281
+ * (every key already present with the same value) doesn't emit
2282
+ * the meta-changed event.
2283
+ */
2284
+ setMetadata: async (input) => {
2285
+ const { deviceId, patch } = input;
2286
+ const result = await withMetaWriteLock(async () => {
2287
+ const persisted = await resolvePersistedById(deviceId);
2288
+ if (!persisted) throw new Error(`[device-manager] setMetadata: unknown device id=${deviceId}`);
2289
+ const { addonId, stableId } = persisted;
2290
+ const key = deviceKey(addonId, stableId);
2291
+ const map = await readMetadataMap();
2292
+ const next = { ...map[key] ?? {} };
2293
+ let changed = false;
2294
+ for (const [k, v] of Object.entries(patch)) if (v === null) {
2295
+ if (k in next) {
2296
+ delete next[k];
2297
+ changed = true;
2298
+ }
2299
+ } else if (next[k] !== v) {
2300
+ next[k] = v;
2301
+ changed = true;
2302
+ }
2303
+ if (!changed) return { changed: false };
2304
+ const hasFields = Object.keys(next).length > 0;
2305
+ const updatedMap = { ...map };
2306
+ if (hasFields) updatedMap[key] = next;
2307
+ else delete updatedMap[key];
2308
+ await settings.writeAddonStore({ deviceMetadata: updatedMap });
2309
+ return {
2310
+ changed: true,
2311
+ finalMeta: hasFields ? next : null
2312
+ };
2313
+ });
2314
+ if (!result.changed) return;
2315
+ this.ctx.eventBus.emit({
2316
+ id: randomUUID(),
2317
+ timestamp: /* @__PURE__ */ new Date(),
2318
+ source: {
2319
+ type: "device",
2320
+ id: deviceId
2321
+ },
2322
+ category: EventCategory.DeviceMetaChanged,
2323
+ data: {
2324
+ deviceId,
2325
+ field: "metadata",
2326
+ value: result.finalMeta
2327
+ }
2328
+ });
2329
+ },
2330
+ /**
2331
+ * Soft-disable the device. Persisted on the meta row;
2332
+ * lifecycle gating is the driver's responsibility (BaseDevice
2333
+ * exposes `this.disabled` for the driver to consult at the top
2334
+ * of its lifecycle methods).
2335
+ */
2336
+ setDisabled: async (input) => {
2337
+ const { deviceId, disabled } = input;
2338
+ await withMetaWriteLock(async () => {
2339
+ const persisted = await resolvePersistedById(deviceId);
2340
+ if (!persisted) throw new Error(`[device-manager] setDisabled: unknown device id=${deviceId}`);
2341
+ const { addonId, stableId, meta: m } = persisted;
2342
+ const key = deviceKey(addonId, stableId);
2343
+ const allMeta = await readMeta();
2344
+ await settings.writeAddonStore({ deviceMeta: {
2345
+ ...allMeta,
2346
+ [key]: {
2347
+ ...m,
2348
+ disabled
2349
+ }
2350
+ } });
2351
+ });
2352
+ this.ctx.eventBus.emit({
2353
+ id: randomUUID(),
2354
+ timestamp: /* @__PURE__ */ new Date(),
2355
+ source: {
2356
+ type: "device",
2357
+ id: deviceId
2358
+ },
2359
+ category: EventCategory.DeviceMetaChanged,
2360
+ data: {
2361
+ deviceId,
2362
+ field: "disabled",
2363
+ value: disabled
2364
+ }
2365
+ });
2366
+ },
2367
+ loadRuntimeState: async (input) => {
2368
+ const { deviceId } = input;
2369
+ if (!await resolvePersistedById(deviceId)) return {};
2370
+ const data = await settings.readDeviceRuntimeState(deviceId);
2371
+ this.seedMirror(deviceId, this.withResetSessionProbe(data));
2372
+ return data;
2373
+ },
2374
+ /**
2375
+ * Union of (1) operator-curated location registry and (2) labels
2376
+ * currently in use on persisted devices. Case-insensitive
2377
+ * dedupe (preserves the first-seen casing). Sorted
2378
+ * case-insensitively for stable UI. Drives the Device Info
2379
+ * location autocomplete.
2380
+ */
2381
+ listLocations: async () => {
2382
+ const store = await settings.readAddonStore();
2383
+ const meta = store.deviceMeta ?? {};
2384
+ const registry = store.locations ?? [];
2385
+ const seen = /* @__PURE__ */ new Map();
2386
+ const consider = (raw) => {
2387
+ if (typeof raw !== "string") return;
2388
+ const trimmed = raw.trim();
2389
+ if (trimmed.length === 0) return;
2390
+ const key = trimmed.toLowerCase();
2391
+ if (!seen.has(key)) seen.set(key, trimmed);
2392
+ };
2393
+ for (const label of registry) consider(label);
2394
+ for (const m of Object.values(meta)) consider(m.location);
2395
+ return [...seen.values()].toSorted((a, b) => a.localeCompare(b, void 0, { sensitivity: "base" }));
2396
+ },
2397
+ /**
2398
+ * Add a label to the curated location registry. Idempotent:
2399
+ * existing entries (case-insensitive match) are silently kept.
2400
+ * Empty / whitespace-only inputs throw — operators must supply a
2401
+ * meaningful label.
2402
+ */
2403
+ addLocation: async (input) => {
2404
+ const trimmed = input.name.trim();
2405
+ if (trimmed.length === 0) throw new Error("[device-manager] addLocation: name must be non-empty");
2406
+ const current = (await settings.readAddonStore()).locations ?? [];
2407
+ if (current.some((l) => l.toLowerCase() === trimmed.toLowerCase())) return;
2408
+ await settings.writeAddonStore({ locations: [...current, trimmed] });
2409
+ },
2410
+ /**
2411
+ * Remove a label from the curated registry. Match is
2412
+ * case-insensitive. Devices that still reference this label keep
2413
+ * their `meta.location` value (the registry is a suggestion
2414
+ * list, not a foreign key) — pass `cascade: true` to also clear
2415
+ * `setLocation` on every device that referenced this exact
2416
+ * label. Cascade only matches case-insensitively + trimmed, same
2417
+ * as the registry equality check.
2418
+ */
2419
+ removeLocation: async (input) => {
2420
+ const trimmed = input.name.trim();
2421
+ if (trimmed.length === 0) return;
2422
+ const store = await settings.readAddonStore();
2423
+ const current = store.locations ?? [];
2424
+ const remaining = current.filter((l) => l.toLowerCase() !== trimmed.toLowerCase());
2425
+ if (remaining.length !== current.length) await settings.writeAddonStore({ locations: remaining });
2426
+ if (input.cascade !== true) return;
2427
+ const meta = store.deviceMeta ?? {};
2428
+ const updates = { ...meta };
2429
+ const cleared = [];
2430
+ for (const [key, m] of Object.entries(meta)) {
2431
+ if (typeof m.location !== "string") continue;
2432
+ if (m.location.trim().toLowerCase() !== trimmed.toLowerCase()) continue;
2433
+ updates[key] = {
2434
+ ...m,
2435
+ location: null
2436
+ };
2437
+ cleared.push(m.id);
2438
+ }
2439
+ if (cleared.length === 0) return;
2440
+ await settings.writeAddonStore({ deviceMeta: updates });
2441
+ for (const deviceId of cleared) this.ctx.eventBus.emit({
2442
+ id: randomUUID(),
2443
+ timestamp: /* @__PURE__ */ new Date(),
2444
+ source: {
2445
+ type: "device",
2446
+ id: deviceId
2447
+ },
2448
+ category: EventCategory.DeviceMetaChanged,
2449
+ data: {
2450
+ deviceId,
2451
+ field: "location",
2452
+ value: null
2453
+ }
2454
+ });
2455
+ },
2456
+ listPersistedByAddon: async (input) => {
2457
+ const { addonId } = input;
2458
+ const [index, meta] = await Promise.all([readIndex(), readMeta()]);
2459
+ return (index[addonId] ?? []).map((stableId) => {
2460
+ const m = meta[deviceKey(addonId, stableId)];
2461
+ return {
2462
+ id: m.id,
2463
+ stableId,
2464
+ type: m.type,
2465
+ name: m.name,
2466
+ location: m.location ?? null,
2467
+ disabled: m.disabled ?? false,
2468
+ parentDeviceId: m.parentDeviceId
2469
+ };
2470
+ });
2471
+ },
2472
+ listAll: async (input) => {
2473
+ const { addonId } = input;
2474
+ const results = [];
2475
+ const seen = /* @__PURE__ */ new Set();
2476
+ const meta = await readMeta();
2477
+ const metadataMap = await readMetadataMap();
2478
+ if (registry) {
2479
+ const liveEntries = addonId ? registry.getAllForAddon(addonId).map((device) => ({
2480
+ addonId,
2481
+ device
2482
+ })) : registry.getAllWithAddonId();
2483
+ for (const { addonId: aid, device } of liveEntries) {
2484
+ const key = deviceKey(aid, device.stableId);
2485
+ const metadata = metadataMap[key] ?? null;
2486
+ const metaRow = meta[key] ?? null;
2487
+ results.push(toDeviceInfo(aid, device, metadata, metaRow));
2488
+ seen.add(key);
2489
+ }
2490
+ }
2491
+ const index = await readIndex();
2492
+ const targetAddons = addonId ? [addonId] : Object.keys(index);
2493
+ for (const aid of targetAddons) for (const stableId of index[aid] ?? []) {
2494
+ const key = deviceKey(aid, stableId);
2495
+ if (seen.has(key)) continue;
2496
+ const m = meta[key];
2497
+ const persistedType = m.type;
2498
+ const persistedConfig = await settings.readDeviceStore(m.id);
2499
+ const metadata = metadataMap[key] ?? null;
2500
+ results.push({
2501
+ id: m.id,
2502
+ stableId,
2503
+ addonId: aid,
2504
+ type: persistedType,
2505
+ name: m?.name ?? stableId,
2506
+ location: m?.location ?? null,
2507
+ disabled: m?.disabled ?? false,
2508
+ parentDeviceId: m?.parentDeviceId ?? null,
2509
+ role: toDeviceRole(m?.role),
2510
+ online: this.resolveDeviceOnline(m.id, registry !== null),
2511
+ probed: this.resolveDeviceProbed(m.id),
2512
+ features: persistedFeatures(m?.features),
2513
+ isCamera: persistedType === DeviceType.Camera,
2514
+ config: persistedConfig ?? {},
2515
+ metadata,
2516
+ ...m?.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
2517
+ ...m?.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
2518
+ ...m?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2519
+ ...m?.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2520
+ ...m?.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2521
+ ...(() => {
2522
+ const si = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
2523
+ return si !== void 0 ? { sourceInfo: si } : {};
2524
+ })()
2525
+ });
2526
+ }
2527
+ return results;
2528
+ },
2529
+ getDevice: async (input) => {
2530
+ const { deviceId } = input;
2531
+ if (registry) {
2532
+ const found = resolveDeviceById(registry, deviceId);
2533
+ if (found) {
2534
+ const key = deviceKey(found.addonId, found.device.stableId);
2535
+ const [map, metaMap] = await Promise.all([readMetadataMap(), readMeta()]);
2536
+ const metadata = map[key] ?? null;
2537
+ const metaRow = metaMap[key] ?? null;
2538
+ return toDeviceInfo(found.addonId, found.device, metadata, metaRow);
2539
+ }
2540
+ }
2541
+ const persisted = await resolvePersistedById(deviceId);
2542
+ if (!persisted) return null;
2543
+ const { addonId: aid, stableId, meta: m } = persisted;
2544
+ const persistedConfig = await settings.readDeviceStore(m.id);
2545
+ const key = deviceKey(aid, stableId);
2546
+ const metadata = (await readMetadataMap())[key] ?? null;
2547
+ const sourceInfoGetDevice = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
2548
+ return {
2549
+ id: deviceId,
2550
+ stableId,
2551
+ addonId: aid,
2552
+ type: m.type,
2553
+ name: m.name,
2554
+ location: m.location ?? null,
2555
+ disabled: m.disabled ?? false,
2556
+ parentDeviceId: m.parentDeviceId,
2557
+ role: toDeviceRole(m.role),
2558
+ online: this.resolveDeviceOnline(deviceId, true),
2559
+ probed: this.resolveDeviceProbed(deviceId),
2560
+ features: persistedFeatures(m.features),
2561
+ isCamera: false,
2562
+ config: persistedConfig ?? {},
2563
+ metadata,
2564
+ ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
2565
+ ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
2566
+ ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2567
+ ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2568
+ ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2569
+ ...sourceInfoGetDevice !== void 0 ? { sourceInfo: sourceInfoGetDevice } : {}
2570
+ };
2571
+ },
2572
+ getChildren: async (input) => {
2573
+ const { parentDeviceId } = input;
2574
+ let ownerAddonId = null;
2575
+ if (registry) {
2576
+ if (registry.getById(parentDeviceId)) ownerAddonId = registry.getAddonId(parentDeviceId);
2577
+ }
2578
+ if (!ownerAddonId) {
2579
+ const persisted = await resolvePersistedById(parentDeviceId);
2580
+ if (!persisted) return [];
2581
+ ownerAddonId = persisted.addonId;
2582
+ }
2583
+ const results = [];
2584
+ const seen = /* @__PURE__ */ new Set();
2585
+ const [index, meta, metadataMap] = await Promise.all([
2586
+ readIndex(),
2587
+ readMeta(),
2588
+ readMetadataMap()
2589
+ ]);
2590
+ if (registry) {
2591
+ const liveChildren = registry.getChildren(parentDeviceId);
2592
+ for (const device of liveChildren) {
2593
+ const key = deviceKey(ownerAddonId, device.stableId);
2594
+ const metadata = metadataMap[key] ?? null;
2595
+ const metaRow = meta[key] ?? null;
2596
+ results.push(toDeviceInfo(ownerAddonId, device, metadata, metaRow));
2597
+ seen.add(key);
2598
+ }
2599
+ }
2600
+ const persistedChildren = (index[ownerAddonId] ?? []).filter((sid) => meta[deviceKey(ownerAddonId, sid)]?.parentDeviceId === parentDeviceId);
2601
+ for (const childStableId of persistedChildren) {
2602
+ const key = deviceKey(ownerAddonId, childStableId);
2603
+ if (seen.has(key)) continue;
2604
+ const m = meta[key];
2605
+ const persistedConfig = await settings.readDeviceStore(m.id);
2606
+ const metadata = metadataMap[key] ?? null;
2607
+ const sourceInfoChild = buildSourceInfoFromConfig(persistedConfig ?? {}, childStableId, ownerAddonId);
2608
+ results.push({
2609
+ id: m.id,
2610
+ stableId: childStableId,
2611
+ addonId: ownerAddonId,
2612
+ type: m.type,
2613
+ name: m.name,
2614
+ location: m.location ?? null,
2615
+ disabled: m.disabled ?? false,
2616
+ parentDeviceId,
2617
+ role: toDeviceRole(m.role),
2618
+ online: this.resolveDeviceOnline(m.id, registry !== null),
2619
+ probed: this.resolveDeviceProbed(m.id),
2620
+ features: persistedFeatures(m.features),
2621
+ isCamera: false,
2622
+ config: persistedConfig ?? {},
2623
+ metadata,
2624
+ ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
2625
+ ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
2626
+ ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2627
+ ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2628
+ ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2629
+ ...sourceInfoChild !== void 0 ? { sourceInfo: sourceInfoChild } : {}
2630
+ });
2631
+ }
2632
+ return results;
2633
+ },
2634
+ getStreamSources: async (input) => {
2635
+ const { deviceId } = input;
2636
+ if (registry) {
2637
+ const found = resolveDeviceById(registry, deviceId);
2638
+ if (found) {
2639
+ if (!isCameraDevice(found.device)) return [];
2640
+ return (await found.device.getStreamSources()).map((s) => ({
2641
+ id: s.id,
2642
+ label: s.label,
2643
+ protocol: s.protocol,
2644
+ url: s.url,
2645
+ resolution: s.resolution,
2646
+ fps: s.fps,
2647
+ bitrate: s.bitrate,
2648
+ codec: s.codec,
2649
+ profileHint: s.profileHint
2650
+ }));
2651
+ }
2652
+ }
2653
+ if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
2654
+ return (await requireDeviceOps(deviceId).getStreamSources({ deviceId })).map((s) => ({ ...s }));
2655
+ },
2656
+ getConfigSchema: async (input) => {
2657
+ const { deviceId } = input;
2658
+ if (registry) {
2659
+ const found = resolveDeviceById(registry, deviceId);
2660
+ if (found) return found.device.config.entries().map((entry) => ({
2661
+ key: entry.key,
2662
+ value: entry.value,
2663
+ ...entry.description !== void 0 ? { description: entry.description } : {}
2664
+ }));
2665
+ }
2666
+ if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
2667
+ return (await requireDeviceOps(deviceId).getConfigEntries({ deviceId })).map((e) => ({ ...e }));
2668
+ },
2669
+ getSettingsSchema: async (input) => {
2670
+ const { deviceId } = input;
2671
+ if (registry) {
2672
+ const found = resolveDeviceById(registry, deviceId);
2673
+ if (found) return found.device.getSettingsUISchema();
2674
+ }
2675
+ if (!await resolvePersistedById(deviceId)) return null;
2676
+ return await requireDeviceOps(deviceId).getSettingsSchema({ deviceId }) ?? null;
2677
+ },
2678
+ updateConfig: async (input) => {
2679
+ const { deviceId } = input;
2680
+ if (registry) {
2681
+ const found = resolveDeviceById(registry, deviceId);
2682
+ if (found) {
2683
+ await found.device.config.setAll(input.values);
2684
+ return { success: true };
2685
+ }
2686
+ }
2687
+ if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
2688
+ await requireDeviceOps(deviceId).setConfig({
2689
+ deviceId,
2690
+ values: input.values
2691
+ });
2692
+ return { success: true };
2693
+ },
2694
+ enable: async (input) => {
2695
+ await provider.setDisabled({
2696
+ deviceId: input.deviceId,
2697
+ disabled: false
2698
+ });
2699
+ return { success: true };
2700
+ },
2701
+ disable: async (input) => {
2702
+ await provider.setDisabled({
2703
+ deviceId: input.deviceId,
2704
+ disabled: true
2705
+ });
2706
+ return { success: true };
2707
+ },
2708
+ remove: async (input) => {
2709
+ const { deviceId } = input;
2710
+ const directChildIds = async (parentId) => {
2711
+ const ids = /* @__PURE__ */ new Set();
2712
+ if (registry) for (const c of registry.getChildren(parentId)) ids.add(c.id);
2713
+ const meta = await readMeta();
2714
+ for (const m of Object.values(meta)) if (m.parentDeviceId === parentId) ids.add(m.id);
2715
+ ids.delete(parentId);
2716
+ return [...ids];
2717
+ };
2718
+ const removeOne = async (id) => {
2719
+ if (registry) {
2720
+ const live = resolveDeviceById(registry, id);
2721
+ if (live) {
2722
+ const deviceName = live.device.name;
2723
+ await live.device.removeDevice();
2724
+ registry.remove(id);
2725
+ await provider.removeDevice({ deviceId: id });
2726
+ this.ctx.logger.info("removed hub-local device", { tags: {
2727
+ deviceId: id,
2728
+ deviceName
2729
+ } });
2730
+ return;
2731
+ }
2732
+ }
2733
+ const persisted = await resolvePersistedById(id);
2734
+ if (!persisted) return;
2735
+ const { meta: persistedMeta } = persisted;
2736
+ try {
2737
+ await requireDeviceOps(id).removeDevice({ deviceId: id });
2738
+ } catch (err) {
2739
+ this.ctx.logger.warn("remove via device-ops failed — clearing persistence anyway", {
2740
+ tags: {
2741
+ deviceId: id,
2742
+ deviceName: persistedMeta.name
2743
+ },
2744
+ meta: { error: errMsg(err) }
2745
+ });
2746
+ }
2747
+ await provider.removeDevice({ deviceId: id });
2748
+ };
2749
+ const removeCascade = async (id) => {
2750
+ for (const childId of await directChildIds(id)) await removeCascade(childId);
2751
+ await removeOne(id);
2752
+ };
2753
+ await removeCascade(deviceId);
2754
+ return { success: true };
2755
+ },
2756
+ /**
2757
+ * Cascade-delete every top-level device whose `integrationId`
2758
+ * matches. Enumerates a SNAPSHOT of the meta map so concurrent
2759
+ * removals don't clobber each other. Only top-level parents are
2760
+ * enumerated — children cascade via the per-parent `removeCascade`
2761
+ * inside the delegated `remove` call. Idempotent: devices with no
2762
+ * `integrationId` never match.
2763
+ */
2764
+ removeByIntegration: async (input) => {
2765
+ const { integrationId } = input;
2766
+ const meta = await readMeta();
2767
+ const parentKeys = Object.keys(meta).filter((key) => {
2768
+ const m = meta[key];
2769
+ return m !== void 0 && m.integrationId === integrationId && m.parentDeviceId === null;
2770
+ });
2771
+ let removed = 0;
2772
+ for (const _key of parentKeys) {
2773
+ const m = meta[_key];
2774
+ if (!m) continue;
2775
+ await provider.remove({ deviceId: m.id });
2776
+ removed++;
2777
+ }
2778
+ return { removed };
2779
+ },
2780
+ getStreamProfileMap: async (input) => {
2781
+ if (!registry) return {};
2782
+ const found = resolveDeviceById(registry, input.deviceId);
2783
+ if (!found) return {};
2784
+ const storedMap = found.device.config.entries().find((e) => e.key === "_profileMap")?.value;
2785
+ if (storedMap !== void 0 && typeof storedMap === "object" && storedMap !== null) return storedMap;
2786
+ if (!isCameraDevice(found.device)) return {};
2787
+ const sources = await found.device.getStreamSources();
2788
+ const profileMap = {};
2789
+ for (const s of sources) if (s.profileHint && s.id) profileMap[s.profileHint] = s.id;
2790
+ return profileMap;
2791
+ },
2792
+ setStreamProfileMap: async (input) => {
2793
+ const { deviceId } = input;
2794
+ if (registry) {
2795
+ const found = resolveDeviceById(registry, deviceId);
2796
+ if (found) {
2797
+ await found.device.config.setAll({ _profileMap: input.profileMap });
2798
+ return { success: true };
2799
+ }
2800
+ }
2801
+ if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
2802
+ await requireDeviceOps(deviceId).setConfig({
2803
+ deviceId,
2804
+ values: { _profileMap: input.profileMap }
2805
+ });
2806
+ return { success: true };
2807
+ },
2808
+ probeStreams: async (input) => {
2809
+ const streamProbe = this.ctx.kernel.streamProbe;
2810
+ if (!streamProbe) return [];
2811
+ const sources = await provider.getStreamSources({ deviceId: input.deviceId });
2812
+ const results = [];
2813
+ for (const s of sources) {
2814
+ if (!s.url) continue;
2815
+ try {
2816
+ const metadata = await streamProbe.probe(s.url, { force: true });
2817
+ results.push({
2818
+ streamId: s.id,
2819
+ width: metadata.width,
2820
+ height: metadata.height,
2821
+ codec: metadata.codec,
2822
+ fps: metadata.fps,
2823
+ bitrateKbps: metadata.bitrateKbps
2824
+ });
2825
+ } catch (err) {
2826
+ this.ctx.logger.debug("streamProbe.probe failed — returning placeholder", { meta: {
2827
+ deviceId: input.deviceId,
2828
+ streamId: s.id,
2829
+ error: err instanceof Error ? err.message : String(err)
2830
+ } });
2831
+ results.push({ streamId: s.id });
2832
+ }
2833
+ }
2834
+ return results;
2835
+ },
2836
+ discoverDevices: async (input) => {
2837
+ const dp = await this.requireDeviceProvider(input.addonId);
2838
+ if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device discovery`);
2839
+ return (await dp.discoverDevices({})).map((d) => ({
2840
+ stableId: d.stableId,
2841
+ type: d.type,
2842
+ suggestedName: d.suggestedName,
2843
+ prefilledConfig: d.prefilledConfig
2844
+ }));
2845
+ },
2846
+ adoptDevice: async (input) => {
2847
+ const dp = await this.requireDeviceProvider(input.addonId);
2848
+ if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device adoption`);
2849
+ const summary = await dp.adoptDiscoveredDevice({ candidate: input.candidate });
2850
+ if (input.integrationId !== void 0) try {
2851
+ await stampIntegrationId(summary.id, input.integrationId);
2852
+ } catch (err) {
2853
+ this.ctx.logger.warn("adoptDevice: integrationId stamp failed (device adopted)", {
2854
+ tags: {
2855
+ deviceId: summary.id,
2856
+ integrationId: input.integrationId
2857
+ },
2858
+ meta: { error: errMsg(err) }
2859
+ });
2860
+ }
2861
+ return summary;
2862
+ },
2863
+ getCreationSchema: async (input) => {
2864
+ const dp = await this.requireDeviceProvider(input.addonId);
2865
+ if (!await dp.supportsManualCreation({})) return null;
2866
+ return await dp.getChildCreationSchema({ type: input.type }) ?? null;
2867
+ },
2868
+ createDevice: async (input) => {
2869
+ const dp = await this.requireDeviceProvider(input.addonId);
2870
+ if (!await dp.supportsManualCreation({})) throw new Error(`Addon "${input.addonId}" does not support manual device creation`);
2871
+ const summary = await dp.createDevice({
2872
+ type: input.type,
2873
+ config: input.config
2874
+ });
2875
+ if (input.integrationId !== void 0) try {
2876
+ await stampIntegrationId(summary.id, input.integrationId);
2877
+ } catch (err) {
2878
+ this.ctx.logger.warn("createDevice: integrationId stamp failed (device created)", {
2879
+ tags: {
2880
+ deviceId: summary.id,
2881
+ integrationId: input.integrationId
2882
+ },
2883
+ meta: { error: errMsg(err) }
2884
+ });
2885
+ }
2886
+ return summary;
2887
+ },
2888
+ testCreationField: async (input) => {
2889
+ return (await this.requireDeviceProvider(input.addonId)).testCreationField({
2890
+ type: input.type,
2891
+ key: input.key,
2892
+ value: input.value,
2893
+ ...input.formValues !== void 0 ? { formValues: input.formValues } : {}
2894
+ });
2895
+ },
2896
+ adoptionListCandidates: async ({ addonId, ...rest }) => {
2897
+ return (await this.requireDeviceAdoptionProvider(addonId)).listCandidates(rest);
2898
+ },
2899
+ adoptionRefresh: async ({ addonId, integrationId }) => {
2900
+ return (await this.requireDeviceAdoptionProvider(addonId)).refresh({ integrationId });
2901
+ },
2902
+ adoptionAdopt: async ({ addonId, ...rest }) => {
2903
+ return (await this.requireDeviceAdoptionProvider(addonId)).adopt(rest);
2904
+ },
2905
+ adoptionRelease: async ({ addonId, ...rest }) => {
2906
+ return (await this.requireDeviceAdoptionProvider(addonId)).release(rest);
2907
+ },
2908
+ testField: async (input) => {
2909
+ const { deviceId } = input;
2910
+ let owningAddonId = null;
2911
+ if (registry) owningAddonId = registry.getAddonId(deviceId);
2912
+ if (!owningAddonId) owningAddonId = (await resolvePersistedById(deviceId))?.addonId ?? null;
2913
+ if (!owningAddonId) throw new Error(`Device with id ${deviceId} not found`);
2914
+ const dp = await this.waitDeviceProvider(owningAddonId);
2915
+ if (!dp) return {
2916
+ status: "ok",
2917
+ labels: [],
2918
+ error: void 0
2919
+ };
2920
+ if (typeof dp.testCreationField !== "function") return {
2921
+ status: "ok",
2922
+ labels: [],
2923
+ error: void 0
2924
+ };
2925
+ return dp.testCreationField({
2926
+ type: DeviceType.Camera,
2927
+ key: input.key,
2928
+ value: input.value
2929
+ });
2930
+ },
2931
+ getBindings: async (input) => {
2932
+ const result = await this.getBindings({ deviceId: input.deviceId });
2933
+ return {
2934
+ deviceId: input.deviceId,
2935
+ entries: result.entries
2936
+ };
2937
+ },
2938
+ getAllBindings: async () => {
2939
+ return this.getAllBindings();
2940
+ },
2941
+ setWrapperActive: async (input) => {
2942
+ return this.setWrapperActive({
2943
+ deviceId: input.deviceId,
2944
+ capName: input.capName,
2945
+ wrapperAddonId: input.wrapperAddonId,
2946
+ active: input.active
2947
+ });
2948
+ },
2949
+ listWrappersForCap: async (input) => this.listWrappersForCap(input),
2950
+ listBindableCapsForDeviceType: async (input) => this.listBindableCapsForDeviceType(input),
2951
+ getDeviceSettingsAggregate: async (input) => {
2952
+ return this.getDeviceAggregate(input.deviceId, "settings");
2953
+ },
2954
+ getDeviceLiveInfoAggregate: async (input) => {
2955
+ return this.getDeviceAggregate(input.deviceId, "live");
2956
+ },
2957
+ getDeviceAggregate: async (input) => {
2958
+ const [settings, live] = await Promise.all([this.getDeviceAggregate(input.deviceId, "settings"), this.getDeviceAggregate(input.deviceId, "live")]);
2959
+ return {
2960
+ settings,
2961
+ live
2962
+ };
2963
+ },
2964
+ runDeviceAction: async (input) => {
2965
+ return this.dispatchDeviceAction(input.deviceId, input.action, input.input);
2966
+ },
2967
+ updateDeviceField: async (input) => {
2968
+ return this.updateDeviceField({
2969
+ deviceId: input.deviceId,
2970
+ writerCapName: input.writerCapName,
2971
+ writerAddonId: input.writerAddonId,
2972
+ key: input.key,
2973
+ value: input.value
2974
+ });
2975
+ },
2976
+ updateDeviceFieldsBatch: async (input) => {
2977
+ return this.updateDeviceFieldsBatch({
2978
+ deviceId: input.deviceId,
2979
+ changes: input.changes
2980
+ });
2981
+ },
2982
+ getDeviceStatusAggregate: async (input) => this.getDeviceStatusAggregate(input),
2983
+ getWireableFields: async ({ deviceId }) => {
2984
+ const reg = this.capabilityRegistry;
2985
+ if (!reg) return { caps: [] };
2986
+ const { entries } = await this.getBindings({ deviceId });
2987
+ const seen = /* @__PURE__ */ new Set();
2988
+ const caps = [];
2989
+ for (const entry of entries) {
2990
+ if (seen.has(entry.capName)) continue;
2991
+ seen.add(entry.capName);
2992
+ const def = reg.getDefinition(entry.capName);
2993
+ if (!def || def.kind === "wrapper") continue;
2994
+ const schema = def.status?.schema;
2995
+ if (!schema) continue;
2996
+ const fields = enumerateSchemaFields(schema).map((f) => f.enumValues !== void 0 ? {
2997
+ path: f.path,
2998
+ kind: f.kind,
2999
+ enumValues: [...f.enumValues]
3000
+ } : {
3001
+ path: f.path,
3002
+ kind: f.kind
3003
+ });
3004
+ if (fields.length > 0) caps.push({
3005
+ cap: entry.capName,
3006
+ fields
3007
+ });
3008
+ }
3009
+ return { caps };
3010
+ }
3011
+ };
3012
+ this.ctx.logger.info("registered device-manager capability", { meta: { liveRegistry: registry !== null } });
3013
+ if (registry) {
3014
+ this.propagator = new DeviceEventPropagator({
3015
+ eventBus: this.ctx.eventBus,
3016
+ getParentOf: (id) => registry.getById(id)?.parentDeviceId ?? null,
3017
+ logger: {
3018
+ warn: (msg, meta) => this.ctx.logger.warn(msg, meta ?? {}),
3019
+ debug: (msg, meta) => this.ctx.logger.debug(msg, meta ?? {})
3020
+ }
3021
+ });
3022
+ this.propagator.start();
3023
+ this.ctx.logger.info("device-event-propagator started");
3024
+ }
3025
+ return [{
3026
+ capability: deviceManagerCapability,
3027
+ provider
3028
+ }, {
3029
+ capability: deviceStateCapability,
3030
+ provider: {
3031
+ getSnapshot: async (input) => {
3032
+ return this.snapshotForDeviceOverlayed(input.deviceId);
3033
+ },
3034
+ getCapSlice: async (input) => {
3035
+ return this.overlayedSlice(input.deviceId, input.capName);
3036
+ },
3037
+ getAllSnapshots: async () => {
3038
+ const out = {};
3039
+ for (const [deviceId, perCap] of this.stateMirror) {
3040
+ const dev = {};
3041
+ for (const [capName, slice] of perCap) dev[capName] = this.overlayedSlice(deviceId, capName) ?? { ...slice };
3042
+ out[String(deviceId)] = dev;
3043
+ }
3044
+ return out;
3045
+ },
3046
+ setCapSlice: async (input) => {
3047
+ const { deviceId, capName, slice } = input;
3048
+ if (!await resolvePersistedById(deviceId)) throw new Error(`[device-manager] setCapSlice: unknown device id=${deviceId}`);
3049
+ this.applySingleCapUpdate(deviceId, capName, slice);
3050
+ this.scheduleRuntimeStateDiskWrite(deviceId, settings);
3051
+ }
3052
+ }
3053
+ }];
3054
+ }
3055
+ /**
3056
+ * Single-cap mirror update — diff against the current mirror,
3057
+ * persist the new slice in-memory, emit `DeviceStateChanged` for
3058
+ * this cap. No-op on identical writes (both same shape and same
3059
+ * values). Called by `setCapSlice` provider.
3060
+ */
3061
+ applySingleCapUpdate(deviceId, capName, slice) {
3062
+ let perCap = this.stateMirror.get(deviceId);
3063
+ if (!perCap) {
3064
+ perCap = /* @__PURE__ */ new Map();
3065
+ this.stateMirror.set(deviceId, perCap);
3066
+ }
3067
+ const prior = perCap.get(capName);
3068
+ if (prior && shallowEqual(prior, slice)) return;
3069
+ perCap.set(capName, { ...slice });
3070
+ this.emitOverlayed(deviceId, capName);
3071
+ const deps = this.linkDependents.get(`${deviceId}:${capName}`);
3072
+ if (deps) for (const d of deps) this.emitOverlayed(d.targetDeviceId, d.targetCap);
3073
+ }
3074
+ /**
3075
+ * Debounced disk writer. Coalesces frequent writes (motion phase
3076
+ * transitions, battery pushes) into one `writeDeviceRuntimeState`
3077
+ * per `RUNTIME_STATE_DEBOUNCE_MS` window. Reads the per-device
3078
+ * blob from the live mirror at flush time so the disk picture is
3079
+ * always the latest state — no risk of writing a stale snapshot.
3080
+ */
3081
+ scheduleRuntimeStateDiskWrite(deviceId, settings) {
3082
+ let slot = this.runtimeStateDebounce.get(deviceId);
3083
+ if (!slot) {
3084
+ slot = {
3085
+ timer: null,
3086
+ inFlight: null
3087
+ };
3088
+ this.runtimeStateDebounce.set(deviceId, slot);
3089
+ }
3090
+ if (slot.timer) return;
3091
+ slot.timer = setTimeout(() => {
3092
+ slot.timer = null;
3093
+ const blob = this.snapshotForDevice(deviceId);
3094
+ const write = (async () => {
3095
+ try {
3096
+ await settings.writeDeviceRuntimeState(deviceId, blob);
3097
+ } catch (err) {
3098
+ this.ctx.logger.warn("writeDeviceRuntimeState failed", {
3099
+ tags: { deviceId },
3100
+ meta: { error: err instanceof Error ? err.message : String(err) }
3101
+ });
3102
+ } finally {
3103
+ slot.inFlight = null;
3104
+ }
3105
+ })();
3106
+ slot.inFlight = write;
3107
+ }, DeviceManagerAddon.RUNTIME_STATE_DEBOUNCE_MS);
3108
+ }
3109
+ /**
3110
+ * One-shot mirror seed used by `loadRuntimeState` at boot so the
3111
+ * hub knows about every persisted slice without waiting for the
3112
+ * first `setCapSlice` call. No events emitted — this is
3113
+ * initial-state population, not a transition.
3114
+ *
3115
+ * Callers that must not carry a stale per-session probe across a
3116
+ * restart pass the blob through `withResetSessionProbe` first (see
3117
+ * `loadRuntimeState`).
3118
+ */
3119
+ seedMirror(deviceId, blob) {
3120
+ let perCap = this.stateMirror.get(deviceId);
3121
+ if (!perCap) {
3122
+ perCap = /* @__PURE__ */ new Map();
3123
+ this.stateMirror.set(deviceId, perCap);
3124
+ }
3125
+ for (const [capName, raw] of Object.entries(blob)) {
3126
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
3127
+ perCap.set(capName, { ...raw });
3128
+ }
3129
+ }
3130
+ /**
3131
+ * The hub mirror's `feature-probe.lastProbedAt` is a PER-SESSION liveness
3132
+ * signal — it means "this worker process completed a probe THIS session".
3133
+ * Persisted runtime state carries the PRE-RESTART timestamp, which is stale
3134
+ * after a hub or worker restart: the device has not re-probed yet. Seeding it
3135
+ * verbatim makes `resolveDeviceProbed` report `probed:true` during the
3136
+ * restart→reprobe window, which defeats the export carry-forward gate
3137
+ * (`resolveExportFingerprint`, gated on `device.probed`) and posts a spurious
3138
+ * partial `AddOrUpdateReport` to Alexa/HAP before the real probe lands.
3139
+ *
3140
+ * Reset `lastProbedAt` to 0 for the MIRROR seed only — `probed:false` carries
3141
+ * the last-advertised fingerprint forward until the worker republishes a
3142
+ * fresh probe (its post-probe `setCapSlice` raises `lastProbedAt` again, which
3143
+ * also fires `DeviceReady`). The worker's returned `initialRuntimeState` blob
3144
+ * is untouched, and every non-probe slice (e.g. `device-status`/online) is
3145
+ * preserved.
3146
+ */
3147
+ withResetSessionProbe(blob) {
3148
+ const probe = blob["feature-probe"];
3149
+ if (!probe || typeof probe !== "object" || Array.isArray(probe)) return blob;
3150
+ return {
3151
+ ...blob,
3152
+ "feature-probe": {
3153
+ ...probe,
3154
+ lastProbedAt: 0
3155
+ }
3156
+ };
3157
+ }
3158
+ /**
3159
+ * Resolve a device's REAL `online` flag for the persisted/forked-worker
3160
+ * list branch. Forked workers own the live `IDevice` in their own process,
3161
+ * so the hub registry can't read `device.online` directly. The owning
3162
+ * driver instead publishes its liveness through the auto-registered
3163
+ * `device-status` runtime-state slice (`markOnline` → `setCapState`), which
3164
+ * the canonical `deviceState.setCapSlice` write entrypoint mirrors into the
3165
+ * hub-side `stateMirror`. We read that mirrored slice here so the list
3166
+ * payload reflects the device's actual reachability instead of a constant.
3167
+ *
3168
+ * Fallback (`fallbackOnline`) preserves the legacy behaviour when no slice
3169
+ * has been published yet: a persisted device with a live registry is
3170
+ * assumed online (it was successfully registered by its owning process),
3171
+ * and the null-registry "offline view" keeps reporting offline. We never
3172
+ * regress a device to offline merely because its mirror is empty.
3173
+ */
3174
+ resolveDeviceOnline(deviceId, fallbackOnline) {
3175
+ const raw = this.stateMirror.get(deviceId)?.get(deviceStatusCapability.name);
3176
+ if (!raw) return fallbackOnline;
3177
+ const parsed = DeviceStatusSchema.safeParse(raw);
3178
+ if (!parsed.success) return fallbackOnline;
3179
+ return parsed.data.online;
3180
+ }
3181
+ /**
3182
+ * Derive the `probed` flag for an offline-view (forked-worker, not
3183
+ * live in the hub registry) device projection. Reads the mirrored
3184
+ * `feature-probe` slice the owning worker publishes. Mirrors the
3185
+ * `toDeviceInfo` rule: no mirrored slice → ready (`true`, no probe seen);
3186
+ * slice present → ready iff `lastProbedAt` has advanced past 0. The
3187
+ * mirror is populated when the worker's first `setCapSlice` RPC arrives
3188
+ * (BaseDevice seeds `feature-probe` `lastProbedAt:0` at construction, but
3189
+ * cross-process delivery is async); until then the no-entry path returns
3190
+ * `true` — a brief transient window, same as `resolveDeviceOnline`.
3191
+ */
3192
+ resolveDeviceProbed(deviceId) {
3193
+ const raw = this.stateMirror.get(deviceId)?.get("feature-probe");
3194
+ if (!raw) return true;
3195
+ return (typeof raw.lastProbedAt === "number" ? raw.lastProbedAt : 0) > 0;
3196
+ }
3197
+ snapshotForDevice(deviceId) {
3198
+ const perCap = this.stateMirror.get(deviceId);
3199
+ if (!perCap) return {};
3200
+ const out = {};
3201
+ for (const [k, v] of perCap) out[k] = { ...v };
3202
+ return out;
3203
+ }
3204
+ /**
3205
+ * Read-time overlay of a cap slice with its cross-device linked values.
3206
+ * Returns a cloned raw mirror slice when the (device, cap) pair has no
3207
+ * links. Sources are read from the same in-hub stateMirror — sync, no
3208
+ * cross-process call. The disk writer must NOT use this method; it must
3209
+ * persist raw provider truth via snapshotForDevice.
3210
+ */
3211
+ overlayedSlice(deviceId, cap) {
3212
+ const raw = this.stateMirror.get(deviceId)?.get(cap) ?? null;
3213
+ const links = this.linkTargets.get(`${deviceId}:${cap}`);
3214
+ if (!links || links.length === 0) return raw ? { ...raw } : null;
3215
+ const resolved = links.map((rl) => ({
3216
+ link: rl.link,
3217
+ sourceValue: applyTransform(getByPath(this.stateMirror.get(rl.sourceDeviceId)?.get(rl.link.source.cap), rl.link.source.fieldPath), rl.link.transform)
3218
+ }));
3219
+ const schema = this.capabilityRegistry?.getDefinition(cap)?.status?.schema;
3220
+ return mergeLinkedStatus(raw ? { ...raw } : {}, resolved, schema);
3221
+ }
3222
+ /**
3223
+ * Like snapshotForDevice but applies the device-link overlay per cap.
3224
+ * Used exclusively by the device-state READ methods (getSnapshot,
3225
+ * getAllSnapshots) so callers see overlayed values. The debounced disk
3226
+ * writer must continue to call snapshotForDevice (raw truth).
3227
+ */
3228
+ snapshotForDeviceOverlayed(deviceId) {
3229
+ const perCap = this.stateMirror.get(deviceId);
3230
+ if (!perCap) return {};
3231
+ const out = {};
3232
+ for (const capName of perCap.keys()) {
3233
+ const s = this.overlayedSlice(deviceId, capName);
3234
+ if (s) out[capName] = s;
3235
+ }
3236
+ return out;
3237
+ }
3238
+ emitStateChanged(deviceId, capName, slice) {
3239
+ this.ctx.eventBus.emit({
3240
+ id: randomUUID(),
3241
+ timestamp: /* @__PURE__ */ new Date(),
3242
+ source: {
3243
+ type: "device",
3244
+ id: deviceId
3245
+ },
3246
+ category: EventCategory.DeviceStateChanged,
3247
+ data: {
3248
+ deviceId,
3249
+ capName,
3250
+ slice
3251
+ }
3252
+ });
3253
+ }
3254
+ /** Emit DeviceStateChanged for (deviceId, cap) using the OVERLAID slice,
3255
+ * skipping when the overlay is unchanged since the last emit (loop/churn
3256
+ * guard). Used for the written pair AND its dependent targets. */
3257
+ emitOverlayed(deviceId, cap) {
3258
+ const slice = this.overlayedSlice(deviceId, cap);
3259
+ if (!slice) return;
3260
+ const key = `${deviceId}:${cap}`;
3261
+ const prev = this.lastEmittedOverlay.get(key);
3262
+ if (prev && shallowEqual(prev, slice)) return;
3263
+ this.lastEmittedOverlay.set(key, slice);
3264
+ this.emitStateChanged(deviceId, cap, slice);
3265
+ }
3266
+ async onShutdown() {
3267
+ this.propagator?.stop();
3268
+ this.propagator = null;
3269
+ const settings = this.ctx.settings;
3270
+ const pending = [];
3271
+ for (const [deviceId, slot] of this.runtimeStateDebounce) {
3272
+ if (slot.timer) {
3273
+ clearTimeout(slot.timer);
3274
+ slot.timer = null;
3275
+ if (settings) {
3276
+ const blob = this.snapshotForDevice(deviceId);
3277
+ pending.push(settings.writeDeviceRuntimeState(deviceId, blob).catch((err) => {
3278
+ this.ctx.logger.warn("shutdown writeDeviceRuntimeState failed", {
3279
+ tags: { deviceId },
3280
+ meta: { error: err instanceof Error ? err.message : String(err) }
3281
+ });
3282
+ }));
3283
+ }
3284
+ }
3285
+ if (slot.inFlight) pending.push(slot.inFlight);
3286
+ }
3287
+ await Promise.all(pending);
3288
+ this.runtimeStateDebounce.clear();
3289
+ }
3290
+ };
3291
+ //#endregion
3292
+ export { DeviceManagerAddon, DeviceManagerAddon as default, mergeAggregates };