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