@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.
- package/dist/builtins/device-manager/device-manager.addon.d.ts +9 -3
- package/dist/builtins/device-manager/device-manager.addon.js +1766 -1696
- package/dist/builtins/device-manager/device-manager.addon.mjs +1766 -1696
- package/dist/builtins/device-manager/device-meta-actions.d.ts +1 -1
- package/dist/index.js +56 -1
- package/dist/index.mjs +56 -1
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (
|
|
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-
|
|
173
|
+
//#region src/builtins/device-manager/device-projection.ts
|
|
254
174
|
/**
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
* functions
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
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
|
-
*
|
|
274
|
-
* Moleculer transport
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
|
323
|
-
return "
|
|
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
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
* `
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
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-
|
|
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
|
-
*
|
|
520
|
-
*
|
|
521
|
-
* (
|
|
522
|
-
*
|
|
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
|
-
*
|
|
525
|
-
*
|
|
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
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1345
|
-
*
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1349
|
-
*
|
|
1350
|
-
*
|
|
1351
|
-
*
|
|
1352
|
-
*
|
|
1353
|
-
*
|
|
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
|
-
*
|
|
1356
|
-
* `
|
|
1357
|
-
*
|
|
1358
|
-
*
|
|
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
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
const
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
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
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
if (
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
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
|
-
|
|
1488
|
-
|
|
1489
|
-
};
|
|
1310
|
+
return chain;
|
|
1311
|
+
}
|
|
1490
1312
|
};
|
|
1491
1313
|
//#endregion
|
|
1492
|
-
//#region src/builtins/device-manager/device-
|
|
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
|
-
*
|
|
1529
|
-
*
|
|
1530
|
-
*
|
|
1531
|
-
*
|
|
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
|
-
*
|
|
1552
|
-
*
|
|
1553
|
-
* `
|
|
1554
|
-
*
|
|
1555
|
-
*
|
|
1556
|
-
* `
|
|
1557
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
2401
|
-
*
|
|
2402
|
-
*
|
|
2403
|
-
*
|
|
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
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
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
|
-
*
|
|
2438
|
-
* device
|
|
2439
|
-
*
|
|
2440
|
-
*
|
|
2441
|
-
*
|
|
2442
|
-
*
|
|
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
|
-
*
|
|
2448
|
-
*
|
|
2449
|
-
*
|
|
2450
|
-
*
|
|
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
|
|
2453
|
-
const {
|
|
2454
|
-
await pctx.metaStore.
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
const
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
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
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
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
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
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
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
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
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
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
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
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
|
|
2645
|
+
async function remove(pctx, input) {
|
|
2587
2646
|
const { deviceId } = input;
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
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
|
-
*
|
|
2679
|
-
*
|
|
2680
|
-
*
|
|
2681
|
-
*
|
|
2682
|
-
* the
|
|
2683
|
-
* `
|
|
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
|
|
2700
|
-
const {
|
|
2701
|
-
const
|
|
2702
|
-
const
|
|
2703
|
-
|
|
2704
|
-
|
|
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
|
|
2719
|
-
|
|
2720
|
-
const
|
|
2721
|
-
|
|
2722
|
-
const
|
|
2723
|
-
|
|
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
|
|
2726
|
-
|
|
2727
|
-
device
|
|
2728
|
-
|
|
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
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
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
|
|
2778
|
-
const
|
|
2779
|
-
if (
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
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
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
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
|
|
2821
|
-
const {
|
|
2822
|
-
let
|
|
2823
|
-
if (pctx.registry)
|
|
2824
|
-
|
|
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
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
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
|
-
|
|
2832
|
-
|
|
2833
|
-
const
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
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
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
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
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
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
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
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
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
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
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
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
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
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
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
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
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
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
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
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
|
-
|
|
3183
|
+
category: EventCategory.DeviceStateChanged,
|
|
3184
|
+
data: {
|
|
3185
|
+
deviceId,
|
|
3186
|
+
capName,
|
|
3187
|
+
slice
|
|
3188
|
+
}
|
|
3101
3189
|
});
|
|
3102
3190
|
}
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
}
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
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
|
-
|
|
3129
|
-
}
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
}
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
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
|
-
|
|
3167
|
-
|
|
3168
|
-
removedChildren
|
|
3169
|
-
} });
|
|
3230
|
+
await Promise.all(pending);
|
|
3231
|
+
this.runtimeStateDebounce.clear();
|
|
3170
3232
|
}
|
|
3171
|
-
|
|
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 });
|