@camstack/system 1.1.13 → 1.1.14

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.
@@ -7,114 +7,34 @@ let _camstack_types_node = require("@camstack/types/node");
7
7
  let _camstack_types = require("@camstack/types");
8
8
  let node_crypto = require("node:crypto");
9
9
  let zod = require("zod");
10
- //#region src/builtins/device-manager/device-event-propagator.ts
11
10
  /**
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.
11
+ * Build the device-detail form section for a `derived-form` device-config
12
+ * cap. Returns null when the camera exposes no configurable property.
41
13
  */
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;
14
+ function deriveFormContribution(builderId, options, status) {
15
+ if (builderId !== "stream-params") throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
16
+ const schema = (0, _camstack_types.buildStreamParamsConfigSchema)(options, status ?? null);
17
+ if (!schema) return null;
18
+ return { sections: schema.sections.map((s) => ({
19
+ id: s.id,
20
+ title: s.title,
21
+ ...s.tab !== void 0 ? { tab: s.tab } : {},
22
+ ...s.order !== void 0 ? { order: s.order } : {},
23
+ ...s.description !== void 0 ? { description: s.description } : {},
24
+ ...s.columns !== void 0 ? { columns: s.columns } : {},
25
+ fields: [...s.fields]
26
+ })) };
27
+ }
28
+ /**
29
+ * Route a flat form patch back through the cap's per-profile setter.
30
+ */
31
+ async function applyDerivedFormPatch(builderId, patch, setProfile) {
32
+ if (builderId !== "stream-params") throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
33
+ for (const meta of _camstack_types.STREAM_PROFILE_META) {
34
+ const profilePatch = (0, _camstack_types.parseStreamParamsFormPatch)(patch, meta.prefix);
35
+ if (profilePatch) await setProfile(meta.profile, profilePatch);
116
36
  }
117
- };
37
+ }
118
38
  //#endregion
119
39
  //#region src/builtins/device-manager/device-aggregation-merge.ts
120
40
  /**
@@ -255,102 +175,241 @@ function mergeAggregates(parts) {
255
175
  return out;
256
176
  }
257
177
  //#endregion
258
- //#region src/builtins/device-manager/device-bindings-store.ts
178
+ //#region src/builtins/device-manager/device-projection.ts
259
179
  /**
260
- * Persisted wrapper-activation + per-device binding resolution for the
261
- * device-manager addon. Extracted from `device-manager.addon.ts` as free
262
- * functions that receive an explicit `BindingsDeps` context (dependency
263
- * injection) instead of closing over the addon instance keeps the routing
264
- * logic cohesive and independently testable while preserving the EXACT call
265
- * semantics, ordering, and side effects of the original class methods.
266
- *
267
- * The addon keeps thin delegator methods (`getBindings`, `getAllBindings`, …)
268
- * that build `BindingsDeps` from its own state and forward here, so every
269
- * existing internal + cap-router call site is unchanged.
180
+ * Pure device-projection + small predicate helpers for the device-manager
181
+ * addon. Extracted verbatim from `device-manager.addon.ts`. None of these
182
+ * functions close over the addon instance they take everything they need
183
+ * as explicit parameters, so they are safe to share across the addon's
184
+ * query / aggregation modules.
270
185
  */
271
186
  /**
272
- * Wire the push-fed cross-process native-cap cache (`remoteNativeCaps`).
273
- * Workers emit `DeviceBindingsChanged` on `ctx.registerNativeCap` / device
274
- * removal; we mirror those into `remoteNativeCaps` so `getBindings` returns the
275
- * full cluster view. Events from the local node are ignored: hub-local natives
276
- * live in `capabilityRegistry` and are folded in directly by getBindings.
187
+ * Return true when `err` is a transient Moleculer error that is worth
188
+ * retrying specifically any `MoleculerRetryableError` subclass
189
+ * (ServiceNotAvailableError, ServiceNotFoundError, BrokerDisconnectedError,
190
+ * RequestTimeoutError, …). Moleculer sets `retryable: true` on all of them.
277
191
  *
278
- * Push events are accurate in the steady state but can be lost during the
279
- * Moleculer transport handshake window (hub restart, crash-respawn,
280
- * restartAddon). The reliable replacement for lost events is the D3 re-handshake
281
- * (`listClusterNativeCaps()`), which `resolveNativeCapOwnerSync` /
282
- * `getBindings` step 4 fall through to on a push miss. The `$node.disconnected`
283
- * handler purges a gone node's entries; the worker re-handshakes (and re-emits
284
- * `native-registered`) on its next boot.
192
+ * Falls back to a message-substring check for serialised errors that arrive
193
+ * across the Moleculer transport as plain objects rather than real instances.
285
194
  */
