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