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