286
- function wireRemoteNativeCapSync(ctx, remoteNativeCaps) {
287
- const localNodeId = ctx.kernel.localNodeId ?? "hub";
288
- ctx.eventBus.subscribe({ category: _camstack_types.EventCategory.DeviceBindingsChanged }, (event) => {
289
- const { deviceId, capName, reason, addonId, nodeId } = event.data;
290
- if (nodeId === localNodeId) return;
291
- if (reason === "native-registered") {
292
- let perDevice = remoteNativeCaps.get(deviceId);
293
- if (!perDevice) {
294
- perDevice = /* @__PURE__ */ new Map();
295
- remoteNativeCaps.set(deviceId, perDevice);
296
- }
297
- perDevice.set(capName, {
298
- addonId,
299
- nodeId
300
- });
301
- } else if (reason === "native-unregistered") {
302
- const perDevice = remoteNativeCaps.get(deviceId);
303
- if (!perDevice) return;
304
- perDevice.delete(capName);
305
- if (perDevice.size === 0) remoteNativeCaps.delete(deviceId);
306
- }
307
- });
308
- const cluster = ctx.kernel.cluster;
309
- if (cluster) cluster.broker.localBus.on("$node.disconnected", (payload) => {
310
- const gone = payload.node.id;
311
- const emptyDevices = [];
312
- for (const [deviceId, perDevice] of remoteNativeCaps) {
313
- const toDelete = [];
314
- for (const [capName, entry] of perDevice) if (entry.nodeId === gone) toDelete.push(capName);
315
- for (const capName of toDelete) perDevice.delete(capName);
316
- if (perDevice.size === 0) emptyDevices.push(deviceId);
317
- }
318
- for (const deviceId of emptyDevices) remoteNativeCaps.delete(deviceId);
319
- });
195
+ function isTransientMoleculerError(err) {
196
+ if (err !== null && typeof err === "object") {
197
+ const e = err;
198
+ if (e["retryable"] === true) return true;
199
+ const code = typeof e["code"] === "string" ? e["code"] : "";
200
+ if (code === "SERVICE_NOT_FOUND" || code === "SERVICE_NOT_AVAILABLE" || code === "REQUEST_TIMEOUT" || code === "BAD_GATEWAY") return true;
201
+ }
202
+ if (err instanceof Error) {
203
+ const msg = err.message;
204
+ if (msg.includes("is not available") || msg.includes("is not found") || msg.includes("transporter has disconnected") || msg.includes("Request timed out")) return true;
205
+ }
206
+ return false;
320
207
  }
321
- async function readBindingsStore(deps) {
322
- return { deviceBindings: (await deps.ctx.settings.readAddonStore()).deviceBindings ?? {} };
208
+ function shallowEqual(a, b) {
209
+ const ak = Object.keys(a);
210
+ const bk = Object.keys(b);
211
+ if (ak.length !== bk.length) return false;
212
+ for (const k of ak) if (a[k] !== b[k]) return false;
213
+ return true;
323
214
  }
324
- async function writeBindingsStore(deps, next) {
325
- await deps.ctx.settings.writeAddonStore({ deviceBindings: next.deviceBindings });
215
+ /** Returns true when `x` is a non-null, non-array plain object. */
216
+ function isRecord$1(x) {
217
+ return x !== null && typeof x === "object" && !Array.isArray(x);
326
218
  }
327
- function resolveWrapperNodeId(_wrapperAddonId) {
328
- return "hub";
219
+ function isCameraDevice(device) {
220
+ return "getStreamSources" in device && typeof device.getStreamSources === "function";
329
221
  }
222
+ var DEVICE_FEATURE_VALUES = new Set(Object.values(_camstack_types.DeviceFeature));
330
223
  /**
331
- * Reduce a provider node id to the routable form `DeviceProxy` can pin.
332
- *
333
- * Every addon runs in its own `addon-runner` with the composite node id
334
- * `${parentNodeId}/${runnerId}` (see `addon-runner.ts`) e.g.
335
- * `hub/provider-reolink` or `dev-agent-0/provider-reolink`. That composite is
336
- * an IDENTITY, not a routable target: the `CapRouteResolver` reaches a child
337
- * only THROUGH its parent (the hub resolves a hub-local-uds child by
338
- * cap+device; an agent forwards to its own child). `DeviceProxy` pins
339
- * `entry.providerNodeId` on every cap call, so a binding entry must expose the
340
- * parent node id — otherwise the explicit pin classifies as `remote-moleculer`
341
- * to an unknown node → `no-provider`, which surfaces as
342
- * "this camera doesn't expose …" for client-proxy-driven widget caps
343
- * (motion-zones, privacy-mask). Wrappers already report the parent via
344
- * `resolveWrapperNodeId`; this aligns natives with the same contract.
345
- *
346
- * A flat node id (a genuine standalone node with no `/`) is returned
347
- * unchanged.
224
+ * Validate persisted feature strings against the `DeviceFeature` enum
225
+ * — workers serialise the live `device.features` array (so every entry
226
+ * is a valid enum value at write time) but the persisted blob is loose
227
+ * `string[]` on the wire. The narrow keeps unknown values out of the
228
+ * `getDevice` response without losing the enum-typed contract.
348
229
  */
349
- function toRoutableProviderNodeId(nodeId) {
350
- const slash = nodeId.indexOf("/");
351
- return slash === -1 ? nodeId : nodeId.slice(0, slash);
352
- }
353
- /**
230
+ function persistedFeatures(features) {
231
+ if (!features) return [];
232
+ const out = [];
233
+ for (const f of features) if (DEVICE_FEATURE_VALUES.has(f)) out.push(f);
234
+ return out;
235
+ }
236
+ /**
237
+ * Build an identity-only `SourceInfo` from the persisted device config blob.
238
+ *
239
+ * Forked-worker accessory children (e.g. HA sensor entities) persist
240
+ * `entityId` and `system` in their config blob at spawn time. The hub has no
241
+ * live `IDevice` instance for these devices, so the persisted-fallback paths
242
+ * in `listAll` / `getDevice` / `getChildren` must reconstruct the identity
243
+ * `SourceInfo` from the config so dispatch routing keeps working.
244
+ *
245
+ * Rendering metadata (unit, precision) flows live through the cap STATUS SLICE
246
+ * and must NOT be derived here. Only `id` + `system` (+ `uniqueId` when
247
+ * present) are projected — purely identity, never rendering hints.
248
+ *
249
+ * Returns `undefined` when no identity anchor is resolvable (pure identity
250
+ * devices like cameras/hubs that don't carry `entityId`/`system` in their
251
+ * config blob) — the hub synthetic fallback applies in that case.
252
+ */
253
+ function buildSourceInfoFromConfig(persistedConfig, stableId, addonId) {
254
+ const id = typeof persistedConfig["entityId"] === "string" ? persistedConfig["entityId"] : void 0;
255
+ const system = typeof persistedConfig["system"] === "string" ? persistedConfig["system"] : void 0;
256
+ if (id === void 0 && system === void 0) return void 0;
257
+ const uniqueId = typeof persistedConfig["uniqueId"] === "string" ? persistedConfig["uniqueId"] : void 0;
258
+ return {
259
+ id: id ?? stableId,
260
+ system: system ?? addonId,
261
+ ...uniqueId !== void 0 ? { uniqueId } : {}
262
+ };
263
+ }
264
+ var DEVICE_ROLE_VALUES = new Set(Object.values(_camstack_types.DeviceRole));
265
+ /** Type guard: a string is a known `DeviceRole` enum member. */
266
+ function isDeviceRole(value) {
267
+ return DEVICE_ROLE_VALUES.has(value);
268
+ }
269
+ /** Narrow a persisted role string (sqlite TEXT column) to a `DeviceRole`.
270
+ * Unknown / null values resolve to `null` so a stale or unrecognised role
271
+ * never leaks an off-enum string onto the wire shape. */
272
+ function toDeviceRole(value) {
273
+ return value != null && isDeviceRole(value) ? value : null;
274
+ }
275
+ function toDeviceInfo(addonId, device, metadata = null, metaRow = null) {
276
+ const configValues = {};
277
+ for (const entry of device.config.entries()) configValues[entry.key] = entry.value;
278
+ const name = metaRow?.name ?? device.name;
279
+ const location = metaRow?.location !== void 0 ? metaRow.location : device.location;
280
+ const disabled = metaRow?.disabled ?? device.disabled;
281
+ const probeSlice = device.runtimeState?.getCapState("feature-probe");
282
+ const probed = probeSlice === void 0 ? true : (probeSlice.lastProbedAt ?? 0) > 0;
283
+ return {
284
+ id: device.id,
285
+ stableId: device.stableId,
286
+ addonId,
287
+ type: device.type,
288
+ name,
289
+ location,
290
+ disabled,
291
+ parentDeviceId: device.parentDeviceId,
292
+ role: device.role ?? null,
293
+ online: device.online,
294
+ probed,
295
+ features: device.features.length > 0 ? [...device.features] : persistedFeatures(metaRow?.features),
296
+ isCamera: isCameraDevice(device),
297
+ config: configValues,
298
+ metadata,
299
+ ...metaRow?.integrationId !== void 0 ? { integrationId: metaRow.integrationId } : {},
300
+ ...metaRow?.linkDeviceId !== void 0 ? { linkDeviceId: metaRow.linkDeviceId } : {},
301
+ ...metaRow?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: metaRow.primaryChildEntityId } : {},
302
+ ...metaRow?.childLayout !== void 0 ? { childLayout: metaRow.childLayout } : {},
303
+ ...metaRow?.deviceLinks !== void 0 ? { deviceLinks: metaRow.deviceLinks } : {}
304
+ };
305
+ }
306
+ function resolveDeviceById(registry, deviceId) {
307
+ const device = registry.getById(deviceId);
308
+ if (!device) return null;
309
+ const addonId = registry.getAddonId(deviceId);
310
+ if (!addonId) return null;
311
+ return {
312
+ addonId,
313
+ device
314
+ };
315
+ }
316
+ //#endregion
317
+ //#region src/builtins/device-manager/device-bindings-store.ts
318
+ /**
319
+ * Persisted wrapper-activation + per-device binding resolution for the
320
+ * device-manager addon. Extracted from `device-manager.addon.ts` as free
321
+ * functions that receive an explicit `BindingsDeps` context (dependency
322
+ * injection) instead of closing over the addon instance — keeps the routing
323
+ * logic cohesive and independently testable while preserving the EXACT call
324
+ * semantics, ordering, and side effects of the original class methods.
325
+ *
326
+ * The addon keeps thin delegator methods (`getBindings`, `getAllBindings`, …)
327
+ * that build `BindingsDeps` from its own state and forward here, so every
328
+ * existing internal + cap-router call site is unchanged.
329
+ */
330
+ /**
331
+ * Wire the push-fed cross-process native-cap cache (`remoteNativeCaps`).
332
+ * Workers emit `DeviceBindingsChanged` on `ctx.registerNativeCap` / device
333
+ * removal; we mirror those into `remoteNativeCaps` so `getBindings` returns the
334
+ * full cluster view. Events from the local node are ignored: hub-local natives
335
+ * live in `capabilityRegistry` and are folded in directly by getBindings.
336
+ *
337
+ * Push events are accurate in the steady state but can be lost during the
338
+ * Moleculer transport handshake window (hub restart, crash-respawn,
339
+ * restartAddon). The reliable replacement for lost events is the D3 re-handshake
340
+ * (`listClusterNativeCaps()`), which `resolveNativeCapOwnerSync` /
341
+ * `getBindings` step 4 fall through to on a push miss. The `$node.disconnected`
342
+ * handler purges a gone node's entries; the worker re-handshakes (and re-emits
343
+ * `native-registered`) on its next boot.
344
+ */
345
+ function wireRemoteNativeCapSync(ctx, remoteNativeCaps) {
346
+ const localNodeId = ctx.kernel.localNodeId ?? "hub";
347
+ ctx.eventBus.subscribe({ category: _camstack_types.EventCategory.DeviceBindingsChanged }, (event) => {
348
+ const { deviceId, capName, reason, addonId, nodeId } = event.data;
349
+ if (nodeId === localNodeId) return;
350
+ if (reason === "native-registered") {
351
+ let perDevice = remoteNativeCaps.get(deviceId);
352
+ if (!perDevice) {
353
+ perDevice = /* @__PURE__ */ new Map();
354
+ remoteNativeCaps.set(deviceId, perDevice);
355
+ }
356
+ perDevice.set(capName, {
357
+ addonId,
358
+ nodeId
359
+ });
360
+ } else if (reason === "native-unregistered") {
361
+ const perDevice = remoteNativeCaps.get(deviceId);
362
+ if (!perDevice) return;
363
+ perDevice.delete(capName);
364
+ if (perDevice.size === 0) remoteNativeCaps.delete(deviceId);
365
+ }
366
+ });
367
+ const cluster = ctx.kernel.cluster;
368
+ if (cluster) cluster.broker.localBus.on("$node.disconnected", (payload) => {
369
+ const gone = payload.node.id;
370
+ const emptyDevices = [];
371
+ for (const [deviceId, perDevice] of remoteNativeCaps) {
372
+ const toDelete = [];
373
+ for (const [capName, entry] of perDevice) if (entry.nodeId === gone) toDelete.push(capName);
374
+ for (const capName of toDelete) perDevice.delete(capName);
375
+ if (perDevice.size === 0) emptyDevices.push(deviceId);
376
+ }
377
+ for (const deviceId of emptyDevices) remoteNativeCaps.delete(deviceId);
378
+ });
379
+ }
380
+ async function readBindingsStore(deps) {
381
+ return { deviceBindings: (await deps.ctx.settings.readAddonStore()).deviceBindings ?? {} };
382
+ }
383
+ async function writeBindingsStore(deps, next) {
384
+ await deps.ctx.settings.writeAddonStore({ deviceBindings: next.deviceBindings });
385
+ }
386
+ function resolveWrapperNodeId(_wrapperAddonId) {
387
+ return "hub";
388
+ }
389
+ /**
390
+ * Reduce a provider node id to the routable form `DeviceProxy` can pin.
391
+ *
392
+ * Every addon runs in its own `addon-runner` with the composite node id
393
+ * `${parentNodeId}/${runnerId}` (see `addon-runner.ts`) — e.g.
394
+ * `hub/provider-reolink` or `dev-agent-0/provider-reolink`. That composite is
395
+ * an IDENTITY, not a routable target: the `CapRouteResolver` reaches a child
396
+ * only THROUGH its parent (the hub resolves a hub-local-uds child by
397
+ * cap+device; an agent forwards to its own child). `DeviceProxy` pins
398
+ * `entry.providerNodeId` on every cap call, so a binding entry must expose the
399
+ * parent node id — otherwise the explicit pin classifies as `remote-moleculer`
400
+ * to an unknown node → `no-provider`, which surfaces as
401
+ * "this camera doesn't expose …" for client-proxy-driven widget caps
402
+ * (motion-zones, privacy-mask). Wrappers already report the parent via
403
+ * `resolveWrapperNodeId`; this aligns natives with the same contract.
404
+ *
405
+ * A flat node id (a genuine standalone node with no `/`) is returned
406
+ * unchanged.
407
+ */
408
+ function toRoutableProviderNodeId(nodeId) {
409
+ const slash = nodeId.indexOf("/");
410
+ return slash === -1 ? nodeId : nodeId.slice(0, slash);
411
+ }
412
+ /**
354
413
  * Resolve a remote native cap entry for a given `(capName, deviceId)` by
355
414
  * consulting the handshake-fed `HubNodeRegistry` via
356
415
  * `ctx.kernel.listClusterNativeCaps()`. Called when the push-based
@@ -483,202 +542,35 @@ async function getAllBindings(deps) {
483
542
  async function lookupPersistedStableId(deps, deviceId) {
484
543
  return ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.stableId;
485
544
  }
486
- /**
487
- * Build the device-detail form section for a `derived-form` device-config
488
- * cap. Returns null when the camera exposes no configurable property.
489
- */
490
- function deriveFormContribution(builderId, options, status) {
491
- if (builderId !== "stream-params") throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
492
- const schema = (0, _camstack_types.buildStreamParamsConfigSchema)(options, status ?? null);
493
- if (!schema) return null;
494
- return { sections: schema.sections.map((s) => ({
495
- id: s.id,
496
- title: s.title,
497
- ...s.tab !== void 0 ? { tab: s.tab } : {},
498
- ...s.order !== void 0 ? { order: s.order } : {},
499
- ...s.description !== void 0 ? { description: s.description } : {},
500
- ...s.columns !== void 0 ? { columns: s.columns } : {},
501
- fields: [...s.fields]
502
- })) };
503
- }
504
- /**
505
- * Route a flat form patch back through the cap's per-profile setter.
506
- */
507
- async function applyDerivedFormPatch(builderId, patch, setProfile) {
508
- if (builderId !== "stream-params") throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
509
- for (const meta of _camstack_types.STREAM_PROFILE_META) {
510
- const profilePatch = (0, _camstack_types.parseStreamParamsFormPatch)(patch, meta.prefix);
511
- if (profilePatch) await setProfile(meta.profile, profilePatch);
512
- }
513
- }
514
545
  //#endregion
515
- //#region src/builtins/device-manager/device-projection.ts
516
- /**
517
- * Pure device-projection + small predicate helpers for the device-manager
518
- * addon. Extracted verbatim from `device-manager.addon.ts`. None of these
519
- * functions close over the addon instance — they take everything they need
520
- * as explicit parameters, so they are safe to share across the addon's
521
- * query / aggregation modules.
522
- */
546
+ //#region src/builtins/device-manager/device-aggregation.ts
523
547
  /**
524
- * Return true when `err` is a transient Moleculer error that is worth
525
- * retrying specifically any `MoleculerRetryableError` subclass
526
- * (ServiceNotAvailableError, ServiceNotFoundError, BrokerDisconnectedError,
527
- * RequestTimeoutError, …). Moleculer sets `retryable: true` on all of them.
548
+ * Device-details aggregation + field-update routing for the device-manager
549
+ * addon. Extracted from `device-manager.addon.ts` as free functions that
550
+ * receive an explicit `AggregationDeps` context (DI) rather than closing over
551
+ * the addon instance.
528
552
  *
529
- * Falls back to a message-substring check for serialised errors that arrive
530
- * across the Moleculer transport as plain objects rather than real instances.
553
+ * This module owns the binding-driven settings/live/status aggregators
554
+ * (`getDeviceAggregate`, `collectSystemDeviceContributions`,
555
+ * `deriveDeviceConfigContribution`, `buildBaseDeviceSection`,
556
+ * `getDeviceStatusAggregate`, `resolveDriverConfigSchema`) and the
557
+ * field-update routing (`updateDeviceField`, `updateDeviceFieldsBatch`,
558
+ * `applyGroupPatch`, `resolveContributionProvider`, `dispatchDeviceAction`)
559
+ * plus the wrapper-activation mutators (`setWrapperActive`,
560
+ * `listWrappersForCap`, `listBindableCapsForDeviceType`).
561
+ *
562
+ * Sibling aggregator functions call each other directly (passing `deps`);
563
+ * cross-domain leaf calls (`getBindings`, `lookupPersistedStableId`,
564
+ * `resolveRemoteNativeCapFromRegistry`, read/writeBindingsStore) route through
565
+ * the bindings-store module via `deps.bindingsDeps`. EXACT semantics, ordering,
566
+ * and side effects of the original class methods are preserved; the addon keeps
567
+ * thin delegator methods over these functions.
531
568
  */
532
- function isTransientMoleculerError(err) {
533
- if (err !== null && typeof err === "object") {
534
- const e = err;
535
- if (e["retryable"] === true) return true;
536
- const code = typeof e["code"] === "string" ? e["code"] : "";
537
- if (code === "SERVICE_NOT_FOUND" || code === "SERVICE_NOT_AVAILABLE" || code === "REQUEST_TIMEOUT" || code === "BAD_GATEWAY") return true;
538
- }
539
- if (err instanceof Error) {
540
- const msg = err.message;
541
- if (msg.includes("is not available") || msg.includes("is not found") || msg.includes("transporter has disconnected") || msg.includes("Request timed out")) return true;
542
- }
543
- return false;
544
- }
545
- function shallowEqual(a, b) {
546
- const ak = Object.keys(a);
547
- const bk = Object.keys(b);
548
- if (ak.length !== bk.length) return false;
549
- for (const k of ak) if (a[k] !== b[k]) return false;
550
- return true;
551
- }
552
- /** Returns true when `x` is a non-null, non-array plain object. */
553
- function isRecord$1(x) {
554
- return x !== null && typeof x === "object" && !Array.isArray(x);
555
- }
556
- function isCameraDevice(device) {
557
- return "getStreamSources" in device && typeof device.getStreamSources === "function";
558
- }
559
- var DEVICE_FEATURE_VALUES = new Set(Object.values(_camstack_types.DeviceFeature));
560
- /**
561
- * Validate persisted feature strings against the `DeviceFeature` enum
562
- * — workers serialise the live `device.features` array (so every entry
563
- * is a valid enum value at write time) but the persisted blob is loose
564
- * `string[]` on the wire. The narrow keeps unknown values out of the
565
- * `getDevice` response without losing the enum-typed contract.
566
- */
567
- function persistedFeatures(features) {
568
- if (!features) return [];
569
- const out = [];
570
- for (const f of features) if (DEVICE_FEATURE_VALUES.has(f)) out.push(f);
571
- return out;
572
- }
573
- /**
574
- * Build an identity-only `SourceInfo` from the persisted device config blob.
575
- *
576
- * Forked-worker accessory children (e.g. HA sensor entities) persist
577
- * `entityId` and `system` in their config blob at spawn time. The hub has no
578
- * live `IDevice` instance for these devices, so the persisted-fallback paths
579
- * in `listAll` / `getDevice` / `getChildren` must reconstruct the identity
580
- * `SourceInfo` from the config so dispatch routing keeps working.
581
- *
582
- * Rendering metadata (unit, precision) flows live through the cap STATUS SLICE
583
- * and must NOT be derived here. Only `id` + `system` (+ `uniqueId` when
584
- * present) are projected — purely identity, never rendering hints.
585
- *
586
- * Returns `undefined` when no identity anchor is resolvable (pure identity
587
- * devices like cameras/hubs that don't carry `entityId`/`system` in their
588
- * config blob) — the hub synthetic fallback applies in that case.
589
- */
590
- function buildSourceInfoFromConfig(persistedConfig, stableId, addonId) {
591
- const id = typeof persistedConfig["entityId"] === "string" ? persistedConfig["entityId"] : void 0;
592
- const system = typeof persistedConfig["system"] === "string" ? persistedConfig["system"] : void 0;
593
- if (id === void 0 && system === void 0) return void 0;
594
- const uniqueId = typeof persistedConfig["uniqueId"] === "string" ? persistedConfig["uniqueId"] : void 0;
595
- return {
596
- id: id ?? stableId,
597
- system: system ?? addonId,
598
- ...uniqueId !== void 0 ? { uniqueId } : {}
599
- };
600
- }
601
- var DEVICE_ROLE_VALUES = new Set(Object.values(_camstack_types.DeviceRole));
602
- /** Type guard: a string is a known `DeviceRole` enum member. */
603
- function isDeviceRole(value) {
604
- return DEVICE_ROLE_VALUES.has(value);
605
- }
606
- /** Narrow a persisted role string (sqlite TEXT column) to a `DeviceRole`.
607
- * Unknown / null values resolve to `null` so a stale or unrecognised role
608
- * never leaks an off-enum string onto the wire shape. */
609
- function toDeviceRole(value) {
610
- return value != null && isDeviceRole(value) ? value : null;
611
- }
612
- function toDeviceInfo(addonId, device, metadata = null, metaRow = null) {
613
- const configValues = {};
614
- for (const entry of device.config.entries()) configValues[entry.key] = entry.value;
615
- const name = metaRow?.name ?? device.name;
616
- const location = metaRow?.location !== void 0 ? metaRow.location : device.location;
617
- const disabled = metaRow?.disabled ?? device.disabled;
618
- const probeSlice = device.runtimeState?.getCapState("feature-probe");
619
- const probed = probeSlice === void 0 ? true : (probeSlice.lastProbedAt ?? 0) > 0;
620
- return {
621
- id: device.id,
622
- stableId: device.stableId,
623
- addonId,
624
- type: device.type,
625
- name,
626
- location,
627
- disabled,
628
- parentDeviceId: device.parentDeviceId,
629
- role: device.role ?? null,
630
- online: device.online,
631
- probed,
632
- features: device.features.length > 0 ? [...device.features] : persistedFeatures(metaRow?.features),
633
- isCamera: isCameraDevice(device),
634
- config: configValues,
635
- metadata,
636
- ...metaRow?.integrationId !== void 0 ? { integrationId: metaRow.integrationId } : {},
637
- ...metaRow?.linkDeviceId !== void 0 ? { linkDeviceId: metaRow.linkDeviceId } : {},
638
- ...metaRow?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: metaRow.primaryChildEntityId } : {},
639
- ...metaRow?.childLayout !== void 0 ? { childLayout: metaRow.childLayout } : {},
640
- ...metaRow?.deviceLinks !== void 0 ? { deviceLinks: metaRow.deviceLinks } : {}
641
- };
642
- }
643
- function resolveDeviceById(registry, deviceId) {
644
- const device = registry.getById(deviceId);
645
- if (!device) return null;
646
- const addonId = registry.getAddonId(deviceId);
647
- if (!addonId) return null;
648
- return {
649
- addonId,
650
- device
651
- };
652
- }
653
- //#endregion
654
- //#region src/builtins/device-manager/device-aggregation.ts
655
- /**
656
- * Device-details aggregation + field-update routing for the device-manager
657
- * addon. Extracted from `device-manager.addon.ts` as free functions that
658
- * receive an explicit `AggregationDeps` context (DI) rather than closing over
659
- * the addon instance.
660
- *
661
- * This module owns the binding-driven settings/live/status aggregators
662
- * (`getDeviceAggregate`, `collectSystemDeviceContributions`,
663
- * `deriveDeviceConfigContribution`, `buildBaseDeviceSection`,
664
- * `getDeviceStatusAggregate`, `resolveDriverConfigSchema`) and the
665
- * field-update routing (`updateDeviceField`, `updateDeviceFieldsBatch`,
666
- * `applyGroupPatch`, `resolveContributionProvider`, `dispatchDeviceAction`)
667
- * plus the wrapper-activation mutators (`setWrapperActive`,
668
- * `listWrappersForCap`, `listBindableCapsForDeviceType`).
669
- *
670
- * Sibling aggregator functions call each other directly (passing `deps`);
671
- * cross-domain leaf calls (`getBindings`, `lookupPersistedStableId`,
672
- * `resolveRemoteNativeCapFromRegistry`, read/writeBindingsStore) route through
673
- * the bindings-store module via `deps.bindingsDeps`. EXACT semantics, ordering,
674
- * and side effects of the original class methods are preserved; the addon keeps
675
- * thin delegator methods over these functions.
676
- */
677
- async function getDeviceAggregate(deps, deviceId, kind) {
678
- const registry = deps.capabilityRegistry;
679
- if (!registry) {
680
- deps.ctx.logger.debug("capability registry unavailable — aggregate empty", { meta: { kind } });
681
- return null;
569
+ async function getDeviceAggregate(deps, deviceId, kind) {
570
+ const registry = deps.capabilityRegistry;
571
+ if (!registry) {
572
+ deps.ctx.logger.debug("capability registry unavailable — aggregate empty", { meta: { kind } });
573
+ return null;
682
574
  }
683
575
  const method = kind === "settings" ? "getDeviceSettingsContribution" : "getDeviceLiveContribution";
684
576
  const { entries: bindingEntries } = await getBindings(deps.bindingsDeps, { deviceId });
@@ -1315,560 +1207,145 @@ async function getWireableFields(deps, input) {
1315
1207
  return { caps };
1316
1208
  }
1317
1209
  //#endregion
1318
- //#region src/builtins/device-manager/device-link-overlay.ts
1319
- /** Build both lookup maps from resolved link entries. Pure — order-preserving. */
1320
- function buildLinkIndexes(entries) {
1321
- const targets = /* @__PURE__ */ new Map();
1322
- const dependents = /* @__PURE__ */ new Map();
1323
- for (const { targetDeviceId, link, sourceDeviceId } of entries) {
1324
- const tKey = `${targetDeviceId}:${link.target.cap}`;
1325
- const tList = targets.get(tKey) ?? [];
1326
- tList.push({
1327
- link,
1328
- sourceDeviceId
1329
- });
1330
- targets.set(tKey, tList);
1331
- const sKey = `${sourceDeviceId}:${link.source.cap}`;
1332
- const sList = dependents.get(sKey) ?? [];
1333
- sList.push({
1334
- targetDeviceId,
1335
- targetCap: link.target.cap
1336
- });
1337
- dependents.set(sKey, sList);
1338
- }
1339
- return {
1340
- targets,
1341
- dependents
1342
- };
1343
- }
1344
- //#endregion
1345
- //#region src/builtins/device-manager/device-meta-store.ts
1210
+ //#region src/builtins/device-manager/device-event-propagator.ts
1346
1211
  /**
1347
- * Meta persistence layer for the device-manager addon.
1212
+ * Walks the parent chain for every device-sourced event and re-emits a
1213
+ * copy on each ancestor scope with `via[]` populated.
1348
1214
  *
1349
- * `DeviceMetaStore` encapsulates the read/modify/write helpers that previously
1350
- * lived as closures inside `onInitialize`: the `ctx.settings`-backed reads
1351
- * (`readStore` / `readIndex` / `readMeta` / `readMetadataMap`), the
1352
- * single-writer mutex (`withMetaWriteLock`), id allocation
1353
- * (`allocateNextDeviceId`), ownership/child resolution, link-source resolution,
1354
- * and the cross-device link reverse-index rebuild. Extracting it as a class
1355
- * (constructed once per `onInitialize`) keeps the EXACT semantics the
1356
- * `withMetaWriteLock` promise-chain is per-instance state, identical to the
1357
- * former closure while letting the meta-action + query modules depend on a
1358
- * named, injectable surface instead of captured closures.
1215
+ * Design goals:
1216
+ * - Transparent: drivers emit once on their own device scope; the
1217
+ * framework handles fan-out. Zero provider boilerplate.
1218
+ * - Anti-loop: events that already carry `via[]` are skipped (we only
1219
+ * propagate ORIGINAL emissions).
1220
+ * - Anti-cycle: the parent chain is bounded if the device registry
1221
+ * is corrupt and has a cycle, the walker caps at `MAX_CHAIN_DEPTH`
1222
+ * and logs a warning.
1223
+ * - Lazy: parent chain is resolved on-demand per event (no cached
1224
+ * topology). The lookup is O(depth) which is ≤2 in practice.
1359
1225
  *
1360
- * Cross-device link state (the `linkTargets` / `linkDependents` /
1361
- * `expectedSourceStableIds` maps + the `devicesWithLinks` gate) lives on the
1362
- * addon; the store reaches it through the injected `LinkIndexHost` so the
1363
- * single owner of that state stays the addon instance.
1226
+ * `via` contract (from SystemEvent.via JSDoc):
1227
+ * - `via[0]` is the originating source (the device that produced the
1228
+ * event). Subsequent entries walk up the parent chain.
1229
+ * - On the re-emission, `source` is the ancestor at that level and
1230
+ * `via[0..i]` is the prefix of the chain up to and including the
1231
+ * first N ancestors below the current one.
1232
+ *
1233
+ * Example (grandchild → parent → grandparent):
1234
+ * Original: { source: {id: 7}, data: {...}, via: undefined }
1235
+ * Re-emit 1: { source: {id: 4}, data: {...}, via: [{id: 7}] }
1236
+ * Re-emit 2: { source: {id: 1}, data: {...}, via: [{id: 7}, {id: 4}] }
1237
+ *
1238
+ * A consumer listening at `source.id === 1` receives re-emit 2 (with
1239
+ * `via` showing the chain). A consumer listening at `source.id === 7`
1240
+ * with `via === undefined` receives the original only.
1364
1241
  */
1365
- var DeviceMetaStore = class {
1366
- settings;
1367
- registry;
1368
- linkHost;
1369
- /** Synchronous ownership cache, keyed by NUMERIC deviceId → owning addonId.
1370
- * The persisted meta store is authoritative but reads are async; hub-side
1371
- * callers (e.g. `CapabilityRegistry.getNativeProvider` fallback) need
1372
- * ownership without awaiting. Kept in sync with every register/remove and
1373
- * warmed from persistence on boot. */
1374
- idToAddonId = /* @__PURE__ */ new Map();
1375
- /** Serialises every read-modify-write of the deviceMeta / deviceIndex blob
1376
- * through one promise chain (see `withMetaWriteLock`). Per-instance state —
1377
- * identical to the former `onInitialize` closure variable. */
1378
- metaWriteChain = Promise.resolve();
1379
- constructor(settings, registry, linkHost) {
1380
- this.settings = settings;
1381
- this.registry = registry;
1382
- this.linkHost = linkHost;
1242
+ /** Bounded walk paranoia against corrupt device registries with cycles. */
1243
+ var MAX_CHAIN_DEPTH = 16;
1244
+ var DeviceEventPropagator = class {
1245
+ opts;
1246
+ unsubscribe = null;
1247
+ constructor(opts) {
1248
+ this.opts = opts;
1383
1249
  }
1384
- readStore = async () => {
1385
- return await this.settings.readAddonStore();
1386
- };
1387
- readIndex = async () => {
1388
- return (await this.readStore()).deviceIndex ?? {};
1389
- };
1390
- readMeta = async () => {
1391
- return (await this.readStore()).deviceMeta ?? {};
1392
- };
1393
- /** Hardware-identity metadata map. Lives in a sibling key on the
1394
- * device-manager addon store so its writers (`setMetadata`) never
1395
- * collide with the lifecycle writers on `deviceMeta`
1396
- * (`registerDevice` / `setName` / `setLocation` / `setDisabled`).
1397
- * Single-writer per row eliminates the "writer X clobbers writer
1398
- * Y's field" bug class — `setMetadata` is the only producer. */
1399
- readMetadataMap = async () => {
1400
- return (await this.readStore()).deviceMetadata ?? {};
1401
- };
1402
- withMetaWriteLock = async (fn) => {
1403
- const previous = this.metaWriteChain;
1404
- let release = () => {};
1405
- const next = new Promise((resolve) => {
1406
- release = resolve;
1407
- });
1408
- this.metaWriteChain = next;
1409
- try {
1410
- await previous.catch(() => {});
1411
- return await fn();
1412
- } finally {
1413
- release();
1414
- }
1415
- };
1416
- /**
1417
- * Resolve a numeric deviceId to the owning `(addonId, stableId)` pair.
1418
- * Scans persisted meta — live IDevice lookup (hub registry) is handled
1419
- * separately per call site so callers can decide whether to route to
1420
- * an in-process driver or to the cross-process `device-ops` bridge.
1421
- * Returns null when no device with that id is known to the hub.
1422
- */
1423
- resolvePersistedById = async (deviceId) => {
1424
- const m = (await this.readMeta())[String(deviceId)];
1425
- if (!m) return null;
1426
- return {
1427
- addonId: m.addonId,
1428
- stableId: m.stableId,
1429
- meta: m
1430
- };
1431
- };
1432
- /** Direct children of a device: the union of the live registry's children
1433
- * and the persisted-meta scan (`parentDeviceId === parentId`), deduplicated
1434
- * and excluding self. Shared by the `remove` cascade and the `resetToSource`
1435
- * resync purge (#19). */
1436
- directChildIds = async (parentId) => {
1437
- const ids = /* @__PURE__ */ new Set();
1438
- if (this.registry) for (const c of this.registry.getChildren(parentId)) ids.add(c.id);
1439
- const meta = await this.readMeta();
1440
- for (const m of Object.values(meta)) if (m.parentDeviceId === parentId) ids.add(m.id);
1441
- ids.delete(parentId);
1442
- return [...ids];
1443
- };
1444
- /** Resolve a link's `sourceKey` to a live device id: the sibling accessory
1445
- * whose stableId is `${parentStableId}-${sourceKey}`. Null when absent.
1446
- * Pure over a pre-read meta map so a multi-link resolve reads the store once. */
1447
- resolveSourceDeviceId = (parentStableId, sourceKey, meta) => {
1448
- const wanted = `${parentStableId}-${sourceKey}`;
1449
- for (const m of Object.values(meta)) if (m.stableId === wanted) return m.id;
1450
- return null;
1451
- };
1452
- allocateNextDeviceId = async () => {
1453
- const current = (await this.readStore()).nextDeviceId ?? 1;
1454
- await this.settings.writeAddonStore({ nextDeviceId: current + 1 });
1455
- return current;
1456
- };
1457
- /** Rebuild the `linkTargets` / `linkDependents` reverse-index maps from the
1458
- * current persisted meta. Called at boot (once the `devicesWithLinks` seed
1459
- * has run) and whenever the link topology can change: `setDeviceLinks`,
1460
- * `registerDevice` (a new sibling source may resolve previously-dangling
1461
- * links), and `removeDevice` (its entries must be evicted). */
1462
- rebuildLinkDependents = async () => {
1463
- if (this.linkHost.devicesWithLinks.size === 0) {
1464
- this.linkHost.setLinkIndexes(/* @__PURE__ */ new Map(), /* @__PURE__ */ new Map(), /* @__PURE__ */ new Set());
1465
- return;
1466
- }
1467
- const allMeta = await this.readMeta();
1468
- const stableIdById = /* @__PURE__ */ new Map();
1469
- const idByStableId = /* @__PURE__ */ new Map();
1470
- for (const m of Object.values(allMeta)) {
1471
- stableIdById.set(m.id, m.stableId);
1472
- idByStableId.set(m.stableId, m.id);
1250
+ start() {
1251
+ if (this.unsubscribe) return;
1252
+ const unsub = this.opts.eventBus.subscribe({}, (ev) => this.handle(ev));
1253
+ this.unsubscribe = unsub;
1254
+ }
1255
+ stop() {
1256
+ if (!this.unsubscribe) return;
1257
+ this.unsubscribe();
1258
+ this.unsubscribe = null;
1259
+ }
1260
+ /** Exposed for tests lets them inject events without the full bus. */
1261
+ handle(ev) {
1262
+ if (ev.via !== void 0) return;
1263
+ if (ev.source.type !== "device") return;
1264
+ const rawId = ev.source.id;
1265
+ const deviceId = typeof rawId === "number" ? rawId : Number(rawId);
1266
+ if (!Number.isFinite(deviceId)) return;
1267
+ const chain = this.resolveParentChain(deviceId);
1268
+ if (chain.length === 0) return;
1269
+ const via = [ev.source];
1270
+ for (const ancestorId of chain) {
1271
+ const reEmission = {
1272
+ ...ev,
1273
+ source: {
1274
+ type: "device",
1275
+ id: ancestorId
1276
+ },
1277
+ via: [...via]
1278
+ };
1279
+ this.opts.eventBus.emit(reEmission);
1280
+ via.push({
1281
+ type: "device",
1282
+ id: ancestorId
1283
+ });
1473
1284
  }
1474
- const entries = [];
1475
- const expectedSources = /* @__PURE__ */ new Set();
1476
- for (const targetId of this.linkHost.devicesWithLinks) {
1477
- const targetMeta = Object.values(allMeta).find((m) => m.id === targetId);
1478
- if (!targetMeta) continue;
1479
- const container = targetMeta.parentDeviceId !== null ? stableIdById.get(targetMeta.parentDeviceId) ?? stableIdById.get(targetId) : stableIdById.get(targetId);
1480
- if (container === void 0) continue;
1481
- for (const link of targetMeta.deviceLinks ?? []) {
1482
- expectedSources.add(`${container}-${link.source.sourceKey}`);
1483
- const srcId = idByStableId.get(`${container}-${link.source.sourceKey}`);
1484
- if (srcId === void 0) continue;
1485
- entries.push({
1486
- targetDeviceId: targetId,
1487
- link,
1488
- sourceDeviceId: srcId
1285
+ }
1286
+ resolveParentChain(deviceId) {
1287
+ const chain = [];
1288
+ const seen = new Set([deviceId]);
1289
+ let current = this.opts.getParentOf(deviceId);
1290
+ while (current != null) {
1291
+ if (seen.has(current)) {
1292
+ this.opts.logger.warn("device-event-propagator: cycle detected in parent chain — aborting propagation", {
1293
+ tags: { deviceId },
1294
+ meta: {
1295
+ cycleAt: current,
1296
+ chainSoFar: [...chain]
1297
+ }
1298
+ });
1299
+ return chain;
1300
+ }
1301
+ seen.add(current);
1302
+ chain.push(current);
1303
+ if (chain.length >= MAX_CHAIN_DEPTH) {
1304
+ this.opts.logger.warn("device-event-propagator: chain depth limit hit — truncating", {
1305
+ tags: { deviceId },
1306
+ meta: {
1307
+ depth: chain.length,
1308
+ max: MAX_CHAIN_DEPTH
1309
+ }
1489
1310
  });
1311
+ break;
1490
1312
  }
1313
+ current = this.opts.getParentOf(current);
1491
1314
  }
1492
- const { targets, dependents } = buildLinkIndexes(entries);
1493
- this.linkHost.setLinkIndexes(targets, dependents, expectedSources);
1494
- };
1315
+ return chain;
1316
+ }
1495
1317
  };
1496
1318
  //#endregion
1497
- //#region src/builtins/device-manager/device-link-resolver.ts
1498
- /** Returns true when `x` is a non-null, non-array plain object. */
1499
- function isRecord(x) {
1500
- return x !== null && typeof x === "object" && !Array.isArray(x);
1501
- }
1502
- /** Narrow Zod v4's structural `$ZodType` (returned by `.unwrap()`) back to the
1503
- * concrete classic `z.ZodType`. Every runtime schema is a `z.ZodType`, so this
1504
- * is a true `instanceof` guard rather than a cast. */
1505
- function asZodType(schema) {
1506
- return schema instanceof zod.z.ZodType ? schema : null;
1507
- }
1508
- /** Unwrap ZodNullable / ZodOptional / ZodDefault wrappers to reach the inner
1509
- * type. This lets the repair logic recognise a `TankStatus.nullable()` field
1510
- * as a ZodObject so it can fill in missing nullable keys. */
1511
- function unwrapSchema(schema) {
1512
- if (schema instanceof zod.z.ZodNullable || schema instanceof zod.z.ZodOptional || schema instanceof zod.z.ZodDefault) {
1513
- const inner = asZodType(schema.unwrap());
1514
- return inner ? unwrapSchema(inner) : schema;
1515
- }
1516
- return schema;
1517
- }
1518
- /** For a ZodObject schema, ensure every nullable key present under `value` is
1519
- * filled — a missing key whose field accepts `null` is set to `null`. Generic
1520
- * repair for the common "set one leaf of a previously-null structured field"
1521
- * case (e.g. TankStatus.level). Recurses into nested object fields. */
1522
- function fillNullableDefaults(schema, value) {
1523
- const inner = unwrapSchema(schema);
1524
- if (!(inner instanceof zod.z.ZodObject) || !isRecord(value)) return value;
1525
- const shape = inner.shape;
1526
- const out = { ...value };
1527
- for (const [key, field] of Object.entries(shape)) if (out[key] === void 0) {
1528
- if (field.safeParse(null).success) out[key] = null;
1529
- } else out[key] = fillNullableDefaults(field, out[key]);
1530
- return out;
1531
- }
1319
+ //#region src/builtins/device-manager/device-meta-actions.ts
1532
1320
  /**
1533
- * Overlay transformed source values onto `base` by dot-path, then validate the
1534
- * result against the target cap's `statusSchema`. On validation failure the
1535
- * overlay is discarded and `base` is returned unchanged (a misconfigured link
1536
- * must never corrupt a cap response). Pure all I/O happens in the caller.
1321
+ * Device meta-mutation + persistence actions for the device-manager addon
1322
+ * the `IDeviceManagerProvider` write/load surface. Extracted verbatim from the
1323
+ * `onInitialize` provider literal as free functions taking a `ProviderContext`
1324
+ * (DI). Each function's input/return types are pinned to the cap contract via
1325
+ * `Parameters<IDeviceManagerProvider['x']>[0]` / `ReturnType<…>` so the typed
1326
+ * surface is identical to the inferred object-literal methods.
1327
+ *
1328
+ * Covers: id allocation + registration (allocateDeviceId, registerDevice,
1329
+ * removeDevice), config persistence (persistConfig, loadConfig), the meta
1330
+ * surface load (loadMeta, loadRuntimeState), every meta setter (setName,
1331
+ * setLocation, setType, setIntegrationId, setLinkDeviceId,
1332
+ * setPrimaryChildEntityId, setChildLayout, setDeviceLinks, setRole,
1333
+ * applyInitialMeta, setMetadata, setDisabled), and the location registry
1334
+ * (listLocations, addLocation, removeLocation).
1335
+ *
1336
+ * EXACT ordering and side effects preserved — notably registerDevice's
1337
+ * name-precedence reconcile + field-preservation spreads, the
1338
+ * withMetaWriteLock-serialised read-modify-writes, the event emissions, and the
1339
+ * link reverse-index rebuild gating. Pure refactor — no behavior change.
1537
1340
  */
1538
- function mergeLinkedStatus(base, resolved, statusSchema) {
1539
- let draft = base;
1540
- let touched = false;
1541
- for (const { link, sourceValue } of resolved) {
1542
- if (sourceValue === void 0) continue;
1543
- draft = (0, _camstack_types.setByPath)(draft, link.target.fieldPath, sourceValue);
1544
- touched = true;
1545
- }
1546
- if (!touched) return base;
1547
- if (!statusSchema) return draft;
1548
- const repaired = fillNullableDefaults(statusSchema, draft);
1549
- const parsed = statusSchema.safeParse(repaired);
1550
- if (!parsed.success) return base;
1551
- return isRecord(parsed.data) ? parsed.data : base;
1552
- }
1553
- //#endregion
1554
- //#region src/builtins/device-manager/device-state-mirror.ts
1555
1341
  /**
1556
- * Hub-side runtime-state mirror for the device-manager addon.
1557
- *
1558
- * `DeviceStateMirror` owns the per-device cap-keyed slice mirror and the
1559
- * debounced disk-write coalescer that previously lived directly on the addon
1560
- * class. It mirrors every `deviceState.setCapSlice` write, emits
1561
- * `DeviceStateChanged` (overlaid with cross-device linked values), and coalesces
1562
- * frequent writes into one `writeDeviceRuntimeState` per debounce window. The
1563
- * cross-device link reverse-index lives on the addon; the mirror reads it
1564
- * through the injected `LinkOverlayHost` so the single owner of that state stays
1565
- * the addon. Extracted verbatim — behavior unchanged.
1566
- */
1567
- var DeviceStateMirror = class DeviceStateMirror {
1568
- ctx;
1569
- linkHost;
1570
- /**
1571
- * Hub-side mirror of every device's cap-keyed runtime state.
1572
- * Key: deviceId. Value: per-cap slice map. Empty by default —
1573
- * slices show up as `setCapSlice` calls trickle in.
1574
- */
1575
- stateMirror = /* @__PURE__ */ new Map();
1576
- /**
1577
- * Per-device disk-write debouncer for runtime-state. `setCapSlice`
1578
- * updates the in-memory mirror synchronously and emits the change
1579
- * event immediately, but the disk write is coalesced.
1580
- */
1581
- runtimeStateDebounce = /* @__PURE__ */ new Map();
1582
- static RUNTIME_STATE_DEBOUNCE_MS = 1e3;
1583
- /** Loop/churn guard: last overlaid slice emitted per `${deviceId}:${cap}`. */
1584
- lastEmittedOverlay = /* @__PURE__ */ new Map();
1585
- constructor(ctx, linkHost) {
1586
- this.ctx = ctx;
1587
- this.linkHost = linkHost;
1588
- }
1589
- /**
1590
- * Single-cap mirror update — diff against the current mirror,
1591
- * persist the new slice in-memory, emit `DeviceStateChanged` for
1592
- * this cap. No-op on identical writes (both same shape and same
1593
- * values). Called by `setCapSlice` provider.
1594
- */
1595
- applySingleCapUpdate(deviceId, capName, slice) {
1596
- let perCap = this.stateMirror.get(deviceId);
1597
- if (!perCap) {
1598
- perCap = /* @__PURE__ */ new Map();
1599
- this.stateMirror.set(deviceId, perCap);
1600
- }
1601
- const prior = perCap.get(capName);
1602
- if (prior && shallowEqual(prior, slice)) return;
1603
- perCap.set(capName, { ...slice });
1604
- this.emitOverlayed(deviceId, capName);
1605
- const deps = this.linkHost.linkDependents.get(`${deviceId}:${capName}`);
1606
- if (deps) for (const d of deps) this.emitOverlayed(d.targetDeviceId, d.targetCap);
1607
- }
1608
- /**
1609
- * Debounced disk writer. Coalesces frequent writes (motion phase
1610
- * transitions, battery pushes) into one `writeDeviceRuntimeState`
1611
- * per `RUNTIME_STATE_DEBOUNCE_MS` window. Reads the per-device
1612
- * blob from the live mirror at flush time so the disk picture is
1613
- * always the latest state — no risk of writing a stale snapshot.
1614
- */
1615
- scheduleRuntimeStateDiskWrite(deviceId, settings) {
1616
- let slot = this.runtimeStateDebounce.get(deviceId);
1617
- if (!slot) {
1618
- slot = {
1619
- timer: null,
1620
- inFlight: null
1621
- };
1622
- this.runtimeStateDebounce.set(deviceId, slot);
1623
- }
1624
- if (slot.timer) return;
1625
- slot.timer = setTimeout(() => {
1626
- slot.timer = null;
1627
- const blob = this.snapshotForDevice(deviceId);
1628
- const write = (async () => {
1629
- try {
1630
- await settings.writeDeviceRuntimeState(deviceId, blob);
1631
- } catch (err) {
1632
- this.ctx.logger.warn("writeDeviceRuntimeState failed", {
1633
- tags: { deviceId },
1634
- meta: { error: err instanceof Error ? err.message : String(err) }
1635
- });
1636
- } finally {
1637
- slot.inFlight = null;
1638
- }
1639
- })();
1640
- slot.inFlight = write;
1641
- }, DeviceStateMirror.RUNTIME_STATE_DEBOUNCE_MS);
1642
- }
1643
- /**
1644
- * One-shot mirror seed used by `loadRuntimeState` at boot so the
1645
- * hub knows about every persisted slice without waiting for the
1646
- * first `setCapSlice` call. No events emitted — this is
1647
- * initial-state population, not a transition.
1648
- *
1649
- * Callers that must not carry a stale per-session probe across a
1650
- * restart pass the blob through `withResetSessionProbe` first (see
1651
- * `loadRuntimeState`).
1652
- */
1653
- seedMirror(deviceId, blob) {
1654
- let perCap = this.stateMirror.get(deviceId);
1655
- if (!perCap) {
1656
- perCap = /* @__PURE__ */ new Map();
1657
- this.stateMirror.set(deviceId, perCap);
1658
- }
1659
- for (const [capName, raw] of Object.entries(blob)) {
1660
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
1661
- perCap.set(capName, { ...raw });
1662
- }
1663
- }
1664
- /**
1665
- * The hub mirror's `feature-probe.lastProbedAt` is a PER-SESSION liveness
1666
- * signal — it means "this worker process completed a probe THIS session".
1667
- * Persisted runtime state carries the PRE-RESTART timestamp, which is stale
1668
- * after a hub or worker restart: the device has not re-probed yet. Seeding it
1669
- * verbatim makes `resolveDeviceProbed` report `probed:true` during the
1670
- * restart→reprobe window, which defeats the export carry-forward gate
1671
- * (`resolveExportFingerprint`, gated on `device.probed`) and posts a spurious
1672
- * partial `AddOrUpdateReport` to Alexa/HAP before the real probe lands.
1673
- *
1674
- * Reset `lastProbedAt` to 0 for the MIRROR seed only — `probed:false` carries
1675
- * the last-advertised fingerprint forward until the worker republishes a
1676
- * fresh probe (its post-probe `setCapSlice` raises `lastProbedAt` again, which
1677
- * also fires `DeviceReady`). The worker's returned `initialRuntimeState` blob
1678
- * is untouched, and every non-probe slice (e.g. `device-status`/online) is
1679
- * preserved.
1680
- */
1681
- withResetSessionProbe(blob) {
1682
- const probe = blob["feature-probe"];
1683
- if (!probe || typeof probe !== "object" || Array.isArray(probe)) return blob;
1684
- return {
1685
- ...blob,
1686
- "feature-probe": {
1687
- ...probe,
1688
- lastProbedAt: 0
1689
- }
1690
- };
1691
- }
1692
- /**
1693
- * Resolve a device's REAL `online` flag for the persisted/forked-worker
1694
- * list branch. Forked workers own the live `IDevice` in their own process,
1695
- * so the hub registry can't read `device.online` directly. The owning
1696
- * driver instead publishes its liveness through the auto-registered
1697
- * `device-status` runtime-state slice (`markOnline` → `setCapState`), which
1698
- * the canonical `deviceState.setCapSlice` write entrypoint mirrors into the
1699
- * hub-side `stateMirror`. We read that mirrored slice here so the list
1700
- * payload reflects the device's actual reachability instead of a constant.
1701
- *
1702
- * Fallback (`fallbackOnline`) preserves the legacy behaviour when no slice
1703
- * has been published yet: a persisted device with a live registry is
1704
- * assumed online (it was successfully registered by its owning process),
1705
- * and the null-registry "offline view" keeps reporting offline. We never
1706
- * regress a device to offline merely because its mirror is empty.
1707
- */
1708
- resolveDeviceOnline(deviceId, fallbackOnline) {
1709
- const raw = this.stateMirror.get(deviceId)?.get(_camstack_types.deviceStatusCapability.name);
1710
- if (!raw) return fallbackOnline;
1711
- const parsed = _camstack_types.DeviceStatusSchema.safeParse(raw);
1712
- if (!parsed.success) return fallbackOnline;
1713
- return parsed.data.online;
1714
- }
1715
- /**
1716
- * Derive the `probed` flag for an offline-view (forked-worker, not
1717
- * live in the hub registry) device projection. Reads the mirrored
1718
- * `feature-probe` slice the owning worker publishes. Mirrors the
1719
- * `toDeviceInfo` rule: no mirrored slice → ready (`true`, no probe seen);
1720
- * slice present → ready iff `lastProbedAt` has advanced past 0. The
1721
- * mirror is populated when the worker's first `setCapSlice` RPC arrives
1722
- * (BaseDevice seeds `feature-probe` `lastProbedAt:0` at construction, but
1723
- * cross-process delivery is async); until then the no-entry path returns
1724
- * `true` — a brief transient window, same as `resolveDeviceOnline`.
1725
- */
1726
- resolveDeviceProbed(deviceId) {
1727
- const raw = this.stateMirror.get(deviceId)?.get("feature-probe");
1728
- if (!raw) return true;
1729
- return (typeof raw.lastProbedAt === "number" ? raw.lastProbedAt : 0) > 0;
1730
- }
1731
- snapshotForDevice(deviceId) {
1732
- const perCap = this.stateMirror.get(deviceId);
1733
- if (!perCap) return {};
1734
- const out = {};
1735
- for (const [k, v] of perCap) out[k] = { ...v };
1736
- return out;
1737
- }
1738
- /**
1739
- * Read-time overlay of a cap slice with its cross-device linked values.
1740
- * Returns a cloned raw mirror slice when the (device, cap) pair has no
1741
- * links. Sources are read from the same in-hub stateMirror — sync, no
1742
- * cross-process call. The disk writer must NOT use this method; it must
1743
- * persist raw provider truth via snapshotForDevice.
1744
- */
1745
- overlayedSlice(deviceId, cap) {
1746
- const raw = this.stateMirror.get(deviceId)?.get(cap) ?? null;
1747
- const links = this.linkHost.linkTargets.get(`${deviceId}:${cap}`);
1748
- if (!links || links.length === 0) return raw ? { ...raw } : null;
1749
- const resolved = links.map((rl) => ({
1750
- link: rl.link,
1751
- 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)
1752
- }));
1753
- const schema = this.linkHost.capabilityRegistry?.getDefinition(cap)?.status?.schema;
1754
- return mergeLinkedStatus(raw ? { ...raw } : {}, resolved, schema);
1755
- }
1756
- /**
1757
- * Like snapshotForDevice but applies the device-link overlay per cap.
1758
- * Used exclusively by the device-state READ methods (getSnapshot,
1759
- * getAllSnapshots) so callers see overlayed values. The debounced disk
1760
- * writer must continue to call snapshotForDevice (raw truth).
1761
- */
1762
- snapshotForDeviceOverlayed(deviceId) {
1763
- const perCap = this.stateMirror.get(deviceId);
1764
- if (!perCap) return {};
1765
- const out = {};
1766
- for (const capName of perCap.keys()) {
1767
- const s = this.overlayedSlice(deviceId, capName);
1768
- if (s) out[capName] = s;
1769
- }
1770
- return out;
1771
- }
1772
- /** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`. */
1773
- allSnapshotsOverlayed() {
1774
- const out = {};
1775
- for (const [deviceId, perCap] of this.stateMirror) {
1776
- const dev = {};
1777
- for (const [capName, slice] of perCap) dev[capName] = this.overlayedSlice(deviceId, capName) ?? { ...slice };
1778
- out[String(deviceId)] = dev;
1779
- }
1780
- return out;
1781
- }
1782
- emitStateChanged(deviceId, capName, slice) {
1783
- this.ctx.eventBus.emit({
1784
- id: (0, node_crypto.randomUUID)(),
1785
- timestamp: /* @__PURE__ */ new Date(),
1786
- source: {
1787
- type: "device",
1788
- id: deviceId
1789
- },
1790
- category: _camstack_types.EventCategory.DeviceStateChanged,
1791
- data: {
1792
- deviceId,
1793
- capName,
1794
- slice
1795
- }
1796
- });
1797
- }
1798
- /** Emit DeviceStateChanged for (deviceId, cap) using the OVERLAID slice,
1799
- * skipping when the overlay is unchanged since the last emit (loop/churn
1800
- * guard). Used for the written pair AND its dependent targets. */
1801
- emitOverlayed(deviceId, cap) {
1802
- const slice = this.overlayedSlice(deviceId, cap);
1803
- if (!slice) return;
1804
- const key = `${deviceId}:${cap}`;
1805
- const prev = this.lastEmittedOverlay.get(key);
1806
- if (prev && shallowEqual(prev, slice)) return;
1807
- this.lastEmittedOverlay.set(key, slice);
1808
- this.emitStateChanged(deviceId, cap, slice);
1809
- }
1810
- /** Drop a removed device's overlay-emit guard entries (keyed
1811
- * `${deviceId}:${cap}`) so the map doesn't retain rows for a removed device.
1812
- * Called from `removeDevice`. */
1813
- dropDeviceOverlays(deviceId) {
1814
- for (const overlayKey of this.lastEmittedOverlay.keys()) if (overlayKey.startsWith(`${deviceId}:`)) this.lastEmittedOverlay.delete(overlayKey);
1815
- }
1816
- /** Flush every pending debounced disk write (graceful shutdown). Clears the
1817
- * debounce slots after awaiting in-flight + scheduled writes so shutdown is
1818
- * lossless. */
1819
- async flushPendingWrites(settings) {
1820
- const pending = [];
1821
- for (const [deviceId, slot] of this.runtimeStateDebounce) {
1822
- if (slot.timer) {
1823
- clearTimeout(slot.timer);
1824
- slot.timer = null;
1825
- if (settings) {
1826
- const blob = this.snapshotForDevice(deviceId);
1827
- pending.push(settings.writeDeviceRuntimeState(deviceId, blob).catch((err) => {
1828
- this.ctx.logger.warn("shutdown writeDeviceRuntimeState failed", {
1829
- tags: { deviceId },
1830
- meta: { error: err instanceof Error ? err.message : String(err) }
1831
- });
1832
- }));
1833
- }
1834
- }
1835
- if (slot.inFlight) pending.push(slot.inFlight);
1836
- }
1837
- await Promise.all(pending);
1838
- this.runtimeStateDebounce.clear();
1839
- }
1840
- };
1841
- //#endregion
1842
- //#region src/builtins/device-manager/device-meta-actions.ts
1843
- /**
1844
- * Device meta-mutation + persistence actions for the device-manager addon —
1845
- * the `IDeviceManagerProvider` write/load surface. Extracted verbatim from the
1846
- * `onInitialize` provider literal as free functions taking a `ProviderContext`
1847
- * (DI). Each function's input/return types are pinned to the cap contract via
1848
- * `Parameters<IDeviceManagerProvider['x']>[0]` / `ReturnType<…>` so the typed
1849
- * surface is identical to the inferred object-literal methods.
1850
- *
1851
- * Covers: id allocation + registration (allocateDeviceId, registerDevice,
1852
- * removeDevice), config persistence (persistConfig, loadConfig), the meta
1853
- * surface load (loadMeta, loadRuntimeState), every meta setter (setName,
1854
- * setLocation, setType, setIntegrationId, setLinkDeviceId,
1855
- * setPrimaryChildEntityId, setChildLayout, setDeviceLinks, setRole,
1856
- * applyInitialMeta, setMetadata, setDisabled), and the location registry
1857
- * (listLocations, addLocation, removeLocation).
1858
- *
1859
- * EXACT ordering and side effects preserved — notably registerDevice's
1860
- * name-precedence reconcile + field-preservation spreads, the
1861
- * withMetaWriteLock-serialised read-modify-writes, the event emissions, and the
1862
- * link reverse-index rebuild gating. Pure refactor — no behavior change.
1863
- */
1864
- /**
1865
- * Stamp (or update) the owning integration id on a device's meta row + emit
1866
- * `DeviceMetaChanged`. The device-manager chokepoint shared by the
1867
- * `setIntegrationId` cap handler AND the create/adopt forwarders (queries)
1868
- * that stamp an integration's ownership onto every device it materializes.
1869
- * Idempotent. Takes its low-level deps directly (not the full `ProviderContext`)
1870
- * because `ProviderContext.stampIntegrationId` delegates HERE — passing the
1871
- * context would be a capture cycle.
1342
+ * Stamp (or update) the owning integration id on a device's meta row + emit
1343
+ * `DeviceMetaChanged`. The device-manager chokepoint shared by the
1344
+ * `setIntegrationId` cap handler AND the create/adopt forwarders (queries)
1345
+ * that stamp an integration's ownership onto every device it materializes.
1346
+ * Idempotent. Takes its low-level deps directly (not the full `ProviderContext`)
1347
+ * because `ProviderContext.stampIntegrationId` delegates HERE passing the
1348
+ * context would be a capture cycle.
1872
1349
  */
1873
1350
  async function stampIntegrationId(metaStore, settings, ctx, deviceId, integrationId) {
1874
1351
  await metaStore.withMetaWriteLock(async () => {
@@ -2134,20 +1611,37 @@ async function loadMeta(pctx, input) {
2134
1611
  */
2135
1612
  async function setName(pctx, input) {
2136
1613
  const { deviceId, name } = input;
1614
+ const cascaded = [];
2137
1615
  await pctx.metaStore.withMetaWriteLock(async () => {
2138
1616
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2139
1617
  if (!persisted) throw new Error(`[device-manager] setName: unknown device id=${deviceId}`);
2140
1618
  const { meta: m } = persisted;
2141
1619
  const key = String(deviceId);
1620
+ const oldName = m.name;
2142
1621
  const allMeta = await pctx.metaStore.readMeta();
2143
- await pctx.settings.writeAddonStore({ deviceMeta: {
1622
+ const nextMeta = {
2144
1623
  ...allMeta,
2145
1624
  [key]: {
2146
1625
  ...m,
2147
1626
  name,
2148
1627
  userNamed: true
2149
1628
  }
2150
- } });
1629
+ };
1630
+ if (oldName.length > 0 && oldName !== name) for (const [childKey, childMeta] of Object.entries(allMeta)) {
1631
+ if (childKey === key) continue;
1632
+ if (!(childMeta.parentDeviceId === deviceId || childMeta.linkDeviceId === deviceId)) continue;
1633
+ if (childMeta.name !== oldName && !childMeta.name.startsWith(`${oldName} `)) continue;
1634
+ const childName = childMeta.name === oldName ? name : `${name}${childMeta.name.slice(oldName.length)}`;
1635
+ nextMeta[childKey] = {
1636
+ ...childMeta,
1637
+ name: childName
1638
+ };
1639
+ cascaded.push({
1640
+ id: Number(childKey),
1641
+ name: childName
1642
+ });
1643
+ }
1644
+ await pctx.settings.writeAddonStore({ deviceMeta: nextMeta });
2151
1645
  });
2152
1646
  pctx.host.ctx.eventBus.emit({
2153
1647
  id: (0, node_crypto.randomUUID)(),
@@ -2163,6 +1657,20 @@ async function setName(pctx, input) {
2163
1657
  value: name
2164
1658
  }
2165
1659
  });
1660
+ for (const child of cascaded) pctx.host.ctx.eventBus.emit({
1661
+ id: (0, node_crypto.randomUUID)(),
1662
+ timestamp: /* @__PURE__ */ new Date(),
1663
+ source: {
1664
+ type: "device",
1665
+ id: child.id
1666
+ },
1667
+ category: _camstack_types.EventCategory.DeviceMetaChanged,
1668
+ data: {
1669
+ deviceId: child.id,
1670
+ field: "name",
1671
+ value: child.name
1672
+ }
1673
+ });
2166
1674
  }
2167
1675
  /**
2168
1676
  * Update the operator-organisational location label. `null`
@@ -2396,814 +1904,1338 @@ async function setDeviceLinks(pctx, input) {
2396
1904
  category: _camstack_types.EventCategory.DeviceMetaChanged,
2397
1905
  data: {
2398
1906
  deviceId,
2399
- field: "deviceLinks",
2400
- value: deviceLinks
1907
+ field: "deviceLinks",
1908
+ value: deviceLinks
1909
+ }
1910
+ });
1911
+ }
1912
+ /**
1913
+ * Stamp (or update) the semantic role on the device's meta row.
1914
+ * Called by the kernel's `create()` / `spawnAccessoryChild`
1915
+ * pre-seed when `initialMeta.role` is set (analogous to
1916
+ * `setIntegrationId`). Idempotent. `null` clears a previous role.
1917
+ */
1918
+ async function setRole(pctx, input) {
1919
+ const { deviceId, role } = input;
1920
+ await pctx.metaStore.withMetaWriteLock(async () => {
1921
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
1922
+ if (!persisted) throw new Error(`[device-manager] setRole: unknown device id=${deviceId}`);
1923
+ const { meta: m } = persisted;
1924
+ const key = String(deviceId);
1925
+ const allMeta = await pctx.metaStore.readMeta();
1926
+ await pctx.settings.writeAddonStore({ deviceMeta: {
1927
+ ...allMeta,
1928
+ [key]: {
1929
+ ...m,
1930
+ role
1931
+ }
1932
+ } });
1933
+ });
1934
+ pctx.host.ctx.eventBus.emit({
1935
+ id: (0, node_crypto.randomUUID)(),
1936
+ timestamp: /* @__PURE__ */ new Date(),
1937
+ source: {
1938
+ type: "device",
1939
+ id: deviceId
1940
+ },
1941
+ category: _camstack_types.EventCategory.DeviceMetaChanged,
1942
+ data: {
1943
+ deviceId,
1944
+ field: "role",
1945
+ value: role
1946
+ }
1947
+ });
1948
+ }
1949
+ /**
1950
+ * Batched meta pre-seed. Applies every provided field to the
1951
+ * device's meta row in ONE read-modify-write under a single
1952
+ * `withMetaWriteLock` acquisition (one `deviceMeta` blob write),
1953
+ * then emits one `DeviceMetaChanged` event per field that was
1954
+ * supplied — preserving the exact semantics of the individual
1955
+ * setters (`setName` / `setLocation` / `setType` /
1956
+ * `setIntegrationId` / `setLinkDeviceId` / `setRole`). Omitted
1957
+ * fields are left untouched; `null` clears `location` /
1958
+ * `linkDeviceId` / `role`. Idempotent.
1959
+ *
1960
+ * Collapses the per-child meta pre-seed (up to 6 individual
1961
+ * setter round-trips, each its own lock + write) into one — the
1962
+ * dominant cost when the kernel's `spawnAccessoryChild` reconciles
1963
+ * a many-entity container.
1964
+ */
1965
+ async function applyInitialMeta(pctx, input) {
1966
+ const { deviceId, name, location, type, integrationId, linkDeviceId, role } = input;
1967
+ await pctx.metaStore.withMetaWriteLock(async () => {
1968
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
1969
+ if (!persisted) throw new Error(`[device-manager] applyInitialMeta: unknown device id=${deviceId}`);
1970
+ const { meta: m } = persisted;
1971
+ const key = String(deviceId);
1972
+ const allMeta = await pctx.metaStore.readMeta();
1973
+ const merged = {
1974
+ ...m,
1975
+ ...name !== void 0 ? { name } : {},
1976
+ ...location !== void 0 ? { location } : {},
1977
+ ...type !== void 0 ? { type } : {},
1978
+ ...integrationId !== void 0 ? { integrationId } : {},
1979
+ ...linkDeviceId !== void 0 ? { linkDeviceId } : {},
1980
+ ...role !== void 0 ? { role } : {}
1981
+ };
1982
+ await pctx.settings.writeAddonStore({ deviceMeta: {
1983
+ ...allMeta,
1984
+ [key]: merged
1985
+ } });
1986
+ });
1987
+ const emitMetaChanged = (field, value) => {
1988
+ pctx.host.ctx.eventBus.emit({
1989
+ id: (0, node_crypto.randomUUID)(),
1990
+ timestamp: /* @__PURE__ */ new Date(),
1991
+ source: {
1992
+ type: "device",
1993
+ id: deviceId
1994
+ },
1995
+ category: _camstack_types.EventCategory.DeviceMetaChanged,
1996
+ data: {
1997
+ deviceId,
1998
+ field,
1999
+ value
2000
+ }
2001
+ });
2002
+ };
2003
+ if (name !== void 0) emitMetaChanged("name", name);
2004
+ if (location !== void 0) emitMetaChanged("location", location);
2005
+ if (type !== void 0) emitMetaChanged("type", type);
2006
+ if (integrationId !== void 0) emitMetaChanged("integrationId", integrationId);
2007
+ if (linkDeviceId !== void 0) emitMetaChanged("linkDeviceId", linkDeviceId);
2008
+ if (role !== void 0) emitMetaChanged("role", role);
2009
+ }
2010
+ /**
2011
+ * Patch the device's hardware-identity metadata blob. Shallow
2012
+ * merge — `null` removes a key, anything else overwrites.
2013
+ * Drivers populate factual fields on first probe; operators
2014
+ * augment via the Device Info tab. Idempotent: a no-op patch
2015
+ * (every key already present with the same value) doesn't emit
2016
+ * the meta-changed event.
2017
+ */
2018
+ async function setMetadata(pctx, input) {
2019
+ const { deviceId, patch } = input;
2020
+ const result = await pctx.metaStore.withMetaWriteLock(async () => {
2021
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setMetadata: unknown device id=${deviceId}`);
2022
+ const key = String(deviceId);
2023
+ const map = await pctx.metaStore.readMetadataMap();
2024
+ const next = { ...map[key] ?? {} };
2025
+ let changed = false;
2026
+ for (const [k, v] of Object.entries(patch)) if (v === null) {
2027
+ if (k in next) {
2028
+ delete next[k];
2029
+ changed = true;
2030
+ }
2031
+ } else if (next[k] !== v) {
2032
+ next[k] = v;
2033
+ changed = true;
2034
+ }
2035
+ if (!changed) return { changed: false };
2036
+ const hasFields = Object.keys(next).length > 0;
2037
+ const updatedMap = { ...map };
2038
+ if (hasFields) updatedMap[key] = next;
2039
+ else delete updatedMap[key];
2040
+ await pctx.settings.writeAddonStore({ deviceMetadata: updatedMap });
2041
+ return {
2042
+ changed: true,
2043
+ finalMeta: hasFields ? next : null
2044
+ };
2045
+ });
2046
+ if (!result.changed) return;
2047
+ pctx.host.ctx.eventBus.emit({
2048
+ id: (0, node_crypto.randomUUID)(),
2049
+ timestamp: /* @__PURE__ */ new Date(),
2050
+ source: {
2051
+ type: "device",
2052
+ id: deviceId
2053
+ },
2054
+ category: _camstack_types.EventCategory.DeviceMetaChanged,
2055
+ data: {
2056
+ deviceId,
2057
+ field: "metadata",
2058
+ value: result.finalMeta
2059
+ }
2060
+ });
2061
+ }
2062
+ /**
2063
+ * Soft-disable the device. Persisted on the meta row;
2064
+ * lifecycle gating is the driver's responsibility (BaseDevice
2065
+ * exposes `this.disabled` for the driver to consult at the top
2066
+ * of its lifecycle methods).
2067
+ */
2068
+ async function setDisabled(pctx, input) {
2069
+ const { deviceId, disabled } = input;
2070
+ await pctx.metaStore.withMetaWriteLock(async () => {
2071
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2072
+ if (!persisted) throw new Error(`[device-manager] setDisabled: unknown device id=${deviceId}`);
2073
+ const { meta: m } = persisted;
2074
+ const key = String(deviceId);
2075
+ const allMeta = await pctx.metaStore.readMeta();
2076
+ await pctx.settings.writeAddonStore({ deviceMeta: {
2077
+ ...allMeta,
2078
+ [key]: {
2079
+ ...m,
2080
+ disabled
2081
+ }
2082
+ } });
2083
+ });
2084
+ pctx.host.ctx.eventBus.emit({
2085
+ id: (0, node_crypto.randomUUID)(),
2086
+ timestamp: /* @__PURE__ */ new Date(),
2087
+ source: {
2088
+ type: "device",
2089
+ id: deviceId
2090
+ },
2091
+ category: _camstack_types.EventCategory.DeviceMetaChanged,
2092
+ data: {
2093
+ deviceId,
2094
+ field: "disabled",
2095
+ value: disabled
2096
+ }
2097
+ });
2098
+ }
2099
+ async function loadRuntimeState(pctx, input) {
2100
+ const { deviceId } = input;
2101
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) return {};
2102
+ const data = await pctx.settings.readDeviceRuntimeState(deviceId);
2103
+ pctx.host.seedMirror(deviceId, pctx.host.withResetSessionProbe(data));
2104
+ return data;
2105
+ }
2106
+ /**
2107
+ * Union of (1) operator-curated location registry and (2) labels
2108
+ * currently in use on persisted devices. Case-insensitive
2109
+ * dedupe (preserves the first-seen casing). Sorted
2110
+ * case-insensitively for stable UI. Drives the Device Info
2111
+ * location autocomplete.
2112
+ */
2113
+ async function listLocations(pctx) {
2114
+ const store = await pctx.settings.readAddonStore();
2115
+ const meta = store.deviceMeta ?? {};
2116
+ const locations = store.locations ?? [];
2117
+ const seen = /* @__PURE__ */ new Map();
2118
+ const consider = (raw) => {
2119
+ if (typeof raw !== "string") return;
2120
+ const trimmed = raw.trim();
2121
+ if (trimmed.length === 0) return;
2122
+ const key = trimmed.toLowerCase();
2123
+ if (!seen.has(key)) seen.set(key, trimmed);
2124
+ };
2125
+ for (const label of locations) consider(label);
2126
+ for (const m of Object.values(meta)) consider(m.location);
2127
+ return [...seen.values()].toSorted((a, b) => a.localeCompare(b, void 0, { sensitivity: "base" }));
2128
+ }
2129
+ /**
2130
+ * Add a label to the curated location registry. Idempotent:
2131
+ * existing entries (case-insensitive match) are silently kept.
2132
+ * Empty / whitespace-only inputs throw — operators must supply a
2133
+ * meaningful label.
2134
+ */
2135
+ async function addLocation(pctx, input) {
2136
+ const trimmed = input.name.trim();
2137
+ if (trimmed.length === 0) throw new Error("[device-manager] addLocation: name must be non-empty");
2138
+ const current = (await pctx.settings.readAddonStore()).locations ?? [];
2139
+ if (current.some((l) => l.toLowerCase() === trimmed.toLowerCase())) return;
2140
+ await pctx.settings.writeAddonStore({ locations: [...current, trimmed] });
2141
+ }
2142
+ /**
2143
+ * Remove a label from the curated registry. Match is
2144
+ * case-insensitive. Devices that still reference this label keep
2145
+ * their `meta.location` value (the registry is a suggestion
2146
+ * list, not a foreign key) — pass `cascade: true` to also clear
2147
+ * `setLocation` on every device that referenced this exact
2148
+ * label. Cascade only matches case-insensitively + trimmed, same
2149
+ * as the registry equality check.
2150
+ */
2151
+ async function removeLocation(pctx, input) {
2152
+ const trimmed = input.name.trim();
2153
+ if (trimmed.length === 0) return;
2154
+ const store = await pctx.settings.readAddonStore();
2155
+ const current = store.locations ?? [];
2156
+ const remaining = current.filter((l) => l.toLowerCase() !== trimmed.toLowerCase());
2157
+ if (remaining.length !== current.length) await pctx.settings.writeAddonStore({ locations: remaining });
2158
+ if (input.cascade !== true) return;
2159
+ const meta = store.deviceMeta ?? {};
2160
+ const updates = { ...meta };
2161
+ const cleared = [];
2162
+ for (const [key, m] of Object.entries(meta)) {
2163
+ if (typeof m.location !== "string") continue;
2164
+ if (m.location.trim().toLowerCase() !== trimmed.toLowerCase()) continue;
2165
+ updates[key] = {
2166
+ ...m,
2167
+ location: null
2168
+ };
2169
+ cleared.push(m.id);
2170
+ }
2171
+ if (cleared.length === 0) return;
2172
+ await pctx.settings.writeAddonStore({ deviceMeta: updates });
2173
+ for (const deviceId of cleared) pctx.host.ctx.eventBus.emit({
2174
+ id: (0, node_crypto.randomUUID)(),
2175
+ timestamp: /* @__PURE__ */ new Date(),
2176
+ source: {
2177
+ type: "device",
2178
+ id: deviceId
2179
+ },
2180
+ category: _camstack_types.EventCategory.DeviceMetaChanged,
2181
+ data: {
2182
+ deviceId,
2183
+ field: "location",
2184
+ value: null
2401
2185
  }
2402
2186
  });
2403
2187
  }
2188
+ //#endregion
2189
+ //#region src/builtins/device-manager/device-link-overlay.ts
2190
+ /** Build both lookup maps from resolved link entries. Pure — order-preserving. */
2191
+ function buildLinkIndexes(entries) {
2192
+ const targets = /* @__PURE__ */ new Map();
2193
+ const dependents = /* @__PURE__ */ new Map();
2194
+ for (const { targetDeviceId, link, sourceDeviceId } of entries) {
2195
+ const tKey = `${targetDeviceId}:${link.target.cap}`;
2196
+ const tList = targets.get(tKey) ?? [];
2197
+ tList.push({
2198
+ link,
2199
+ sourceDeviceId
2200
+ });
2201
+ targets.set(tKey, tList);
2202
+ const sKey = `${sourceDeviceId}:${link.source.cap}`;
2203
+ const sList = dependents.get(sKey) ?? [];
2204
+ sList.push({
2205
+ targetDeviceId,
2206
+ targetCap: link.target.cap
2207
+ });
2208
+ dependents.set(sKey, sList);
2209
+ }
2210
+ return {
2211
+ targets,
2212
+ dependents
2213
+ };
2214
+ }
2215
+ //#endregion
2216
+ //#region src/builtins/device-manager/device-meta-store.ts
2404
2217
  /**
2405
- * Stamp (or update) the semantic role on the device's meta row.
2406
- * Called by the kernel's `create()` / `spawnAccessoryChild`
2407
- * pre-seed when `initialMeta.role` is set (analogous to
2408
- * `setIntegrationId`). Idempotent. `null` clears a previous role.
2218
+ * Meta persistence layer for the device-manager addon.
2219
+ *
2220
+ * `DeviceMetaStore` encapsulates the read/modify/write helpers that previously
2221
+ * lived as closures inside `onInitialize`: the `ctx.settings`-backed reads
2222
+ * (`readStore` / `readIndex` / `readMeta` / `readMetadataMap`), the
2223
+ * single-writer mutex (`withMetaWriteLock`), id allocation
2224
+ * (`allocateNextDeviceId`), ownership/child resolution, link-source resolution,
2225
+ * and the cross-device link reverse-index rebuild. Extracting it as a class
2226
+ * (constructed once per `onInitialize`) keeps the EXACT semantics — the
2227
+ * `withMetaWriteLock` promise-chain is per-instance state, identical to the
2228
+ * former closure — while letting the meta-action + query modules depend on a
2229
+ * named, injectable surface instead of captured closures.
2230
+ *
2231
+ * Cross-device link state (the `linkTargets` / `linkDependents` /
2232
+ * `expectedSourceStableIds` maps + the `devicesWithLinks` gate) lives on the
2233
+ * addon; the store reaches it through the injected `LinkIndexHost` so the
2234
+ * single owner of that state stays the addon instance.
2409
2235
  */
2410
- async function setRole(pctx, input) {
2411
- const { deviceId, role } = input;
2412
- await pctx.metaStore.withMetaWriteLock(async () => {
2413
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2414
- if (!persisted) throw new Error(`[device-manager] setRole: unknown device id=${deviceId}`);
2415
- const { meta: m } = persisted;
2416
- const key = String(deviceId);
2417
- const allMeta = await pctx.metaStore.readMeta();
2418
- await pctx.settings.writeAddonStore({ deviceMeta: {
2419
- ...allMeta,
2420
- [key]: {
2421
- ...m,
2422
- role
2236
+ var DeviceMetaStore = class {
2237
+ settings;
2238
+ registry;
2239
+ linkHost;
2240
+ /** Synchronous ownership cache, keyed by NUMERIC deviceId → owning addonId.
2241
+ * The persisted meta store is authoritative but reads are async; hub-side
2242
+ * callers (e.g. `CapabilityRegistry.getNativeProvider` fallback) need
2243
+ * ownership without awaiting. Kept in sync with every register/remove and
2244
+ * warmed from persistence on boot. */
2245
+ idToAddonId = /* @__PURE__ */ new Map();
2246
+ /** Serialises every read-modify-write of the deviceMeta / deviceIndex blob
2247
+ * through one promise chain (see `withMetaWriteLock`). Per-instance state —
2248
+ * identical to the former `onInitialize` closure variable. */
2249
+ metaWriteChain = Promise.resolve();
2250
+ constructor(settings, registry, linkHost) {
2251
+ this.settings = settings;
2252
+ this.registry = registry;
2253
+ this.linkHost = linkHost;
2254
+ }
2255
+ readStore = async () => {
2256
+ return await this.settings.readAddonStore();
2257
+ };
2258
+ readIndex = async () => {
2259
+ return (await this.readStore()).deviceIndex ?? {};
2260
+ };
2261
+ readMeta = async () => {
2262
+ return (await this.readStore()).deviceMeta ?? {};
2263
+ };
2264
+ /** Hardware-identity metadata map. Lives in a sibling key on the
2265
+ * device-manager addon store so its writers (`setMetadata`) never
2266
+ * collide with the lifecycle writers on `deviceMeta`
2267
+ * (`registerDevice` / `setName` / `setLocation` / `setDisabled`).
2268
+ * Single-writer per row eliminates the "writer X clobbers writer
2269
+ * Y's field" bug class — `setMetadata` is the only producer. */
2270
+ readMetadataMap = async () => {
2271
+ return (await this.readStore()).deviceMetadata ?? {};
2272
+ };
2273
+ withMetaWriteLock = async (fn) => {
2274
+ const previous = this.metaWriteChain;
2275
+ let release = () => {};
2276
+ const next = new Promise((resolve) => {
2277
+ release = resolve;
2278
+ });
2279
+ this.metaWriteChain = next;
2280
+ try {
2281
+ await previous.catch(() => {});
2282
+ return await fn();
2283
+ } finally {
2284
+ release();
2285
+ }
2286
+ };
2287
+ /**
2288
+ * Resolve a numeric deviceId to the owning `(addonId, stableId)` pair.
2289
+ * Scans persisted meta — live IDevice lookup (hub registry) is handled
2290
+ * separately per call site so callers can decide whether to route to
2291
+ * an in-process driver or to the cross-process `device-ops` bridge.
2292
+ * Returns null when no device with that id is known to the hub.
2293
+ */
2294
+ resolvePersistedById = async (deviceId) => {
2295
+ const m = (await this.readMeta())[String(deviceId)];
2296
+ if (!m) return null;
2297
+ return {
2298
+ addonId: m.addonId,
2299
+ stableId: m.stableId,
2300
+ meta: m
2301
+ };
2302
+ };
2303
+ /** Direct children of a device: the union of the live registry's children
2304
+ * and the persisted-meta scan (`parentDeviceId === parentId`), deduplicated
2305
+ * and excluding self. Shared by the `remove` cascade and the `resetToSource`
2306
+ * resync purge (#19). */
2307
+ directChildIds = async (parentId) => {
2308
+ const ids = /* @__PURE__ */ new Set();
2309
+ if (this.registry) for (const c of this.registry.getChildren(parentId)) ids.add(c.id);
2310
+ const meta = await this.readMeta();
2311
+ for (const m of Object.values(meta)) if (m.parentDeviceId === parentId) ids.add(m.id);
2312
+ ids.delete(parentId);
2313
+ return [...ids];
2314
+ };
2315
+ /** Resolve a link's `sourceKey` to a live device id: the sibling accessory
2316
+ * whose stableId is `${parentStableId}-${sourceKey}`. Null when absent.
2317
+ * Pure over a pre-read meta map so a multi-link resolve reads the store once. */
2318
+ resolveSourceDeviceId = (parentStableId, sourceKey, meta) => {
2319
+ const wanted = `${parentStableId}-${sourceKey}`;
2320
+ for (const m of Object.values(meta)) if (m.stableId === wanted) return m.id;
2321
+ return null;
2322
+ };
2323
+ allocateNextDeviceId = async () => {
2324
+ const current = (await this.readStore()).nextDeviceId ?? 1;
2325
+ await this.settings.writeAddonStore({ nextDeviceId: current + 1 });
2326
+ return current;
2327
+ };
2328
+ /** Rebuild the `linkTargets` / `linkDependents` reverse-index maps from the
2329
+ * current persisted meta. Called at boot (once the `devicesWithLinks` seed
2330
+ * has run) and whenever the link topology can change: `setDeviceLinks`,
2331
+ * `registerDevice` (a new sibling source may resolve previously-dangling
2332
+ * links), and `removeDevice` (its entries must be evicted). */
2333
+ rebuildLinkDependents = async () => {
2334
+ if (this.linkHost.devicesWithLinks.size === 0) {
2335
+ this.linkHost.setLinkIndexes(/* @__PURE__ */ new Map(), /* @__PURE__ */ new Map(), /* @__PURE__ */ new Set());
2336
+ return;
2337
+ }
2338
+ const allMeta = await this.readMeta();
2339
+ const stableIdById = /* @__PURE__ */ new Map();
2340
+ const idByStableId = /* @__PURE__ */ new Map();
2341
+ for (const m of Object.values(allMeta)) {
2342
+ stableIdById.set(m.id, m.stableId);
2343
+ idByStableId.set(m.stableId, m.id);
2344
+ }
2345
+ const entries = [];
2346
+ const expectedSources = /* @__PURE__ */ new Set();
2347
+ for (const targetId of this.linkHost.devicesWithLinks) {
2348
+ const targetMeta = Object.values(allMeta).find((m) => m.id === targetId);
2349
+ if (!targetMeta) continue;
2350
+ const container = targetMeta.parentDeviceId !== null ? stableIdById.get(targetMeta.parentDeviceId) ?? stableIdById.get(targetId) : stableIdById.get(targetId);
2351
+ if (container === void 0) continue;
2352
+ for (const link of targetMeta.deviceLinks ?? []) {
2353
+ expectedSources.add(`${container}-${link.source.sourceKey}`);
2354
+ const srcId = idByStableId.get(`${container}-${link.source.sourceKey}`);
2355
+ if (srcId === void 0) continue;
2356
+ entries.push({
2357
+ targetDeviceId: targetId,
2358
+ link,
2359
+ sourceDeviceId: srcId
2360
+ });
2423
2361
  }
2424
- } });
2425
- });
2426
- pctx.host.ctx.eventBus.emit({
2427
- id: (0, node_crypto.randomUUID)(),
2428
- timestamp: /* @__PURE__ */ new Date(),
2429
- source: {
2430
- type: "device",
2431
- id: deviceId
2432
- },
2433
- category: _camstack_types.EventCategory.DeviceMetaChanged,
2434
- data: {
2435
- deviceId,
2436
- field: "role",
2437
- value: role
2438
2362
  }
2439
- });
2440
- }
2363
+ const { targets, dependents } = buildLinkIndexes(entries);
2364
+ this.linkHost.setLinkIndexes(targets, dependents, expectedSources);
2365
+ };
2366
+ };
2367
+ //#endregion
2368
+ //#region src/builtins/device-manager/device-queries.ts
2441
2369
  /**
2442
- * Batched meta pre-seed. Applies every provided field to the
2443
- * device's meta row in ONE read-modify-write under a single
2444
- * `withMetaWriteLock` acquisition (one `deviceMeta` blob write),
2445
- * then emits one `DeviceMetaChanged` event per field that was
2446
- * supplied — preserving the exact semantics of the individual
2447
- * setters (`setName` / `setLocation` / `setType` /
2448
- * `setIntegrationId` / `setLinkDeviceId` / `setRole`). Omitted
2449
- * fields are left untouched; `null` clears `location` /
2450
- * `linkDeviceId` / `role`. Idempotent.
2370
+ * Device query + live-runtime + provider/adoption operations for the
2371
+ * device-manager addon the read/live half of the `IDeviceManagerProvider`
2372
+ * surface. Extracted verbatim from the `onInitialize` provider literal as free
2373
+ * functions taking a `ProviderContext` (DI). Input/return types are pinned to
2374
+ * the cap contract via `Parameters<IDeviceManagerProvider['x']>[0]` /
2375
+ * `ReturnType<…>` so the typed surface is identical to the inferred methods.
2451
2376
  *
2452
- * Collapses the per-child meta pre-seed (up to 6 individual
2453
- * setter round-trips, each its own lock + write) into one the
2454
- * dominant cost when the kernel's `spawnAccessoryChild` reconciles
2455
- * a many-entity container.
2377
+ * Covers: persisted/live device queries (listPersistedByAddon, listAll,
2378
+ * getDevice, getChildren), live runtime methods (getStreamSources,
2379
+ * getConfigSchema, getSettingsSchema, updateConfig, enable, disable, remove,
2380
+ * removeByIntegration, getStreamProfileMap, setStreamProfileMap, probeStreams),
2381
+ * device-provider ops (discoverDevices, adoptDevice, getCreationSchema,
2382
+ * createDevice, testCreationField), and device-adoption ops
2383
+ * (adoptionListCandidates/Refresh/Adopt/Release/Resync, testField).
2384
+ *
2385
+ * The hub-live-then-persisted-fallback union, the cross-process device-ops
2386
+ * dispatch, the depth-first remove cascade, and the resetToSource resync purge
2387
+ * are byte-for-byte preserved. `remove`/`removeByIntegration`/`enable`/`disable`/
2388
+ * `probeStreams`/`getStreamProfileMap` self-call sibling cap methods via
2389
+ * `pctx.provider`. Pure refactor — no behavior change.
2456
2390
  */
2457
- async function applyInitialMeta(pctx, input) {
2458
- const { deviceId, name, location, type, integrationId, linkDeviceId, role } = input;
2459
- await pctx.metaStore.withMetaWriteLock(async () => {
2460
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2461
- if (!persisted) throw new Error(`[device-manager] applyInitialMeta: unknown device id=${deviceId}`);
2462
- const { meta: m } = persisted;
2463
- const key = String(deviceId);
2464
- const allMeta = await pctx.metaStore.readMeta();
2465
- const merged = {
2466
- ...m,
2467
- ...name !== void 0 ? { name } : {},
2468
- ...location !== void 0 ? { location } : {},
2469
- ...type !== void 0 ? { type } : {},
2470
- ...integrationId !== void 0 ? { integrationId } : {},
2471
- ...linkDeviceId !== void 0 ? { linkDeviceId } : {},
2472
- ...role !== void 0 ? { role } : {}
2391
+ async function listPersistedByAddon(pctx, input) {
2392
+ const { addonId } = input;
2393
+ const [index, meta] = await Promise.all([pctx.metaStore.readIndex(), pctx.metaStore.readMeta()]);
2394
+ const stableIds = index[addonId] ?? [];
2395
+ const byStableId = /* @__PURE__ */ new Map();
2396
+ for (const m of Object.values(meta)) if (m.addonId === addonId) byStableId.set(m.stableId, m);
2397
+ return stableIds.map((stableId) => {
2398
+ const m = byStableId.get(stableId);
2399
+ return {
2400
+ id: m.id,
2401
+ stableId,
2402
+ type: m.type,
2403
+ name: m.name,
2404
+ location: m.location ?? null,
2405
+ disabled: m.disabled ?? false,
2406
+ parentDeviceId: m.parentDeviceId
2473
2407
  };
2474
- await pctx.settings.writeAddonStore({ deviceMeta: {
2475
- ...allMeta,
2476
- [key]: merged
2477
- } });
2478
2408
  });
2479
- const emitMetaChanged = (field, value) => {
2480
- pctx.host.ctx.eventBus.emit({
2481
- id: (0, node_crypto.randomUUID)(),
2482
- timestamp: /* @__PURE__ */ new Date(),
2483
- source: {
2484
- type: "device",
2485
- id: deviceId
2486
- },
2487
- category: _camstack_types.EventCategory.DeviceMetaChanged,
2488
- data: {
2489
- deviceId,
2490
- field,
2491
- value
2492
- }
2409
+ }
2410
+ async function listAll(pctx, input) {
2411
+ const { addonId } = input;
2412
+ const results = [];
2413
+ const seen = /* @__PURE__ */ new Set();
2414
+ const meta = await pctx.metaStore.readMeta();
2415
+ const metadataMap = await pctx.metaStore.readMetadataMap();
2416
+ if (pctx.registry) {
2417
+ const liveEntries = addonId ? pctx.registry.getAllForAddon(addonId).map((device) => ({
2418
+ addonId,
2419
+ device
2420
+ })) : pctx.registry.getAllWithAddonId();
2421
+ for (const { addonId: aid, device } of liveEntries) {
2422
+ const key = String(device.id);
2423
+ const metadata = metadataMap[key] ?? null;
2424
+ const metaRow = meta[key] ?? null;
2425
+ results.push(toDeviceInfo(aid, device, metadata, metaRow));
2426
+ seen.add(key);
2427
+ }
2428
+ }
2429
+ const index = await pctx.metaStore.readIndex();
2430
+ const metaByAddonStable = /* @__PURE__ */ new Map();
2431
+ for (const m of Object.values(meta)) metaByAddonStable.set(`${m.addonId}${m.stableId}`, m);
2432
+ const targetAddons = addonId ? [addonId] : Object.keys(index);
2433
+ for (const aid of targetAddons) for (const stableId of index[aid] ?? []) {
2434
+ const m = metaByAddonStable.get(`${aid}${stableId}`);
2435
+ const key = String(m.id);
2436
+ if (seen.has(key)) continue;
2437
+ const persistedType = m.type;
2438
+ const persistedConfig = await pctx.settings.readDeviceStore(m.id);
2439
+ const metadata = metadataMap[key] ?? null;
2440
+ results.push({
2441
+ id: m.id,
2442
+ stableId,
2443
+ addonId: aid,
2444
+ type: persistedType,
2445
+ name: m?.name ?? stableId,
2446
+ location: m?.location ?? null,
2447
+ disabled: m?.disabled ?? false,
2448
+ parentDeviceId: m?.parentDeviceId ?? null,
2449
+ role: toDeviceRole(m?.role),
2450
+ online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
2451
+ probed: pctx.host.resolveDeviceProbed(m.id),
2452
+ features: persistedFeatures(m?.features),
2453
+ isCamera: persistedType === _camstack_types.DeviceType.Camera,
2454
+ config: persistedConfig ?? {},
2455
+ metadata,
2456
+ ...m?.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
2457
+ ...m?.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
2458
+ ...m?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2459
+ ...m?.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2460
+ ...m?.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2461
+ ...(() => {
2462
+ const si = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
2463
+ return si !== void 0 ? { sourceInfo: si } : {};
2464
+ })()
2493
2465
  });
2466
+ }
2467
+ return results;
2468
+ }
2469
+ async function getDevice(pctx, input) {
2470
+ const { deviceId } = input;
2471
+ if (pctx.registry) {
2472
+ const found = resolveDeviceById(pctx.registry, deviceId);
2473
+ if (found) {
2474
+ const key = String(found.device.id);
2475
+ const [map, metaMap] = await Promise.all([pctx.metaStore.readMetadataMap(), pctx.metaStore.readMeta()]);
2476
+ const metadata = map[key] ?? null;
2477
+ const metaRow = metaMap[key] ?? null;
2478
+ return toDeviceInfo(found.addonId, found.device, metadata, metaRow);
2479
+ }
2480
+ }
2481
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2482
+ if (!persisted) return null;
2483
+ const { addonId: aid, stableId, meta: m } = persisted;
2484
+ const persistedConfig = await pctx.settings.readDeviceStore(m.id);
2485
+ const key = String(deviceId);
2486
+ const metadata = (await pctx.metaStore.readMetadataMap())[key] ?? null;
2487
+ const sourceInfoGetDevice = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
2488
+ return {
2489
+ id: deviceId,
2490
+ stableId,
2491
+ addonId: aid,
2492
+ type: m.type,
2493
+ name: m.name,
2494
+ location: m.location ?? null,
2495
+ disabled: m.disabled ?? false,
2496
+ parentDeviceId: m.parentDeviceId,
2497
+ role: toDeviceRole(m.role),
2498
+ online: pctx.host.resolveDeviceOnline(deviceId, true),
2499
+ probed: pctx.host.resolveDeviceProbed(deviceId),
2500
+ features: persistedFeatures(m.features),
2501
+ isCamera: false,
2502
+ config: persistedConfig ?? {},
2503
+ metadata,
2504
+ ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
2505
+ ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
2506
+ ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2507
+ ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2508
+ ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2509
+ ...sourceInfoGetDevice !== void 0 ? { sourceInfo: sourceInfoGetDevice } : {}
2494
2510
  };
2495
- if (name !== void 0) emitMetaChanged("name", name);
2496
- if (location !== void 0) emitMetaChanged("location", location);
2497
- if (type !== void 0) emitMetaChanged("type", type);
2498
- if (integrationId !== void 0) emitMetaChanged("integrationId", integrationId);
2499
- if (linkDeviceId !== void 0) emitMetaChanged("linkDeviceId", linkDeviceId);
2500
- if (role !== void 0) emitMetaChanged("role", role);
2501
2511
  }
2502
- /**
2503
- * Patch the device's hardware-identity metadata blob. Shallow
2504
- * merge `null` removes a key, anything else overwrites.
2505
- * Drivers populate factual fields on first probe; operators
2506
- * augment via the Device Info tab. Idempotent: a no-op patch
2507
- * (every key already present with the same value) doesn't emit
2508
- * the meta-changed event.
2509
- */
2510
- async function setMetadata(pctx, input) {
2511
- const { deviceId, patch } = input;
2512
- const result = await pctx.metaStore.withMetaWriteLock(async () => {
2513
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setMetadata: unknown device id=${deviceId}`);
2514
- const key = String(deviceId);
2515
- const map = await pctx.metaStore.readMetadataMap();
2516
- const next = { ...map[key] ?? {} };
2517
- let changed = false;
2518
- for (const [k, v] of Object.entries(patch)) if (v === null) {
2519
- if (k in next) {
2520
- delete next[k];
2521
- changed = true;
2522
- }
2523
- } else if (next[k] !== v) {
2524
- next[k] = v;
2525
- changed = true;
2512
+ async function getChildren(pctx, input) {
2513
+ const { parentDeviceId } = input;
2514
+ let ownerAddonId = null;
2515
+ if (pctx.registry) {
2516
+ if (pctx.registry.getById(parentDeviceId)) ownerAddonId = pctx.registry.getAddonId(parentDeviceId);
2517
+ }
2518
+ if (!ownerAddonId) {
2519
+ const persisted = await pctx.metaStore.resolvePersistedById(parentDeviceId);
2520
+ if (!persisted) return [];
2521
+ ownerAddonId = persisted.addonId;
2522
+ }
2523
+ const results = [];
2524
+ const seen = /* @__PURE__ */ new Set();
2525
+ const [index, meta, metadataMap] = await Promise.all([
2526
+ pctx.metaStore.readIndex(),
2527
+ pctx.metaStore.readMeta(),
2528
+ pctx.metaStore.readMetadataMap()
2529
+ ]);
2530
+ if (pctx.registry) {
2531
+ const liveChildren = pctx.registry.getChildren(parentDeviceId);
2532
+ for (const device of liveChildren) {
2533
+ const key = String(device.id);
2534
+ const metadata = metadataMap[key] ?? null;
2535
+ const metaRow = meta[key] ?? null;
2536
+ results.push(toDeviceInfo(ownerAddonId, device, metadata, metaRow));
2537
+ seen.add(key);
2538
+ }
2539
+ }
2540
+ const ownerMetaByStableId = /* @__PURE__ */ new Map();
2541
+ for (const m of Object.values(meta)) if (m.addonId === ownerAddonId) ownerMetaByStableId.set(m.stableId, m);
2542
+ const persistedChildren = (index[ownerAddonId] ?? []).filter((sid) => ownerMetaByStableId.get(sid)?.parentDeviceId === parentDeviceId);
2543
+ for (const childStableId of persistedChildren) {
2544
+ const m = ownerMetaByStableId.get(childStableId);
2545
+ const key = String(m.id);
2546
+ if (seen.has(key)) continue;
2547
+ const persistedConfig = await pctx.settings.readDeviceStore(m.id);
2548
+ const metadata = metadataMap[key] ?? null;
2549
+ const sourceInfoChild = buildSourceInfoFromConfig(persistedConfig ?? {}, childStableId, ownerAddonId);
2550
+ results.push({
2551
+ id: m.id,
2552
+ stableId: childStableId,
2553
+ addonId: ownerAddonId,
2554
+ type: m.type,
2555
+ name: m.name,
2556
+ location: m.location ?? null,
2557
+ disabled: m.disabled ?? false,
2558
+ parentDeviceId,
2559
+ role: toDeviceRole(m.role),
2560
+ online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
2561
+ probed: pctx.host.resolveDeviceProbed(m.id),
2562
+ features: persistedFeatures(m.features),
2563
+ isCamera: false,
2564
+ config: persistedConfig ?? {},
2565
+ metadata,
2566
+ ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
2567
+ ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
2568
+ ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2569
+ ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2570
+ ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2571
+ ...sourceInfoChild !== void 0 ? { sourceInfo: sourceInfoChild } : {}
2572
+ });
2573
+ }
2574
+ return results;
2575
+ }
2576
+ async function getStreamSources(pctx, input) {
2577
+ const { deviceId } = input;
2578
+ if (pctx.registry) {
2579
+ const found = resolveDeviceById(pctx.registry, deviceId);
2580
+ if (found) {
2581
+ if (!isCameraDevice(found.device)) return [];
2582
+ return (await found.device.getStreamSources()).map((s) => ({
2583
+ id: s.id,
2584
+ label: s.label,
2585
+ protocol: s.protocol,
2586
+ url: s.url,
2587
+ resolution: s.resolution,
2588
+ fps: s.fps,
2589
+ bitrate: s.bitrate,
2590
+ codec: s.codec,
2591
+ profileHint: s.profileHint
2592
+ }));
2526
2593
  }
2527
- if (!changed) return { changed: false };
2528
- const hasFields = Object.keys(next).length > 0;
2529
- const updatedMap = { ...map };
2530
- if (hasFields) updatedMap[key] = next;
2531
- else delete updatedMap[key];
2532
- await pctx.settings.writeAddonStore({ deviceMetadata: updatedMap });
2533
- return {
2534
- changed: true,
2535
- finalMeta: hasFields ? next : null
2536
- };
2537
- });
2538
- if (!result.changed) return;
2539
- pctx.host.ctx.eventBus.emit({
2540
- id: (0, node_crypto.randomUUID)(),
2541
- timestamp: /* @__PURE__ */ new Date(),
2542
- source: {
2543
- type: "device",
2544
- id: deviceId
2545
- },
2546
- category: _camstack_types.EventCategory.DeviceMetaChanged,
2547
- data: {
2548
- deviceId,
2549
- field: "metadata",
2550
- value: result.finalMeta
2594
+ }
2595
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
2596
+ return (await pctx.requireDeviceOps(deviceId).getStreamSources({ deviceId })).map((s) => ({ ...s }));
2597
+ }
2598
+ async function getConfigSchema(pctx, input) {
2599
+ const { deviceId } = input;
2600
+ if (pctx.registry) {
2601
+ const found = resolveDeviceById(pctx.registry, deviceId);
2602
+ if (found) return found.device.config.entries().map((entry) => ({
2603
+ key: entry.key,
2604
+ value: entry.value,
2605
+ ...entry.description !== void 0 ? { description: entry.description } : {}
2606
+ }));
2607
+ }
2608
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
2609
+ return (await pctx.requireDeviceOps(deviceId).getConfigEntries({ deviceId })).map((e) => ({ ...e }));
2610
+ }
2611
+ async function getSettingsSchema(pctx, input) {
2612
+ const { deviceId } = input;
2613
+ if (pctx.registry) {
2614
+ const found = resolveDeviceById(pctx.registry, deviceId);
2615
+ if (found) return found.device.getSettingsUISchema();
2616
+ }
2617
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) return null;
2618
+ return await pctx.requireDeviceOps(deviceId).getSettingsSchema({ deviceId }) ?? null;
2619
+ }
2620
+ async function updateConfig(pctx, input) {
2621
+ const { deviceId } = input;
2622
+ if (pctx.registry) {
2623
+ const found = resolveDeviceById(pctx.registry, deviceId);
2624
+ if (found) {
2625
+ await found.device.config.setAll(input.values);
2626
+ return { success: true };
2551
2627
  }
2628
+ }
2629
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
2630
+ await pctx.requireDeviceOps(deviceId).setConfig({
2631
+ deviceId,
2632
+ values: input.values
2552
2633
  });
2634
+ return { success: true };
2553
2635
  }
2554
- /**
2555
- * Soft-disable the device. Persisted on the meta row;
2556
- * lifecycle gating is the driver's responsibility (BaseDevice
2557
- * exposes `this.disabled` for the driver to consult at the top
2558
- * of its lifecycle methods).
2559
- */
2560
- async function setDisabled(pctx, input) {
2561
- const { deviceId, disabled } = input;
2562
- await pctx.metaStore.withMetaWriteLock(async () => {
2563
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2564
- if (!persisted) throw new Error(`[device-manager] setDisabled: unknown device id=${deviceId}`);
2565
- const { meta: m } = persisted;
2566
- const key = String(deviceId);
2567
- const allMeta = await pctx.metaStore.readMeta();
2568
- await pctx.settings.writeAddonStore({ deviceMeta: {
2569
- ...allMeta,
2570
- [key]: {
2571
- ...m,
2572
- disabled
2573
- }
2574
- } });
2636
+ async function enable(pctx, input) {
2637
+ await pctx.provider.setDisabled({
2638
+ deviceId: input.deviceId,
2639
+ disabled: false
2575
2640
  });
2576
- pctx.host.ctx.eventBus.emit({
2577
- id: (0, node_crypto.randomUUID)(),
2578
- timestamp: /* @__PURE__ */ new Date(),
2579
- source: {
2580
- type: "device",
2581
- id: deviceId
2582
- },
2583
- category: _camstack_types.EventCategory.DeviceMetaChanged,
2584
- data: {
2585
- deviceId,
2586
- field: "disabled",
2587
- value: disabled
2588
- }
2641
+ return { success: true };
2642
+ }
2643
+ async function disable(pctx, input) {
2644
+ await pctx.provider.setDisabled({
2645
+ deviceId: input.deviceId,
2646
+ disabled: true
2589
2647
  });
2648
+ return { success: true };
2590
2649
  }
2591
- async function loadRuntimeState(pctx, input) {
2650
+ async function remove(pctx, input) {
2592
2651
  const { deviceId } = input;
2593
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) return {};
2594
- const data = await pctx.settings.readDeviceRuntimeState(deviceId);
2595
- pctx.host.seedMirror(deviceId, pctx.host.withResetSessionProbe(data));
2596
- return data;
2597
- }
2598
- /**
2599
- * Union of (1) operator-curated location registry and (2) labels
2600
- * currently in use on persisted devices. Case-insensitive
2601
- * dedupe (preserves the first-seen casing). Sorted
2602
- * case-insensitively for stable UI. Drives the Device Info
2603
- * location autocomplete.
2604
- */
2605
- async function listLocations(pctx) {
2606
- const store = await pctx.settings.readAddonStore();
2607
- const meta = store.deviceMeta ?? {};
2608
- const locations = store.locations ?? [];
2609
- const seen = /* @__PURE__ */ new Map();
2610
- const consider = (raw) => {
2611
- if (typeof raw !== "string") return;
2612
- const trimmed = raw.trim();
2613
- if (trimmed.length === 0) return;
2614
- const key = trimmed.toLowerCase();
2615
- if (!seen.has(key)) seen.set(key, trimmed);
2616
- };
2617
- for (const label of locations) consider(label);
2618
- for (const m of Object.values(meta)) consider(m.location);
2619
- return [...seen.values()].toSorted((a, b) => a.localeCompare(b, void 0, { sensitivity: "base" }));
2620
- }
2621
- /**
2622
- * Add a label to the curated location registry. Idempotent:
2623
- * existing entries (case-insensitive match) are silently kept.
2624
- * Empty / whitespace-only inputs throw — operators must supply a
2625
- * meaningful label.
2626
- */
2627
- async function addLocation(pctx, input) {
2628
- const trimmed = input.name.trim();
2629
- if (trimmed.length === 0) throw new Error("[device-manager] addLocation: name must be non-empty");
2630
- const current = (await pctx.settings.readAddonStore()).locations ?? [];
2631
- if (current.some((l) => l.toLowerCase() === trimmed.toLowerCase())) return;
2632
- await pctx.settings.writeAddonStore({ locations: [...current, trimmed] });
2633
- }
2634
- /**
2635
- * Remove a label from the curated registry. Match is
2636
- * case-insensitive. Devices that still reference this label keep
2637
- * their `meta.location` value (the registry is a suggestion
2638
- * list, not a foreign key) — pass `cascade: true` to also clear
2639
- * `setLocation` on every device that referenced this exact
2640
- * label. Cascade only matches case-insensitively + trimmed, same
2641
- * as the registry equality check.
2642
- */
2643
- async function removeLocation(pctx, input) {
2644
- const trimmed = input.name.trim();
2645
- if (trimmed.length === 0) return;
2646
- const store = await pctx.settings.readAddonStore();
2647
- const current = store.locations ?? [];
2648
- const remaining = current.filter((l) => l.toLowerCase() !== trimmed.toLowerCase());
2649
- if (remaining.length !== current.length) await pctx.settings.writeAddonStore({ locations: remaining });
2650
- if (input.cascade !== true) return;
2651
- const meta = store.deviceMeta ?? {};
2652
- const updates = { ...meta };
2653
- const cleared = [];
2654
- for (const [key, m] of Object.entries(meta)) {
2655
- if (typeof m.location !== "string") continue;
2656
- if (m.location.trim().toLowerCase() !== trimmed.toLowerCase()) continue;
2657
- updates[key] = {
2658
- ...m,
2659
- location: null
2660
- };
2661
- cleared.push(m.id);
2662
- }
2663
- if (cleared.length === 0) return;
2664
- await pctx.settings.writeAddonStore({ deviceMeta: updates });
2665
- for (const deviceId of cleared) pctx.host.ctx.eventBus.emit({
2666
- id: (0, node_crypto.randomUUID)(),
2667
- timestamp: /* @__PURE__ */ new Date(),
2668
- source: {
2669
- type: "device",
2670
- id: deviceId
2671
- },
2672
- category: _camstack_types.EventCategory.DeviceMetaChanged,
2673
- data: {
2674
- deviceId,
2675
- field: "location",
2676
- value: null
2652
+ const removeOne = async (id) => {
2653
+ if (pctx.registry) {
2654
+ const live = resolveDeviceById(pctx.registry, id);
2655
+ if (live) {
2656
+ const deviceName = live.device.name;
2657
+ await live.device.removeDevice();
2658
+ pctx.registry.remove(id);
2659
+ await pctx.provider.removeDevice({ deviceId: id });
2660
+ pctx.host.ctx.logger.info("removed hub-local device", { tags: {
2661
+ deviceId: id,
2662
+ deviceName
2663
+ } });
2664
+ return;
2665
+ }
2677
2666
  }
2678
- });
2667
+ const persisted = await pctx.metaStore.resolvePersistedById(id);
2668
+ if (!persisted) return;
2669
+ const { meta: persistedMeta } = persisted;
2670
+ try {
2671
+ await pctx.requireDeviceOps(id).removeDevice({ deviceId: id });
2672
+ } catch (err) {
2673
+ pctx.host.ctx.logger.warn("remove via device-ops failed — clearing persistence anyway", {
2674
+ tags: {
2675
+ deviceId: id,
2676
+ deviceName: persistedMeta.name
2677
+ },
2678
+ meta: { error: (0, _camstack_types.errMsg)(err) }
2679
+ });
2680
+ }
2681
+ await pctx.provider.removeDevice({ deviceId: id });
2682
+ };
2683
+ const removeCascade = async (id) => {
2684
+ for (const childId of await pctx.metaStore.directChildIds(id)) await removeCascade(childId);
2685
+ await removeOne(id);
2686
+ };
2687
+ await removeCascade(deviceId);
2688
+ return { success: true };
2679
2689
  }
2680
- //#endregion
2681
- //#region src/builtins/device-manager/device-queries.ts
2682
2690
  /**
2683
- * Device query + live-runtime + provider/adoption operations for the
2684
- * device-manager addon the read/live half of the `IDeviceManagerProvider`
2685
- * surface. Extracted verbatim from the `onInitialize` provider literal as free
2686
- * functions taking a `ProviderContext` (DI). Input/return types are pinned to
2687
- * the cap contract via `Parameters<IDeviceManagerProvider['x']>[0]` /
2688
- * `ReturnType<…>` so the typed surface is identical to the inferred methods.
2689
- *
2690
- * Covers: persisted/live device queries (listPersistedByAddon, listAll,
2691
- * getDevice, getChildren), live runtime methods (getStreamSources,
2692
- * getConfigSchema, getSettingsSchema, updateConfig, enable, disable, remove,
2693
- * removeByIntegration, getStreamProfileMap, setStreamProfileMap, probeStreams),
2694
- * device-provider ops (discoverDevices, adoptDevice, getCreationSchema,
2695
- * createDevice, testCreationField), and device-adoption ops
2696
- * (adoptionListCandidates/Refresh/Adopt/Release/Resync, testField).
2697
- *
2698
- * The hub-live-then-persisted-fallback union, the cross-process device-ops
2699
- * dispatch, the depth-first remove cascade, and the resetToSource resync purge
2700
- * are byte-for-byte preserved. `remove`/`removeByIntegration`/`enable`/`disable`/
2701
- * `probeStreams`/`getStreamProfileMap` self-call sibling cap methods via
2702
- * `pctx.provider`. Pure refactor — no behavior change.
2691
+ * Cascade-delete every top-level device whose `integrationId`
2692
+ * matches. Enumerates a SNAPSHOT of the meta map so concurrent
2693
+ * removals don't clobber each other. Only top-level parents are
2694
+ * enumerated children cascade via the per-parent `removeCascade`
2695
+ * inside the delegated `remove` call. Idempotent: devices with no
2696
+ * `integrationId` never match.
2703
2697
  */
2704
- async function listPersistedByAddon(pctx, input) {
2705
- const { addonId } = input;
2706
- const [index, meta] = await Promise.all([pctx.metaStore.readIndex(), pctx.metaStore.readMeta()]);
2707
- const stableIds = index[addonId] ?? [];
2708
- const byStableId = /* @__PURE__ */ new Map();
2709
- for (const m of Object.values(meta)) if (m.addonId === addonId) byStableId.set(m.stableId, m);
2710
- return stableIds.map((stableId) => {
2711
- const m = byStableId.get(stableId);
2712
- return {
2713
- id: m.id,
2714
- stableId,
2715
- type: m.type,
2716
- name: m.name,
2717
- location: m.location ?? null,
2718
- disabled: m.disabled ?? false,
2719
- parentDeviceId: m.parentDeviceId
2720
- };
2698
+ async function removeByIntegration(pctx, input) {
2699
+ const { integrationId } = input;
2700
+ const meta = await pctx.metaStore.readMeta();
2701
+ const parentKeys = Object.keys(meta).filter((key) => {
2702
+ const m = meta[key];
2703
+ return m !== void 0 && m.integrationId === integrationId && m.parentDeviceId === null;
2721
2704
  });
2705
+ let removed = 0;
2706
+ for (const _key of parentKeys) {
2707
+ const m = meta[_key];
2708
+ if (!m) continue;
2709
+ await pctx.provider.remove({ deviceId: m.id });
2710
+ removed++;
2711
+ }
2712
+ return { removed };
2722
2713
  }
2723
- async function listAll(pctx, input) {
2724
- const { addonId } = input;
2725
- const results = [];
2726
- const seen = /* @__PURE__ */ new Set();
2727
- const meta = await pctx.metaStore.readMeta();
2728
- const metadataMap = await pctx.metaStore.readMetadataMap();
2714
+ async function getStreamProfileMap(pctx, input) {
2715
+ if (!pctx.registry) return {};
2716
+ const found = resolveDeviceById(pctx.registry, input.deviceId);
2717
+ if (!found) return {};
2718
+ const storedMap = found.device.config.entries().find((e) => e.key === "_profileMap")?.value;
2719
+ if (storedMap !== void 0 && typeof storedMap === "object" && storedMap !== null) return storedMap;
2720
+ if (!isCameraDevice(found.device)) return {};
2721
+ const sources = await found.device.getStreamSources();
2722
+ const profileMap = {};
2723
+ for (const s of sources) if (s.profileHint && s.id) profileMap[s.profileHint] = s.id;
2724
+ return profileMap;
2725
+ }
2726
+ async function setStreamProfileMap(pctx, input) {
2727
+ const { deviceId } = input;
2729
2728
  if (pctx.registry) {
2730
- const liveEntries = addonId ? pctx.registry.getAllForAddon(addonId).map((device) => ({
2731
- addonId,
2732
- device
2733
- })) : pctx.registry.getAllWithAddonId();
2734
- for (const { addonId: aid, device } of liveEntries) {
2735
- const key = String(device.id);
2736
- const metadata = metadataMap[key] ?? null;
2737
- const metaRow = meta[key] ?? null;
2738
- results.push(toDeviceInfo(aid, device, metadata, metaRow));
2739
- seen.add(key);
2729
+ const found = resolveDeviceById(pctx.registry, deviceId);
2730
+ if (found) {
2731
+ await found.device.config.setAll({ _profileMap: input.profileMap });
2732
+ return { success: true };
2740
2733
  }
2741
2734
  }
2742
- const index = await pctx.metaStore.readIndex();
2743
- const metaByAddonStable = /* @__PURE__ */ new Map();
2744
- for (const m of Object.values(meta)) metaByAddonStable.set(`${m.addonId}${m.stableId}`, m);
2745
- const targetAddons = addonId ? [addonId] : Object.keys(index);
2746
- for (const aid of targetAddons) for (const stableId of index[aid] ?? []) {
2747
- const m = metaByAddonStable.get(`${aid}${stableId}`);
2748
- const key = String(m.id);
2749
- if (seen.has(key)) continue;
2750
- const persistedType = m.type;
2751
- const persistedConfig = await pctx.settings.readDeviceStore(m.id);
2752
- const metadata = metadataMap[key] ?? null;
2753
- results.push({
2754
- id: m.id,
2755
- stableId,
2756
- addonId: aid,
2757
- type: persistedType,
2758
- name: m?.name ?? stableId,
2759
- location: m?.location ?? null,
2760
- disabled: m?.disabled ?? false,
2761
- parentDeviceId: m?.parentDeviceId ?? null,
2762
- role: toDeviceRole(m?.role),
2763
- online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
2764
- probed: pctx.host.resolveDeviceProbed(m.id),
2765
- features: persistedFeatures(m?.features),
2766
- isCamera: persistedType === _camstack_types.DeviceType.Camera,
2767
- config: persistedConfig ?? {},
2768
- metadata,
2769
- ...m?.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
2770
- ...m?.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
2771
- ...m?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2772
- ...m?.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2773
- ...m?.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2774
- ...(() => {
2775
- const si = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
2776
- return si !== void 0 ? { sourceInfo: si } : {};
2777
- })()
2778
- });
2735
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
2736
+ await pctx.requireDeviceOps(deviceId).setConfig({
2737
+ deviceId,
2738
+ values: { _profileMap: input.profileMap }
2739
+ });
2740
+ return { success: true };
2741
+ }
2742
+ async function probeStreams(pctx, input) {
2743
+ const streamProbe = pctx.host.ctx.kernel.streamProbe;
2744
+ if (!streamProbe) return [];
2745
+ const sources = await pctx.provider.getStreamSources({ deviceId: input.deviceId });
2746
+ const results = [];
2747
+ for (const s of sources) {
2748
+ if (!s.url) continue;
2749
+ try {
2750
+ const metadata = await streamProbe.probe(s.url, { force: true });
2751
+ results.push({
2752
+ streamId: s.id,
2753
+ width: metadata.width,
2754
+ height: metadata.height,
2755
+ codec: metadata.codec,
2756
+ fps: metadata.fps,
2757
+ bitrateKbps: metadata.bitrateKbps
2758
+ });
2759
+ } catch (err) {
2760
+ pctx.host.ctx.logger.debug("streamProbe.probe failed returning placeholder", { meta: {
2761
+ deviceId: input.deviceId,
2762
+ streamId: s.id,
2763
+ error: err instanceof Error ? err.message : String(err)
2764
+ } });
2765
+ results.push({ streamId: s.id });
2766
+ }
2779
2767
  }
2780
2768
  return results;
2781
2769
  }
2782
- async function getDevice(pctx, input) {
2783
- const { deviceId } = input;
2784
- if (pctx.registry) {
2785
- const found = resolveDeviceById(pctx.registry, deviceId);
2786
- if (found) {
2787
- const key = String(found.device.id);
2788
- const [map, metaMap] = await Promise.all([pctx.metaStore.readMetadataMap(), pctx.metaStore.readMeta()]);
2789
- const metadata = map[key] ?? null;
2790
- const metaRow = metaMap[key] ?? null;
2791
- return toDeviceInfo(found.addonId, found.device, metadata, metaRow);
2770
+ async function discoverDevices(pctx, input) {
2771
+ const dp = await pctx.host.requireDeviceProvider(input.addonId);
2772
+ if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device discovery`);
2773
+ return (await dp.discoverDevices({})).map((d) => ({
2774
+ stableId: d.stableId,
2775
+ type: d.type,
2776
+ suggestedName: d.suggestedName,
2777
+ prefilledConfig: d.prefilledConfig
2778
+ }));
2779
+ }
2780
+ async function adoptDevice(pctx, input) {
2781
+ const dp = await pctx.host.requireDeviceProvider(input.addonId);
2782
+ if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device adoption`);
2783
+ const summary = await dp.adoptDiscoveredDevice({ candidate: input.candidate });
2784
+ if (input.integrationId !== void 0) try {
2785
+ await pctx.stampIntegrationId(summary.id, input.integrationId);
2786
+ } catch (err) {
2787
+ pctx.host.ctx.logger.warn("adoptDevice: integrationId stamp failed (device adopted)", {
2788
+ tags: {
2789
+ deviceId: summary.id,
2790
+ integrationId: input.integrationId
2791
+ },
2792
+ meta: { error: (0, _camstack_types.errMsg)(err) }
2793
+ });
2794
+ }
2795
+ return summary;
2796
+ }
2797
+ async function getCreationSchema(pctx, input) {
2798
+ const dp = await pctx.host.requireDeviceProvider(input.addonId);
2799
+ if (!await dp.supportsManualCreation({})) return null;
2800
+ return await dp.getChildCreationSchema({ type: input.type }) ?? null;
2801
+ }
2802
+ async function createDevice(pctx, input) {
2803
+ const dp = await pctx.host.requireDeviceProvider(input.addonId);
2804
+ if (!await dp.supportsManualCreation({})) throw new Error(`Addon "${input.addonId}" does not support manual device creation`);
2805
+ const summary = await dp.createDevice({
2806
+ type: input.type,
2807
+ config: input.config
2808
+ });
2809
+ if (input.integrationId !== void 0) try {
2810
+ await pctx.stampIntegrationId(summary.id, input.integrationId);
2811
+ } catch (err) {
2812
+ pctx.host.ctx.logger.warn("createDevice: integrationId stamp failed (device created)", {
2813
+ tags: {
2814
+ deviceId: summary.id,
2815
+ integrationId: input.integrationId
2816
+ },
2817
+ meta: { error: (0, _camstack_types.errMsg)(err) }
2818
+ });
2819
+ }
2820
+ return summary;
2821
+ }
2822
+ async function testCreationField(pctx, input) {
2823
+ return (await pctx.host.requireDeviceProvider(input.addonId)).testCreationField({
2824
+ type: input.type,
2825
+ key: input.key,
2826
+ value: input.value,
2827
+ ...input.formValues !== void 0 ? { formValues: input.formValues } : {}
2828
+ });
2829
+ }
2830
+ async function adoptionListCandidates(pctx, input) {
2831
+ const { addonId, ...rest } = input;
2832
+ return (await pctx.host.requireDeviceAdoptionProvider(addonId)).listCandidates(rest);
2833
+ }
2834
+ async function adoptionRefresh(pctx, input) {
2835
+ const { addonId, integrationId } = input;
2836
+ return (await pctx.host.requireDeviceAdoptionProvider(addonId)).refresh({ integrationId });
2837
+ }
2838
+ async function adoptionAdopt(pctx, input) {
2839
+ const { addonId, ...rest } = input;
2840
+ return (await pctx.host.requireDeviceAdoptionProvider(addonId)).adopt(rest);
2841
+ }
2842
+ async function adoptionRelease(pctx, input) {
2843
+ const { addonId, ...rest } = input;
2844
+ return (await pctx.host.requireDeviceAdoptionProvider(addonId)).release(rest);
2845
+ }
2846
+ async function adoptionResync(pctx, input) {
2847
+ const { camDeviceId, resetToSource } = input;
2848
+ let owningAddonId = pctx.registry?.getAddonId(camDeviceId) ?? null;
2849
+ if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(camDeviceId))?.addonId ?? null;
2850
+ if (!owningAddonId) throw new Error(`adoptionResync: device ${camDeviceId} not found`);
2851
+ let removedChildren = 0;
2852
+ if (resetToSource === true) {
2853
+ const childIds = await pctx.metaStore.directChildIds(camDeviceId);
2854
+ for (const childId of childIds) {
2855
+ await pctx.provider.remove({ deviceId: childId });
2856
+ removedChildren += 1;
2792
2857
  }
2858
+ pctx.host.ctx.logger.info("resetToSource purge before resync", { tags: {
2859
+ deviceId: camDeviceId,
2860
+ removedChildren
2861
+ } });
2793
2862
  }
2794
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2795
- if (!persisted) return null;
2796
- const { addonId: aid, stableId, meta: m } = persisted;
2797
- const persistedConfig = await pctx.settings.readDeviceStore(m.id);
2798
- const key = String(deviceId);
2799
- const metadata = (await pctx.metaStore.readMetadataMap())[key] ?? null;
2800
- const sourceInfoGetDevice = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
2801
2863
  return {
2802
- id: deviceId,
2803
- stableId,
2804
- addonId: aid,
2805
- type: m.type,
2806
- name: m.name,
2807
- location: m.location ?? null,
2808
- disabled: m.disabled ?? false,
2809
- parentDeviceId: m.parentDeviceId,
2810
- role: toDeviceRole(m.role),
2811
- online: pctx.host.resolveDeviceOnline(deviceId, true),
2812
- probed: pctx.host.resolveDeviceProbed(deviceId),
2813
- features: persistedFeatures(m.features),
2814
- isCamera: false,
2815
- config: persistedConfig ?? {},
2816
- metadata,
2817
- ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
2818
- ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
2819
- ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2820
- ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2821
- ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2822
- ...sourceInfoGetDevice !== void 0 ? { sourceInfo: sourceInfoGetDevice } : {}
2864
+ ...await (await pctx.host.requireDeviceAdoptionProvider(owningAddonId)).resync({
2865
+ camDeviceId,
2866
+ resetToSource
2867
+ }),
2868
+ removedChildren
2823
2869
  };
2824
2870
  }
2825
- async function getChildren(pctx, input) {
2826
- const { parentDeviceId } = input;
2827
- let ownerAddonId = null;
2828
- if (pctx.registry) {
2829
- if (pctx.registry.getById(parentDeviceId)) ownerAddonId = pctx.registry.getAddonId(parentDeviceId);
2871
+ async function testField(pctx, input) {
2872
+ const { deviceId } = input;
2873
+ let owningAddonId = null;
2874
+ if (pctx.registry) owningAddonId = pctx.registry.getAddonId(deviceId);
2875
+ if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(deviceId))?.addonId ?? null;
2876
+ if (!owningAddonId) throw new Error(`Device with id ${deviceId} not found`);
2877
+ const dp = await pctx.host.waitDeviceProvider(owningAddonId);
2878
+ if (!dp) return {
2879
+ status: "ok",
2880
+ labels: [],
2881
+ error: void 0
2882
+ };
2883
+ if (typeof dp.testCreationField !== "function") return {
2884
+ status: "ok",
2885
+ labels: [],
2886
+ error: void 0
2887
+ };
2888
+ return dp.testCreationField({
2889
+ type: _camstack_types.DeviceType.Camera,
2890
+ key: input.key,
2891
+ value: input.value
2892
+ });
2893
+ }
2894
+ //#endregion
2895
+ //#region src/builtins/device-manager/device-link-resolver.ts
2896
+ /** Returns true when `x` is a non-null, non-array plain object. */
2897
+ function isRecord(x) {
2898
+ return x !== null && typeof x === "object" && !Array.isArray(x);
2899
+ }
2900
+ /** Narrow Zod v4's structural `$ZodType` (returned by `.unwrap()`) back to the
2901
+ * concrete classic `z.ZodType`. Every runtime schema is a `z.ZodType`, so this
2902
+ * is a true `instanceof` guard rather than a cast. */
2903
+ function asZodType(schema) {
2904
+ return schema instanceof zod.z.ZodType ? schema : null;
2905
+ }
2906
+ /** Unwrap ZodNullable / ZodOptional / ZodDefault wrappers to reach the inner
2907
+ * type. This lets the repair logic recognise a `TankStatus.nullable()` field
2908
+ * as a ZodObject so it can fill in missing nullable keys. */
2909
+ function unwrapSchema(schema) {
2910
+ if (schema instanceof zod.z.ZodNullable || schema instanceof zod.z.ZodOptional || schema instanceof zod.z.ZodDefault) {
2911
+ const inner = asZodType(schema.unwrap());
2912
+ return inner ? unwrapSchema(inner) : schema;
2830
2913
  }
2831
- if (!ownerAddonId) {
2832
- const persisted = await pctx.metaStore.resolvePersistedById(parentDeviceId);
2833
- if (!persisted) return [];
2834
- ownerAddonId = persisted.addonId;
2914
+ return schema;
2915
+ }
2916
+ /** For a ZodObject schema, ensure every nullable key present under `value` is
2917
+ * filled a missing key whose field accepts `null` is set to `null`. Generic
2918
+ * repair for the common "set one leaf of a previously-null structured field"
2919
+ * case (e.g. TankStatus.level). Recurses into nested object fields. */
2920
+ function fillNullableDefaults(schema, value) {
2921
+ const inner = unwrapSchema(schema);
2922
+ if (!(inner instanceof zod.z.ZodObject) || !isRecord(value)) return value;
2923
+ const shape = inner.shape;
2924
+ const out = { ...value };
2925
+ for (const [key, field] of Object.entries(shape)) if (out[key] === void 0) {
2926
+ if (field.safeParse(null).success) out[key] = null;
2927
+ } else out[key] = fillNullableDefaults(field, out[key]);
2928
+ return out;
2929
+ }
2930
+ /**
2931
+ * Overlay transformed source values onto `base` by dot-path, then validate the
2932
+ * result against the target cap's `statusSchema`. On validation failure the
2933
+ * overlay is discarded and `base` is returned unchanged (a misconfigured link
2934
+ * must never corrupt a cap response). Pure — all I/O happens in the caller.
2935
+ */
2936
+ function mergeLinkedStatus(base, resolved, statusSchema) {
2937
+ let draft = base;
2938
+ let touched = false;
2939
+ for (const { link, sourceValue } of resolved) {
2940
+ if (sourceValue === void 0) continue;
2941
+ draft = (0, _camstack_types.setByPath)(draft, link.target.fieldPath, sourceValue);
2942
+ touched = true;
2835
2943
  }
2836
- const results = [];
2837
- const seen = /* @__PURE__ */ new Set();
2838
- const [index, meta, metadataMap] = await Promise.all([
2839
- pctx.metaStore.readIndex(),
2840
- pctx.metaStore.readMeta(),
2841
- pctx.metaStore.readMetadataMap()
2842
- ]);
2843
- if (pctx.registry) {
2844
- const liveChildren = pctx.registry.getChildren(parentDeviceId);
2845
- for (const device of liveChildren) {
2846
- const key = String(device.id);
2847
- const metadata = metadataMap[key] ?? null;
2848
- const metaRow = meta[key] ?? null;
2849
- results.push(toDeviceInfo(ownerAddonId, device, metadata, metaRow));
2850
- seen.add(key);
2944
+ if (!touched) return base;
2945
+ if (!statusSchema) return draft;
2946
+ const repaired = fillNullableDefaults(statusSchema, draft);
2947
+ const parsed = statusSchema.safeParse(repaired);
2948
+ if (!parsed.success) return base;
2949
+ return isRecord(parsed.data) ? parsed.data : base;
2950
+ }
2951
+ //#endregion
2952
+ //#region src/builtins/device-manager/device-state-mirror.ts
2953
+ /**
2954
+ * Hub-side runtime-state mirror for the device-manager addon.
2955
+ *
2956
+ * `DeviceStateMirror` owns the per-device cap-keyed slice mirror and the
2957
+ * debounced disk-write coalescer that previously lived directly on the addon
2958
+ * class. It mirrors every `deviceState.setCapSlice` write, emits
2959
+ * `DeviceStateChanged` (overlaid with cross-device linked values), and coalesces
2960
+ * frequent writes into one `writeDeviceRuntimeState` per debounce window. The
2961
+ * cross-device link reverse-index lives on the addon; the mirror reads it
2962
+ * through the injected `LinkOverlayHost` so the single owner of that state stays
2963
+ * the addon. Extracted verbatim — behavior unchanged.
2964
+ */
2965
+ var DeviceStateMirror = class DeviceStateMirror {
2966
+ ctx;
2967
+ linkHost;
2968
+ /**
2969
+ * Hub-side mirror of every device's cap-keyed runtime state.
2970
+ * Key: deviceId. Value: per-cap slice map. Empty by default —
2971
+ * slices show up as `setCapSlice` calls trickle in.
2972
+ */
2973
+ stateMirror = /* @__PURE__ */ new Map();
2974
+ /**
2975
+ * Per-device disk-write debouncer for runtime-state. `setCapSlice`
2976
+ * updates the in-memory mirror synchronously and emits the change
2977
+ * event immediately, but the disk write is coalesced.
2978
+ */
2979
+ runtimeStateDebounce = /* @__PURE__ */ new Map();
2980
+ static RUNTIME_STATE_DEBOUNCE_MS = 1e3;
2981
+ /** Loop/churn guard: last overlaid slice emitted per `${deviceId}:${cap}`. */
2982
+ lastEmittedOverlay = /* @__PURE__ */ new Map();
2983
+ constructor(ctx, linkHost) {
2984
+ this.ctx = ctx;
2985
+ this.linkHost = linkHost;
2986
+ }
2987
+ /**
2988
+ * Single-cap mirror update — diff against the current mirror,
2989
+ * persist the new slice in-memory, emit `DeviceStateChanged` for
2990
+ * this cap. No-op on identical writes (both same shape and same
2991
+ * values). Called by `setCapSlice` provider.
2992
+ */
2993
+ applySingleCapUpdate(deviceId, capName, slice) {
2994
+ let perCap = this.stateMirror.get(deviceId);
2995
+ if (!perCap) {
2996
+ perCap = /* @__PURE__ */ new Map();
2997
+ this.stateMirror.set(deviceId, perCap);
2851
2998
  }
2999
+ const prior = perCap.get(capName);
3000
+ if (prior && shallowEqual(prior, slice)) return;
3001
+ perCap.set(capName, { ...slice });
3002
+ this.emitOverlayed(deviceId, capName);
3003
+ const deps = this.linkHost.linkDependents.get(`${deviceId}:${capName}`);
3004
+ if (deps) for (const d of deps) this.emitOverlayed(d.targetDeviceId, d.targetCap);
2852
3005
  }
2853
- const ownerMetaByStableId = /* @__PURE__ */ new Map();
2854
- for (const m of Object.values(meta)) if (m.addonId === ownerAddonId) ownerMetaByStableId.set(m.stableId, m);
2855
- const persistedChildren = (index[ownerAddonId] ?? []).filter((sid) => ownerMetaByStableId.get(sid)?.parentDeviceId === parentDeviceId);
2856
- for (const childStableId of persistedChildren) {
2857
- const m = ownerMetaByStableId.get(childStableId);
2858
- const key = String(m.id);
2859
- if (seen.has(key)) continue;
2860
- const persistedConfig = await pctx.settings.readDeviceStore(m.id);
2861
- const metadata = metadataMap[key] ?? null;
2862
- const sourceInfoChild = buildSourceInfoFromConfig(persistedConfig ?? {}, childStableId, ownerAddonId);
2863
- results.push({
2864
- id: m.id,
2865
- stableId: childStableId,
2866
- addonId: ownerAddonId,
2867
- type: m.type,
2868
- name: m.name,
2869
- location: m.location ?? null,
2870
- disabled: m.disabled ?? false,
2871
- parentDeviceId,
2872
- role: toDeviceRole(m.role),
2873
- online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
2874
- probed: pctx.host.resolveDeviceProbed(m.id),
2875
- features: persistedFeatures(m.features),
2876
- isCamera: false,
2877
- config: persistedConfig ?? {},
2878
- metadata,
2879
- ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
2880
- ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
2881
- ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2882
- ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2883
- ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2884
- ...sourceInfoChild !== void 0 ? { sourceInfo: sourceInfoChild } : {}
2885
- });
3006
+ /**
3007
+ * Debounced disk writer. Coalesces frequent writes (motion phase
3008
+ * transitions, battery pushes) into one `writeDeviceRuntimeState`
3009
+ * per `RUNTIME_STATE_DEBOUNCE_MS` window. Reads the per-device
3010
+ * blob from the live mirror at flush time so the disk picture is
3011
+ * always the latest state — no risk of writing a stale snapshot.
3012
+ */
3013
+ scheduleRuntimeStateDiskWrite(deviceId, settings) {
3014
+ let slot = this.runtimeStateDebounce.get(deviceId);
3015
+ if (!slot) {
3016
+ slot = {
3017
+ timer: null,
3018
+ inFlight: null
3019
+ };
3020
+ this.runtimeStateDebounce.set(deviceId, slot);
3021
+ }
3022
+ if (slot.timer) return;
3023
+ slot.timer = setTimeout(() => {
3024
+ slot.timer = null;
3025
+ const blob = this.snapshotForDevice(deviceId);
3026
+ const write = (async () => {
3027
+ try {
3028
+ await settings.writeDeviceRuntimeState(deviceId, blob);
3029
+ } catch (err) {
3030
+ this.ctx.logger.warn("writeDeviceRuntimeState failed", {
3031
+ tags: { deviceId },
3032
+ meta: { error: err instanceof Error ? err.message : String(err) }
3033
+ });
3034
+ } finally {
3035
+ slot.inFlight = null;
3036
+ }
3037
+ })();
3038
+ slot.inFlight = write;
3039
+ }, DeviceStateMirror.RUNTIME_STATE_DEBOUNCE_MS);
2886
3040
  }
2887
- return results;
2888
- }
2889
- async function getStreamSources(pctx, input) {
2890
- const { deviceId } = input;
2891
- if (pctx.registry) {
2892
- const found = resolveDeviceById(pctx.registry, deviceId);
2893
- if (found) {
2894
- if (!isCameraDevice(found.device)) return [];
2895
- return (await found.device.getStreamSources()).map((s) => ({
2896
- id: s.id,
2897
- label: s.label,
2898
- protocol: s.protocol,
2899
- url: s.url,
2900
- resolution: s.resolution,
2901
- fps: s.fps,
2902
- bitrate: s.bitrate,
2903
- codec: s.codec,
2904
- profileHint: s.profileHint
2905
- }));
3041
+ /**
3042
+ * One-shot mirror seed used by `loadRuntimeState` at boot so the
3043
+ * hub knows about every persisted slice without waiting for the
3044
+ * first `setCapSlice` call. No events emitted — this is
3045
+ * initial-state population, not a transition.
3046
+ *
3047
+ * Callers that must not carry a stale per-session probe across a
3048
+ * restart pass the blob through `withResetSessionProbe` first (see
3049
+ * `loadRuntimeState`).
3050
+ */
3051
+ seedMirror(deviceId, blob) {
3052
+ let perCap = this.stateMirror.get(deviceId);
3053
+ if (!perCap) {
3054
+ perCap = /* @__PURE__ */ new Map();
3055
+ this.stateMirror.set(deviceId, perCap);
3056
+ }
3057
+ for (const [capName, raw] of Object.entries(blob)) {
3058
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
3059
+ perCap.set(capName, { ...raw });
2906
3060
  }
2907
3061
  }
2908
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
2909
- return (await pctx.requireDeviceOps(deviceId).getStreamSources({ deviceId })).map((s) => ({ ...s }));
2910
- }
2911
- async function getConfigSchema(pctx, input) {
2912
- const { deviceId } = input;
2913
- if (pctx.registry) {
2914
- const found = resolveDeviceById(pctx.registry, deviceId);
2915
- if (found) return found.device.config.entries().map((entry) => ({
2916
- key: entry.key,
2917
- value: entry.value,
2918
- ...entry.description !== void 0 ? { description: entry.description } : {}
2919
- }));
3062
+ /**
3063
+ * The hub mirror's `feature-probe.lastProbedAt` is a PER-SESSION liveness
3064
+ * signal — it means "this worker process completed a probe THIS session".
3065
+ * Persisted runtime state carries the PRE-RESTART timestamp, which is stale
3066
+ * after a hub or worker restart: the device has not re-probed yet. Seeding it
3067
+ * verbatim makes `resolveDeviceProbed` report `probed:true` during the
3068
+ * restart→reprobe window, which defeats the export carry-forward gate
3069
+ * (`resolveExportFingerprint`, gated on `device.probed`) and posts a spurious
3070
+ * partial `AddOrUpdateReport` to Alexa/HAP before the real probe lands.
3071
+ *
3072
+ * Reset `lastProbedAt` to 0 for the MIRROR seed only — `probed:false` carries
3073
+ * the last-advertised fingerprint forward until the worker republishes a
3074
+ * fresh probe (its post-probe `setCapSlice` raises `lastProbedAt` again, which
3075
+ * also fires `DeviceReady`). The worker's returned `initialRuntimeState` blob
3076
+ * is untouched, and every non-probe slice (e.g. `device-status`/online) is
3077
+ * preserved.
3078
+ */
3079
+ withResetSessionProbe(blob) {
3080
+ const probe = blob["feature-probe"];
3081
+ if (!probe || typeof probe !== "object" || Array.isArray(probe)) return blob;
3082
+ return {
3083
+ ...blob,
3084
+ "feature-probe": {
3085
+ ...probe,
3086
+ lastProbedAt: 0
3087
+ }
3088
+ };
2920
3089
  }
2921
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
2922
- return (await pctx.requireDeviceOps(deviceId).getConfigEntries({ deviceId })).map((e) => ({ ...e }));
2923
- }
2924
- async function getSettingsSchema(pctx, input) {
2925
- const { deviceId } = input;
2926
- if (pctx.registry) {
2927
- const found = resolveDeviceById(pctx.registry, deviceId);
2928
- if (found) return found.device.getSettingsUISchema();
3090
+ /**
3091
+ * Resolve a device's REAL `online` flag for the persisted/forked-worker
3092
+ * list branch. Forked workers own the live `IDevice` in their own process,
3093
+ * so the hub registry can't read `device.online` directly. The owning
3094
+ * driver instead publishes its liveness through the auto-registered
3095
+ * `device-status` runtime-state slice (`markOnline` → `setCapState`), which
3096
+ * the canonical `deviceState.setCapSlice` write entrypoint mirrors into the
3097
+ * hub-side `stateMirror`. We read that mirrored slice here so the list
3098
+ * payload reflects the device's actual reachability instead of a constant.
3099
+ *
3100
+ * Fallback (`fallbackOnline`) preserves the legacy behaviour when no slice
3101
+ * has been published yet: a persisted device with a live registry is
3102
+ * assumed online (it was successfully registered by its owning process),
3103
+ * and the null-registry "offline view" keeps reporting offline. We never
3104
+ * regress a device to offline merely because its mirror is empty.
3105
+ */
3106
+ resolveDeviceOnline(deviceId, fallbackOnline) {
3107
+ const raw = this.stateMirror.get(deviceId)?.get(_camstack_types.deviceStatusCapability.name);
3108
+ if (!raw) return fallbackOnline;
3109
+ const parsed = _camstack_types.DeviceStatusSchema.safeParse(raw);
3110
+ if (!parsed.success) return fallbackOnline;
3111
+ return parsed.data.online;
2929
3112
  }
2930
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) return null;
2931
- return await pctx.requireDeviceOps(deviceId).getSettingsSchema({ deviceId }) ?? null;
2932
- }
2933
- async function updateConfig(pctx, input) {
2934
- const { deviceId } = input;
2935
- if (pctx.registry) {
2936
- const found = resolveDeviceById(pctx.registry, deviceId);
2937
- if (found) {
2938
- await found.device.config.setAll(input.values);
2939
- return { success: true };
2940
- }
3113
+ /**
3114
+ * Derive the `probed` flag for an offline-view (forked-worker, not
3115
+ * live in the hub registry) device projection. Reads the mirrored
3116
+ * `feature-probe` slice the owning worker publishes. Mirrors the
3117
+ * `toDeviceInfo` rule: no mirrored slice → ready (`true`, no probe seen);
3118
+ * slice present → ready iff `lastProbedAt` has advanced past 0. The
3119
+ * mirror is populated when the worker's first `setCapSlice` RPC arrives
3120
+ * (BaseDevice seeds `feature-probe` `lastProbedAt:0` at construction, but
3121
+ * cross-process delivery is async); until then the no-entry path returns
3122
+ * `true` a brief transient window, same as `resolveDeviceOnline`.
3123
+ */
3124
+ resolveDeviceProbed(deviceId) {
3125
+ const raw = this.stateMirror.get(deviceId)?.get("feature-probe");
3126
+ if (!raw) return true;
3127
+ return (typeof raw.lastProbedAt === "number" ? raw.lastProbedAt : 0) > 0;
2941
3128
  }
2942
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
2943
- await pctx.requireDeviceOps(deviceId).setConfig({
2944
- deviceId,
2945
- values: input.values
2946
- });
2947
- return { success: true };
2948
- }
2949
- async function enable(pctx, input) {
2950
- await pctx.provider.setDisabled({
2951
- deviceId: input.deviceId,
2952
- disabled: false
2953
- });
2954
- return { success: true };
2955
- }
2956
- async function disable(pctx, input) {
2957
- await pctx.provider.setDisabled({
2958
- deviceId: input.deviceId,
2959
- disabled: true
2960
- });
2961
- return { success: true };
2962
- }
2963
- async function remove(pctx, input) {
2964
- const { deviceId } = input;
2965
- const removeOne = async (id) => {
2966
- if (pctx.registry) {
2967
- const live = resolveDeviceById(pctx.registry, id);
2968
- if (live) {
2969
- const deviceName = live.device.name;
2970
- await live.device.removeDevice();
2971
- pctx.registry.remove(id);
2972
- await pctx.provider.removeDevice({ deviceId: id });
2973
- pctx.host.ctx.logger.info("removed hub-local device", { tags: {
2974
- deviceId: id,
2975
- deviceName
2976
- } });
2977
- return;
2978
- }
2979
- }
2980
- const persisted = await pctx.metaStore.resolvePersistedById(id);
2981
- if (!persisted) return;
2982
- const { meta: persistedMeta } = persisted;
2983
- try {
2984
- await pctx.requireDeviceOps(id).removeDevice({ deviceId: id });
2985
- } catch (err) {
2986
- pctx.host.ctx.logger.warn("remove via device-ops failed — clearing persistence anyway", {
2987
- tags: {
2988
- deviceId: id,
2989
- deviceName: persistedMeta.name
2990
- },
2991
- meta: { error: (0, _camstack_types.errMsg)(err) }
2992
- });
2993
- }
2994
- await pctx.provider.removeDevice({ deviceId: id });
2995
- };
2996
- const removeCascade = async (id) => {
2997
- for (const childId of await pctx.metaStore.directChildIds(id)) await removeCascade(childId);
2998
- await removeOne(id);
2999
- };
3000
- await removeCascade(deviceId);
3001
- return { success: true };
3002
- }
3003
- /**
3004
- * Cascade-delete every top-level device whose `integrationId`
3005
- * matches. Enumerates a SNAPSHOT of the meta map so concurrent
3006
- * removals don't clobber each other. Only top-level parents are
3007
- * enumerated — children cascade via the per-parent `removeCascade`
3008
- * inside the delegated `remove` call. Idempotent: devices with no
3009
- * `integrationId` never match.
3010
- */
3011
- async function removeByIntegration(pctx, input) {
3012
- const { integrationId } = input;
3013
- const meta = await pctx.metaStore.readMeta();
3014
- const parentKeys = Object.keys(meta).filter((key) => {
3015
- const m = meta[key];
3016
- return m !== void 0 && m.integrationId === integrationId && m.parentDeviceId === null;
3017
- });
3018
- let removed = 0;
3019
- for (const _key of parentKeys) {
3020
- const m = meta[_key];
3021
- if (!m) continue;
3022
- await pctx.provider.remove({ deviceId: m.id });
3023
- removed++;
3129
+ snapshotForDevice(deviceId) {
3130
+ const perCap = this.stateMirror.get(deviceId);
3131
+ if (!perCap) return {};
3132
+ const out = {};
3133
+ for (const [k, v] of perCap) out[k] = { ...v };
3134
+ return out;
3024
3135
  }
3025
- return { removed };
3026
- }
3027
- async function getStreamProfileMap(pctx, input) {
3028
- if (!pctx.registry) return {};
3029
- const found = resolveDeviceById(pctx.registry, input.deviceId);
3030
- if (!found) return {};
3031
- const storedMap = found.device.config.entries().find((e) => e.key === "_profileMap")?.value;
3032
- if (storedMap !== void 0 && typeof storedMap === "object" && storedMap !== null) return storedMap;
3033
- if (!isCameraDevice(found.device)) return {};
3034
- const sources = await found.device.getStreamSources();
3035
- const profileMap = {};
3036
- for (const s of sources) if (s.profileHint && s.id) profileMap[s.profileHint] = s.id;
3037
- return profileMap;
3038
- }
3039
- async function setStreamProfileMap(pctx, input) {
3040
- const { deviceId } = input;
3041
- if (pctx.registry) {
3042
- const found = resolveDeviceById(pctx.registry, deviceId);
3043
- if (found) {
3044
- await found.device.config.setAll({ _profileMap: input.profileMap });
3045
- return { success: true };
3136
+ /**
3137
+ * Read-time overlay of a cap slice with its cross-device linked values.
3138
+ * Returns a cloned raw mirror slice when the (device, cap) pair has no
3139
+ * links. Sources are read from the same in-hub stateMirror — sync, no
3140
+ * cross-process call. The disk writer must NOT use this method; it must
3141
+ * persist raw provider truth via snapshotForDevice.
3142
+ */
3143
+ overlayedSlice(deviceId, cap) {
3144
+ const raw = this.stateMirror.get(deviceId)?.get(cap) ?? null;
3145
+ const links = this.linkHost.linkTargets.get(`${deviceId}:${cap}`);
3146
+ if (!links || links.length === 0) return raw ? { ...raw } : null;
3147
+ const resolved = links.map((rl) => ({
3148
+ link: rl.link,
3149
+ 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)
3150
+ }));
3151
+ const schema = this.linkHost.capabilityRegistry?.getDefinition(cap)?.status?.schema;
3152
+ return mergeLinkedStatus(raw ? { ...raw } : {}, resolved, schema);
3153
+ }
3154
+ /**
3155
+ * Like snapshotForDevice but applies the device-link overlay per cap.
3156
+ * Used exclusively by the device-state READ methods (getSnapshot,
3157
+ * getAllSnapshots) so callers see overlayed values. The debounced disk
3158
+ * writer must continue to call snapshotForDevice (raw truth).
3159
+ */
3160
+ snapshotForDeviceOverlayed(deviceId) {
3161
+ const perCap = this.stateMirror.get(deviceId);
3162
+ if (!perCap) return {};
3163
+ const out = {};
3164
+ for (const capName of perCap.keys()) {
3165
+ const s = this.overlayedSlice(deviceId, capName);
3166
+ if (s) out[capName] = s;
3046
3167
  }
3168
+ return out;
3047
3169
  }
3048
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
3049
- await pctx.requireDeviceOps(deviceId).setConfig({
3050
- deviceId,
3051
- values: { _profileMap: input.profileMap }
3052
- });
3053
- return { success: true };
3054
- }
3055
- async function probeStreams(pctx, input) {
3056
- const streamProbe = pctx.host.ctx.kernel.streamProbe;
3057
- if (!streamProbe) return [];
3058
- const sources = await pctx.provider.getStreamSources({ deviceId: input.deviceId });
3059
- const results = [];
3060
- for (const s of sources) {
3061
- if (!s.url) continue;
3062
- try {
3063
- const metadata = await streamProbe.probe(s.url, { force: true });
3064
- results.push({
3065
- streamId: s.id,
3066
- width: metadata.width,
3067
- height: metadata.height,
3068
- codec: metadata.codec,
3069
- fps: metadata.fps,
3070
- bitrateKbps: metadata.bitrateKbps
3071
- });
3072
- } catch (err) {
3073
- pctx.host.ctx.logger.debug("streamProbe.probe failed — returning placeholder", { meta: {
3074
- deviceId: input.deviceId,
3075
- streamId: s.id,
3076
- error: err instanceof Error ? err.message : String(err)
3077
- } });
3078
- results.push({ streamId: s.id });
3170
+ /** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`. */
3171
+ allSnapshotsOverlayed() {
3172
+ const out = {};
3173
+ for (const [deviceId, perCap] of this.stateMirror) {
3174
+ const dev = {};
3175
+ for (const [capName, slice] of perCap) dev[capName] = this.overlayedSlice(deviceId, capName) ?? { ...slice };
3176
+ out[String(deviceId)] = dev;
3079
3177
  }
3178
+ return out;
3080
3179
  }
3081
- return results;
3082
- }
3083
- async function discoverDevices(pctx, input) {
3084
- const dp = await pctx.host.requireDeviceProvider(input.addonId);
3085
- if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device discovery`);
3086
- return (await dp.discoverDevices({})).map((d) => ({
3087
- stableId: d.stableId,
3088
- type: d.type,
3089
- suggestedName: d.suggestedName,
3090
- prefilledConfig: d.prefilledConfig
3091
- }));
3092
- }
3093
- async function adoptDevice(pctx, input) {
3094
- const dp = await pctx.host.requireDeviceProvider(input.addonId);
3095
- if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device adoption`);
3096
- const summary = await dp.adoptDiscoveredDevice({ candidate: input.candidate });
3097
- if (input.integrationId !== void 0) try {
3098
- await pctx.stampIntegrationId(summary.id, input.integrationId);
3099
- } catch (err) {
3100
- pctx.host.ctx.logger.warn("adoptDevice: integrationId stamp failed (device adopted)", {
3101
- tags: {
3102
- deviceId: summary.id,
3103
- integrationId: input.integrationId
3180
+ emitStateChanged(deviceId, capName, slice) {
3181
+ this.ctx.eventBus.emit({
3182
+ id: (0, node_crypto.randomUUID)(),
3183
+ timestamp: /* @__PURE__ */ new Date(),
3184
+ source: {
3185
+ type: "device",
3186
+ id: deviceId
3104
3187
  },
3105
- meta: { error: (0, _camstack_types.errMsg)(err) }
3188
+ category: _camstack_types.EventCategory.DeviceStateChanged,
3189
+ data: {
3190
+ deviceId,
3191
+ capName,
3192
+ slice
3193
+ }
3106
3194
  });
3107
3195
  }
3108
- return summary;
3109
- }
3110
- async function getCreationSchema(pctx, input) {
3111
- const dp = await pctx.host.requireDeviceProvider(input.addonId);
3112
- if (!await dp.supportsManualCreation({})) return null;
3113
- return await dp.getChildCreationSchema({ type: input.type }) ?? null;
3114
- }
3115
- async function createDevice(pctx, input) {
3116
- const dp = await pctx.host.requireDeviceProvider(input.addonId);
3117
- if (!await dp.supportsManualCreation({})) throw new Error(`Addon "${input.addonId}" does not support manual device creation`);
3118
- const summary = await dp.createDevice({
3119
- type: input.type,
3120
- config: input.config
3121
- });
3122
- if (input.integrationId !== void 0) try {
3123
- await pctx.stampIntegrationId(summary.id, input.integrationId);
3124
- } catch (err) {
3125
- pctx.host.ctx.logger.warn("createDevice: integrationId stamp failed (device created)", {
3126
- tags: {
3127
- deviceId: summary.id,
3128
- integrationId: input.integrationId
3129
- },
3130
- meta: { error: (0, _camstack_types.errMsg)(err) }
3131
- });
3196
+ /** Emit DeviceStateChanged for (deviceId, cap) using the OVERLAID slice,
3197
+ * skipping when the overlay is unchanged since the last emit (loop/churn
3198
+ * guard). Used for the written pair AND its dependent targets. */
3199
+ emitOverlayed(deviceId, cap) {
3200
+ const slice = this.overlayedSlice(deviceId, cap);
3201
+ if (!slice) return;
3202
+ const key = `${deviceId}:${cap}`;
3203
+ const prev = this.lastEmittedOverlay.get(key);
3204
+ if (prev && shallowEqual(prev, slice)) return;
3205
+ this.lastEmittedOverlay.set(key, slice);
3206
+ this.emitStateChanged(deviceId, cap, slice);
3132
3207
  }
3133
- return summary;
3134
- }
3135
- async function testCreationField(pctx, input) {
3136
- return (await pctx.host.requireDeviceProvider(input.addonId)).testCreationField({
3137
- type: input.type,
3138
- key: input.key,
3139
- value: input.value,
3140
- ...input.formValues !== void 0 ? { formValues: input.formValues } : {}
3141
- });
3142
- }
3143
- async function adoptionListCandidates(pctx, input) {
3144
- const { addonId, ...rest } = input;
3145
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).listCandidates(rest);
3146
- }
3147
- async function adoptionRefresh(pctx, input) {
3148
- const { addonId, integrationId } = input;
3149
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).refresh({ integrationId });
3150
- }
3151
- async function adoptionAdopt(pctx, input) {
3152
- const { addonId, ...rest } = input;
3153
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).adopt(rest);
3154
- }
3155
- async function adoptionRelease(pctx, input) {
3156
- const { addonId, ...rest } = input;
3157
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).release(rest);
3158
- }
3159
- async function adoptionResync(pctx, input) {
3160
- const { camDeviceId, resetToSource } = input;
3161
- let owningAddonId = pctx.registry?.getAddonId(camDeviceId) ?? null;
3162
- if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(camDeviceId))?.addonId ?? null;
3163
- if (!owningAddonId) throw new Error(`adoptionResync: device ${camDeviceId} not found`);
3164
- let removedChildren = 0;
3165
- if (resetToSource === true) {
3166
- const childIds = await pctx.metaStore.directChildIds(camDeviceId);
3167
- for (const childId of childIds) {
3168
- await pctx.provider.remove({ deviceId: childId });
3169
- removedChildren += 1;
3208
+ /** Drop a removed device's overlay-emit guard entries (keyed
3209
+ * `${deviceId}:${cap}`) so the map doesn't retain rows for a removed device.
3210
+ * Called from `removeDevice`. */
3211
+ dropDeviceOverlays(deviceId) {
3212
+ for (const overlayKey of this.lastEmittedOverlay.keys()) if (overlayKey.startsWith(`${deviceId}:`)) this.lastEmittedOverlay.delete(overlayKey);
3213
+ }
3214
+ /** Flush every pending debounced disk write (graceful shutdown). Clears the
3215
+ * debounce slots after awaiting in-flight + scheduled writes so shutdown is
3216
+ * lossless. */
3217
+ async flushPendingWrites(settings) {
3218
+ const pending = [];
3219
+ for (const [deviceId, slot] of this.runtimeStateDebounce) {
3220
+ if (slot.timer) {
3221
+ clearTimeout(slot.timer);
3222
+ slot.timer = null;
3223
+ if (settings) {
3224
+ const blob = this.snapshotForDevice(deviceId);
3225
+ pending.push(settings.writeDeviceRuntimeState(deviceId, blob).catch((err) => {
3226
+ this.ctx.logger.warn("shutdown writeDeviceRuntimeState failed", {
3227
+ tags: { deviceId },
3228
+ meta: { error: err instanceof Error ? err.message : String(err) }
3229
+ });
3230
+ }));
3231
+ }
3232
+ }
3233
+ if (slot.inFlight) pending.push(slot.inFlight);
3170
3234
  }
3171
- pctx.host.ctx.logger.info("resetToSource purge before resync", { tags: {
3172
- deviceId: camDeviceId,
3173
- removedChildren
3174
- } });
3235
+ await Promise.all(pending);
3236
+ this.runtimeStateDebounce.clear();
3175
3237
  }
3176
- return {
3177
- ...await (await pctx.host.requireDeviceAdoptionProvider(owningAddonId)).resync({
3178
- camDeviceId,
3179
- resetToSource
3180
- }),
3181
- removedChildren
3182
- };
3183
- }
3184
- async function testField(pctx, input) {
3185
- const { deviceId } = input;
3186
- let owningAddonId = null;
3187
- if (pctx.registry) owningAddonId = pctx.registry.getAddonId(deviceId);
3188
- if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(deviceId))?.addonId ?? null;
3189
- if (!owningAddonId) throw new Error(`Device with id ${deviceId} not found`);
3190
- const dp = await pctx.host.waitDeviceProvider(owningAddonId);
3191
- if (!dp) return {
3192
- status: "ok",
3193
- labels: [],
3194
- error: void 0
3195
- };
3196
- if (typeof dp.testCreationField !== "function") return {
3197
- status: "ok",
3198
- labels: [],
3199
- error: void 0
3200
- };
3201
- return dp.testCreationField({
3202
- type: _camstack_types.DeviceType.Camera,
3203
- key: input.key,
3204
- value: input.value
3205
- });
3206
- }
3238
+ };
3207
3239
  //#endregion
3208
3240
  //#region src/builtins/device-manager/device-sync-resolvers.ts
3209
3241
  /**
@@ -3305,31 +3337,6 @@ async function resolveLinkedStatus(pctx, input) {
3305
3337
  }
3306
3338
  //#endregion
3307
3339
  //#region src/builtins/device-manager/device-manager.addon.ts
3308
- /**
3309
- * Device Manager addon — hub-side singleton that unifies device persistence,
3310
- * live registry queries, and all management operations into a single
3311
- * tRPC-routable capability.
3312
- *
3313
- * Persistence strategy: all device data is stored via `ctx.settings`, the same
3314
- * settings API every other addon uses. No raw SQLite access.
3315
- *
3316
- * Addon store layout:
3317
- * deviceIndex → Record<addonId, stableId[]> (which devices exist per addon)
3318
- * deviceMeta → Record<numericId, DeviceMeta> (keyed by numeric device id;
3319
- * addonId + stableId are FIELDS on the record)
3320
- *
3321
- * Device store (per-device config):
3322
- * readDeviceStore(numericDeviceId) → config blob
3323
- * writeDeviceStore(numericDeviceId, patch)
3324
- *
3325
- * Live registry: resolved from the kernel capability registry after Phase 2.
3326
- * This gives direct access to in-memory IDevice instances registered by provider addons.
3327
- * The DeviceManagerAddon is the single owner of the live device operations API.
3328
- *
3329
- * Replaces:
3330
- * - `device-persistence` capability (absorbed here)
3331
- * - live operations previously served by `device-management.router.ts`
3332
- */
3333
3340
  var DeviceManagerAddon = class extends _camstack_types.BaseAddon {
3334
3341
  constructor() {
3335
3342
  super({});
@@ -3495,6 +3502,26 @@ var DeviceManagerAddon = class extends _camstack_types.BaseAddon {
3495
3502
  if (!provider) throw new Error(`Device-adoption provider "${addonId}" not found or not registered`);
3496
3503
  return provider;
3497
3504
  }
3505
+ /** Addon ids that currently register a `device-provider`. */
3506
+ listDeviceProviderIds() {
3507
+ return (this.capabilityRegistry?.listCapabilities().find((c) => c.name === "device-provider"))?.providers ?? [];
3508
+ }
3509
+ /** "provider-gree" → "Gree" — a human label for the discovery modal. */
3510
+ discoveryProviderLabel(addonId) {
3511
+ const base = addonId.replace(/^addon-(provider-)?/, "").replace(/^provider-/, "");
3512
+ return base.length > 0 ? base.charAt(0).toUpperCase() + base.slice(1) : addonId;
3513
+ }
3514
+ /** Provider ids that report `supportsDiscovery() === true` (probed in parallel). */
3515
+ async listDiscoveryCapableProviderIds() {
3516
+ const ids = this.listDeviceProviderIds();
3517
+ return (await Promise.all(ids.map(async (addonId) => {
3518
+ try {
3519
+ return await (await this.requireDeviceProvider(addonId)).supportsDiscovery({}) ? addonId : null;
3520
+ } catch {
3521
+ return null;
3522
+ }
3523
+ }))).filter((id) => id !== null);
3524
+ }
3498
3525
  /** Build the dependency context the extracted binding resolvers consume. */
3499
3526
  get bindingsDeps() {
3500
3527
  return {
@@ -3630,11 +3657,54 @@ var DeviceManagerAddon = class extends _camstack_types.BaseAddon {
3630
3657
  getCreationSchema: (input) => getCreationSchema(pctx, input),
3631
3658
  createDevice: (input) => createDevice(pctx, input),
3632
3659
  testCreationField: (input) => testCreationField(pctx, input),
3660
+ adoptionListCandidateFilters: async (input) => {
3661
+ return (await this.requireDeviceAdoptionProvider(input.addonId)).listCandidateFilters({ integrationId: input.integrationId });
3662
+ },
3633
3663
  adoptionListCandidates: (input) => adoptionListCandidates(pctx, input),
3634
3664
  adoptionRefresh: (input) => adoptionRefresh(pctx, input),
3635
3665
  adoptionAdopt: (input) => adoptionAdopt(pctx, input),
3636
3666
  adoptionRelease: (input) => adoptionRelease(pctx, input),
3637
3667
  adoptionResync: (input) => adoptionResync(pctx, input),
3668
+ discoveryProviders: async () => {
3669
+ return { providers: (await this.listDiscoveryCapableProviderIds()).map((addonId) => ({
3670
+ addonId,
3671
+ label: this.discoveryProviderLabel(addonId)
3672
+ })) };
3673
+ },
3674
+ discoverAllProviders: async () => {
3675
+ const ids = await this.listDiscoveryCapableProviderIds();
3676
+ return { groups: await Promise.all(ids.map(async (addonId) => {
3677
+ const label = this.discoveryProviderLabel(addonId);
3678
+ try {
3679
+ return {
3680
+ addonId,
3681
+ label,
3682
+ candidates: await (await this.requireDeviceProvider(addonId)).discoverDevices({}),
3683
+ error: null
3684
+ };
3685
+ } catch (err) {
3686
+ this.ctx.logger.warn("discovery provider scan failed", { meta: {
3687
+ addonId,
3688
+ error: err instanceof Error ? err.message : String(err)
3689
+ } });
3690
+ return {
3691
+ addonId,
3692
+ label,
3693
+ candidates: [],
3694
+ error: err instanceof Error ? err.message : String(err)
3695
+ };
3696
+ }
3697
+ })) };
3698
+ },
3699
+ discoverProvider: async (input) => {
3700
+ return { candidates: await (await this.requireDeviceProvider(input.addonId)).discoverDevices({ params: input.params }) };
3701
+ },
3702
+ providerCreationType: async (input) => {
3703
+ return (await this.requireDeviceProvider(input.addonId)).getManualCreationType({});
3704
+ },
3705
+ providerDiscoveryParamsSchema: async (input) => {
3706
+ return (await this.requireDeviceProvider(input.addonId)).getDiscoveryParamsSchema({});
3707
+ },
3638
3708
  testField: (input) => testField(pctx, input),
3639
3709
  getBindings: async (input) => {
3640
3710
  const result = await this.getBindings({ deviceId: input.deviceId });