@camstack/system 1.1.22 → 1.1.24
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/addon-runner.js +1 -1
- package/dist/addon-runner.mjs +1 -1
- package/dist/builtins/device-manager/device-aggregation.d.ts +8 -1
- package/dist/builtins/device-manager/device-bindings-store.d.ts +3 -0
- package/dist/builtins/device-manager/device-link-cycle.d.ts +18 -0
- package/dist/builtins/device-manager/device-link-overlay.d.ts +15 -5
- package/dist/builtins/device-manager/device-link-resolver.d.ts +31 -2
- package/dist/builtins/device-manager/device-manager.addon.d.ts +9 -2
- package/dist/builtins/device-manager/device-manager.addon.js +585 -62
- package/dist/builtins/device-manager/device-manager.addon.mjs +586 -63
- package/dist/builtins/device-manager/device-meta-actions.d.ts +21 -0
- package/dist/builtins/device-manager/device-meta-store.d.ts +6 -0
- package/dist/builtins/device-manager/device-meta-types.d.ts +12 -1
- package/dist/builtins/device-manager/device-projection.d.ts +1 -0
- package/dist/builtins/device-manager/device-provider-context.d.ts +4 -0
- package/dist/builtins/device-manager/device-state-mirror.d.ts +13 -5
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{manifest-python-deps-hDWMDe_b.mjs → manifest-python-deps-CPJXzrZt.mjs} +12 -0
- package/dist/{manifest-python-deps-D7iR07uA.js → manifest-python-deps-XWJwKYDx.js} +12 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { canonicalDeviceFingerprint } from "@camstack/types/node";
|
|
2
|
-
import { BaseAddon, CAP_NAMES_WITH_STATUS, DeviceFeature, DeviceRole, DeviceStatusSchema, DeviceType, EventCategory, STREAM_PROFILE_META, WELL_KNOWN_TAB_MAP, applyTransform, buildStreamParamsConfigSchema, deviceManagerCapability, deviceStateCapability, deviceStatusCapability, enumerateSchemaFields, errMsg, getByPath, isDeviceConfigCap, parseStreamParamsFormPatch, setByPath, sleep } from "@camstack/types";
|
|
2
|
+
import { ALL_CAPABILITY_DEFINITIONS, BaseAddon, CAP_NAMES_WITH_STATUS, DeviceFeature, DeviceRole, DeviceStatusSchema, DeviceType, EventCategory, STREAM_PROFILE_META, WELL_KNOWN_TAB_MAP, applyTransform, buildStreamParamsConfigSchema, deviceManagerCapability, deviceStateCapability, deviceStatusCapability, enumerateItemArrayFields, enumerateSchemaFields, errMsg, evaluateLinkExpression, getByPath, isDeviceConfigCap, normalizeUnit, parseStreamParamsFormPatch, setByPath, sleep, toExpressionValue, validateExpressionSource } from "@camstack/types";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
/**
|
|
@@ -295,7 +295,8 @@ function toDeviceInfo(addonId, device, metadata = null, metaRow = null) {
|
|
|
295
295
|
...metaRow?.linkDeviceId !== void 0 ? { linkDeviceId: metaRow.linkDeviceId } : {},
|
|
296
296
|
...metaRow?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: metaRow.primaryChildEntityId } : {},
|
|
297
297
|
...metaRow?.childLayout !== void 0 ? { childLayout: metaRow.childLayout } : {},
|
|
298
|
-
...metaRow?.deviceLinks !== void 0 ? { deviceLinks: metaRow.deviceLinks } : {}
|
|
298
|
+
...metaRow?.deviceLinks !== void 0 ? { deviceLinks: metaRow.deviceLinks } : {},
|
|
299
|
+
...metaRow?.display !== void 0 ? { display: metaRow.display } : {}
|
|
299
300
|
};
|
|
300
301
|
}
|
|
301
302
|
function resolveDeviceById(registry, deviceId) {
|
|
@@ -504,6 +505,21 @@ async function getBindings(deps, input) {
|
|
|
504
505
|
});
|
|
505
506
|
seenCaps.add(entry.capName);
|
|
506
507
|
}
|
|
508
|
+
if (deps.devicesWithLinks?.has(input.deviceId)) {
|
|
509
|
+
const row = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(input.deviceId)];
|
|
510
|
+
for (const link of row?.deviceLinks ?? []) {
|
|
511
|
+
const capName = link.target.cap;
|
|
512
|
+
if (seenCaps.has(capName)) continue;
|
|
513
|
+
entries.push({
|
|
514
|
+
capName,
|
|
515
|
+
kind: "linked",
|
|
516
|
+
providerAddonId: deps.ctx.id,
|
|
517
|
+
providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
|
|
518
|
+
nativeAddonId: ""
|
|
519
|
+
});
|
|
520
|
+
seenCaps.add(capName);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
507
523
|
return {
|
|
508
524
|
deviceId: input.deviceId,
|
|
509
525
|
entries
|
|
@@ -1166,11 +1182,47 @@ async function setWrapperActive(deps, input) {
|
|
|
1166
1182
|
});
|
|
1167
1183
|
}
|
|
1168
1184
|
/**
|
|
1185
|
+
* Build the wireable-cap entry for one capability definition: the status
|
|
1186
|
+
* schema's scalar leaf fields plus — for an item-array cap
|
|
1187
|
+
* (`status.itemArray`) — the per-item fields tagged `item: true` (a link
|
|
1188
|
+
* targeting one must carry a `target.itemKey`) and the cap-level `itemArray`
|
|
1189
|
+
* descriptor so the UI can address items. Returns null when the cap exposes
|
|
1190
|
+
* nothing wireable (no status schema / no leaf fields).
|
|
1191
|
+
*/
|
|
1192
|
+
function wireableEntryForDef(capName, def) {
|
|
1193
|
+
const status = def.status;
|
|
1194
|
+
const schema = status?.schema;
|
|
1195
|
+
if (!schema) return null;
|
|
1196
|
+
const itemArray = status.itemArray;
|
|
1197
|
+
const fields = [...enumerateSchemaFields(schema), ...itemArray ? enumerateItemArrayFields(itemArray) : []].map((f) => ({
|
|
1198
|
+
path: f.path,
|
|
1199
|
+
kind: f.kind,
|
|
1200
|
+
...f.enumValues !== void 0 ? { enumValues: [...f.enumValues] } : {},
|
|
1201
|
+
...f.item === true ? { item: true } : {}
|
|
1202
|
+
}));
|
|
1203
|
+
if (fields.length === 0) return null;
|
|
1204
|
+
return {
|
|
1205
|
+
cap: capName,
|
|
1206
|
+
fields,
|
|
1207
|
+
...itemArray ? { itemArray: {
|
|
1208
|
+
path: itemArray.path,
|
|
1209
|
+
keyField: itemArray.keyField
|
|
1210
|
+
} } : {}
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1169
1214
|
* Per-device wireable-field catalog — the domain status fields a device's
|
|
1170
1215
|
* bound caps expose, for the operator's cross-device wiring UI. Binding-driven:
|
|
1171
1216
|
* walks `getBindings(deviceId)`, skips wrapper caps (their status schemas are
|
|
1172
1217
|
* internal book-keeping, not wireable domain data), and enumerates each cap's
|
|
1173
|
-
* status schema leaf fields
|
|
1218
|
+
* status schema leaf fields (per-item fields included for item-array caps).
|
|
1219
|
+
*
|
|
1220
|
+
* `includeSynthesizable: true` (TARGET pickers only) additionally unions in
|
|
1221
|
+
* UNBOUND device-scoped caps that declare `status.empty` (the synthesize-target
|
|
1222
|
+
* marker) and whose `deviceTypes` admit this device's persisted type — so the
|
|
1223
|
+
* FIRST link to a synthesize-only cap (consumables on an HA vacuum) can be
|
|
1224
|
+
* authored before any binding exists. Default (absent/false) behavior is
|
|
1225
|
+
* byte-identical to the binding-driven catalog.
|
|
1174
1226
|
*/
|
|
1175
1227
|
async function getWireableFields(deps, input) {
|
|
1176
1228
|
const { deviceId } = input;
|
|
@@ -1184,20 +1236,20 @@ async function getWireableFields(deps, input) {
|
|
|
1184
1236
|
seen.add(entry.capName);
|
|
1185
1237
|
const def = reg.getDefinition(entry.capName);
|
|
1186
1238
|
if (!def || def.kind === "wrapper") continue;
|
|
1187
|
-
const
|
|
1188
|
-
if (
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
}
|
|
1239
|
+
const wireable = wireableEntryForDef(entry.capName, def);
|
|
1240
|
+
if (wireable) caps.push(wireable);
|
|
1241
|
+
}
|
|
1242
|
+
if (input.includeSynthesizable === true) {
|
|
1243
|
+
const deviceType = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.type;
|
|
1244
|
+
if (deviceType !== void 0) for (const def of ALL_CAPABILITY_DEFINITIONS) {
|
|
1245
|
+
if (seen.has(def.name)) continue;
|
|
1246
|
+
if (def.scope !== "device" || def.kind === "wrapper") continue;
|
|
1247
|
+
if (def.status?.empty === void 0) continue;
|
|
1248
|
+
if (def.deviceTypes && !def.deviceTypes.some((t) => t === deviceType)) continue;
|
|
1249
|
+
seen.add(def.name);
|
|
1250
|
+
const wireable = wireableEntryForDef(def.name, def);
|
|
1251
|
+
if (wireable) caps.push(wireable);
|
|
1252
|
+
}
|
|
1201
1253
|
}
|
|
1202
1254
|
return { caps };
|
|
1203
1255
|
}
|
|
@@ -1311,6 +1363,120 @@ var DeviceEventPropagator = class {
|
|
|
1311
1363
|
}
|
|
1312
1364
|
};
|
|
1313
1365
|
//#endregion
|
|
1366
|
+
//#region src/builtins/device-manager/device-link-cycle.ts
|
|
1367
|
+
function nodeKey(deviceId, cap) {
|
|
1368
|
+
return `${deviceId}:${cap}`;
|
|
1369
|
+
}
|
|
1370
|
+
/** The target's container stableId — its parent's (falling back to its own for
|
|
1371
|
+
* a top-level target). Sibling FIELD sources resolve relative to this, the same
|
|
1372
|
+
* rule `rebuildLinkDependents` / `resolveLinkedStatus` apply. */
|
|
1373
|
+
function containerStableId(targetRow, rowById) {
|
|
1374
|
+
return targetRow.parentDeviceId !== null ? rowById.get(targetRow.parentDeviceId)?.stableId ?? targetRow.stableId : targetRow.stableId;
|
|
1375
|
+
}
|
|
1376
|
+
/** Resolve ONE field/global/literal binding to its `(deviceId, cap)` node, or
|
|
1377
|
+
* null when it is a literal or resolves to no known device. */
|
|
1378
|
+
function resolveBindingNode(binding, targetRow, rowById, idByStableId) {
|
|
1379
|
+
if (binding.kind === "literal") return null;
|
|
1380
|
+
const wantedStableId = binding.kind === "global" ? binding.sourceStableId : `${containerStableId(targetRow, rowById)}-${binding.sourceKey}`;
|
|
1381
|
+
const srcId = idByStableId.get(wantedStableId);
|
|
1382
|
+
if (srcId === void 0) return null;
|
|
1383
|
+
return nodeKey(srcId, binding.cap);
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Resolve a link source to the set of `(deviceId, cap)` nodes it depends on.
|
|
1387
|
+
* A literal source depends on nothing; a field/global source depends on exactly
|
|
1388
|
+
* one node; an EXPRESSION source depends on one node per resolvable non-literal
|
|
1389
|
+
* binding. Unresolvable (dangling) bindings contribute no edge — they cannot
|
|
1390
|
+
* loop while dangling; a cycle that only becomes resolvable later is bounded by
|
|
1391
|
+
* the resolve-time re-entrancy guard.
|
|
1392
|
+
*/
|
|
1393
|
+
function resolveSourceNodes(link, targetRow, rowById, idByStableId) {
|
|
1394
|
+
const src = link.source;
|
|
1395
|
+
if (src.kind === "expression") {
|
|
1396
|
+
const nodes = [];
|
|
1397
|
+
for (const binding of Object.values(src.bindings)) {
|
|
1398
|
+
const node = resolveBindingNode(binding, targetRow, rowById, idByStableId);
|
|
1399
|
+
if (node !== null) nodes.push(node);
|
|
1400
|
+
}
|
|
1401
|
+
return nodes;
|
|
1402
|
+
}
|
|
1403
|
+
const node = resolveBindingNode(src, targetRow, rowById, idByStableId);
|
|
1404
|
+
return node !== null ? [node] : [];
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Detect whether replacing `editedDeviceId`'s links with `editedLinks` closes
|
|
1408
|
+
* a dependency cycle. Returns the cycle as an ordered list of node keys
|
|
1409
|
+
* (first node repeated at the end) or null when the set is safe.
|
|
1410
|
+
*/
|
|
1411
|
+
function findDeviceLinkCycle(allMeta, editedDeviceId, editedLinks) {
|
|
1412
|
+
const rows = Object.values(allMeta);
|
|
1413
|
+
const rowById = /* @__PURE__ */ new Map();
|
|
1414
|
+
const idByStableId = /* @__PURE__ */ new Map();
|
|
1415
|
+
for (const r of rows) {
|
|
1416
|
+
rowById.set(r.id, r);
|
|
1417
|
+
if (!idByStableId.has(r.stableId)) idByStableId.set(r.stableId, r.id);
|
|
1418
|
+
}
|
|
1419
|
+
const dependsOn = /* @__PURE__ */ new Map();
|
|
1420
|
+
const addEdges = (row, links) => {
|
|
1421
|
+
for (const link of links) {
|
|
1422
|
+
const srcNodes = resolveSourceNodes(link, row, rowById, idByStableId);
|
|
1423
|
+
if (srcNodes.length === 0) continue;
|
|
1424
|
+
const tNode = nodeKey(row.id, link.target.cap);
|
|
1425
|
+
const list = dependsOn.get(tNode) ?? [];
|
|
1426
|
+
for (const srcNode of srcNodes) list.push(srcNode);
|
|
1427
|
+
dependsOn.set(tNode, list);
|
|
1428
|
+
}
|
|
1429
|
+
};
|
|
1430
|
+
for (const r of rows) {
|
|
1431
|
+
const links = r.id === editedDeviceId ? editedLinks : r.deviceLinks ?? [];
|
|
1432
|
+
if (links.length > 0) addEdges(r, links);
|
|
1433
|
+
}
|
|
1434
|
+
const roots = [...new Set(editedLinks.map((l) => nodeKey(editedDeviceId, l.target.cap)))];
|
|
1435
|
+
for (const root of roots) {
|
|
1436
|
+
const cycle = dfsFindCycle(root, dependsOn);
|
|
1437
|
+
if (cycle) return cycle;
|
|
1438
|
+
}
|
|
1439
|
+
return null;
|
|
1440
|
+
}
|
|
1441
|
+
/** Iterative DFS from `root` over `dependsOn`; returns the first back-edge
|
|
1442
|
+
* cycle path (closed — first node repeated last) or null. */
|
|
1443
|
+
function dfsFindCycle(root, dependsOn) {
|
|
1444
|
+
const onPath = /* @__PURE__ */ new Set();
|
|
1445
|
+
const done = /* @__PURE__ */ new Set();
|
|
1446
|
+
const path = [];
|
|
1447
|
+
const stack = [{
|
|
1448
|
+
node: root,
|
|
1449
|
+
nextChild: 0
|
|
1450
|
+
}];
|
|
1451
|
+
onPath.add(root);
|
|
1452
|
+
path.push(root);
|
|
1453
|
+
while (stack.length > 0) {
|
|
1454
|
+
const frame = stack[stack.length - 1];
|
|
1455
|
+
const children = dependsOn.get(frame.node) ?? [];
|
|
1456
|
+
if (frame.nextChild < children.length) {
|
|
1457
|
+
const child = children[frame.nextChild];
|
|
1458
|
+
frame.nextChild += 1;
|
|
1459
|
+
if (onPath.has(child)) {
|
|
1460
|
+
const start = path.indexOf(child);
|
|
1461
|
+
return [...path.slice(start), child];
|
|
1462
|
+
}
|
|
1463
|
+
if (done.has(child)) continue;
|
|
1464
|
+
stack.push({
|
|
1465
|
+
node: child,
|
|
1466
|
+
nextChild: 0
|
|
1467
|
+
});
|
|
1468
|
+
onPath.add(child);
|
|
1469
|
+
path.push(child);
|
|
1470
|
+
continue;
|
|
1471
|
+
}
|
|
1472
|
+
stack.pop();
|
|
1473
|
+
onPath.delete(frame.node);
|
|
1474
|
+
path.pop();
|
|
1475
|
+
done.add(frame.node);
|
|
1476
|
+
}
|
|
1477
|
+
return null;
|
|
1478
|
+
}
|
|
1479
|
+
//#endregion
|
|
1314
1480
|
//#region src/builtins/device-manager/device-meta-actions.ts
|
|
1315
1481
|
/**
|
|
1316
1482
|
* Device meta-mutation + persistence actions for the device-manager addon —
|
|
@@ -1324,7 +1490,7 @@ var DeviceEventPropagator = class {
|
|
|
1324
1490
|
* removeDevice), config persistence (persistConfig, loadConfig), the meta
|
|
1325
1491
|
* surface load (loadMeta, loadRuntimeState), every meta setter (setName,
|
|
1326
1492
|
* setLocation, setType, setIntegrationId, setLinkDeviceId,
|
|
1327
|
-
* setPrimaryChildEntityId, setChildLayout, setDeviceLinks, setRole,
|
|
1493
|
+
* setPrimaryChildEntityId, setChildLayout, setDeviceLinks, setRole, setDisplay,
|
|
1328
1494
|
* applyInitialMeta, setMetadata, setDisabled), and the location registry
|
|
1329
1495
|
* (listLocations, addLocation, removeLocation).
|
|
1330
1496
|
*
|
|
@@ -1433,6 +1599,7 @@ async function registerDevice(pctx, input) {
|
|
|
1433
1599
|
...existingMeta?.childLayout !== void 0 ? { childLayout: existingMeta.childLayout } : {},
|
|
1434
1600
|
...existingMeta?.deviceLinks !== void 0 ? { deviceLinks: existingMeta.deviceLinks } : {},
|
|
1435
1601
|
...existingMeta?.role !== void 0 ? { role: existingMeta.role } : {},
|
|
1602
|
+
...existingMeta?.display !== void 0 ? { display: existingMeta.display } : {},
|
|
1436
1603
|
parentDeviceId,
|
|
1437
1604
|
id,
|
|
1438
1605
|
features: featuresArr,
|
|
@@ -1872,6 +2039,13 @@ async function setChildLayout(pctx, input) {
|
|
|
1872
2039
|
*/
|
|
1873
2040
|
async function setDeviceLinks(pctx, input) {
|
|
1874
2041
|
const { deviceId, deviceLinks } = input;
|
|
2042
|
+
const cycle = findDeviceLinkCycle(await pctx.metaStore.readMeta(), deviceId, deviceLinks);
|
|
2043
|
+
if (cycle) throw new Error(`[device-manager] setDeviceLinks: cross-device link cycle: ${cycle.join(" → ")}`);
|
|
2044
|
+
for (const link of deviceLinks) {
|
|
2045
|
+
if (link.source.kind !== "expression") continue;
|
|
2046
|
+
const err = validateExpressionSource(link.source);
|
|
2047
|
+
if (err) throw new Error(`[device-manager] setDeviceLinks: invalid expression on link '${link.id}': ${err}`);
|
|
2048
|
+
}
|
|
1875
2049
|
await pctx.metaStore.withMetaWriteLock(async () => {
|
|
1876
2050
|
const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
|
|
1877
2051
|
if (!persisted) throw new Error(`[device-manager] setDeviceLinks: unknown device id=${deviceId}`);
|
|
@@ -1942,6 +2116,84 @@ async function setRole(pctx, input) {
|
|
|
1942
2116
|
});
|
|
1943
2117
|
}
|
|
1944
2118
|
/**
|
|
2119
|
+
* Normalize an operator-authored display override at write time so the render
|
|
2120
|
+
* path's `UNIT_TABLE` lookups always hit canonical spellings — maps `unit` and
|
|
2121
|
+
* every `perCap[*].unit` through `normalizeUnit`. Pure: rebuilds new objects,
|
|
2122
|
+
* never mutates the input. A unit `normalizeUnit` cannot canonicalize is left
|
|
2123
|
+
* as-is (the render path refuses conversion for unknown spellings).
|
|
2124
|
+
*/
|
|
2125
|
+
function normalizeDisplayOverride(display) {
|
|
2126
|
+
return {
|
|
2127
|
+
...display,
|
|
2128
|
+
...display.unit !== void 0 ? { unit: normalizeUnit(display.unit) ?? display.unit } : {},
|
|
2129
|
+
...display.perCap !== void 0 ? { perCap: Object.fromEntries(Object.entries(display.perCap).map(([cap, refine]) => [cap, refine.unit !== void 0 ? {
|
|
2130
|
+
...refine,
|
|
2131
|
+
unit: normalizeUnit(refine.unit) ?? refine.unit
|
|
2132
|
+
} : refine])) } : {}
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
/**
|
|
2136
|
+
* Set (or clear) the per-device display override on a device's meta row.
|
|
2137
|
+
* Mirrors `setChildLayout` persistence; `null` REMOVES the `display` key
|
|
2138
|
+
* entirely (immutable rest-destructure — matches the "absent ⇒ no override"
|
|
2139
|
+
* projection contract, never persists `display: undefined`). Override units are
|
|
2140
|
+
* normalized at write so the render path always looks up canonical spellings.
|
|
2141
|
+
*/
|
|
2142
|
+
async function setDisplay(pctx, input) {
|
|
2143
|
+
const { deviceId, display } = input;
|
|
2144
|
+
const normalized = display === null ? null : normalizeDisplayOverride(display);
|
|
2145
|
+
await pctx.metaStore.withMetaWriteLock(async () => {
|
|
2146
|
+
const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
|
|
2147
|
+
if (!persisted) throw new Error(`[device-manager] setDisplay: unknown device id=${deviceId}`);
|
|
2148
|
+
const { meta: m } = persisted;
|
|
2149
|
+
const key = String(deviceId);
|
|
2150
|
+
const allMeta = await pctx.metaStore.readMeta();
|
|
2151
|
+
const nextRow = normalized === null ? (({ display: _drop, ...rest }) => rest)(m) : {
|
|
2152
|
+
...m,
|
|
2153
|
+
display: normalized
|
|
2154
|
+
};
|
|
2155
|
+
await pctx.settings.writeAddonStore({ deviceMeta: {
|
|
2156
|
+
...allMeta,
|
|
2157
|
+
[key]: nextRow
|
|
2158
|
+
} });
|
|
2159
|
+
});
|
|
2160
|
+
pctx.host.ctx.eventBus.emit({
|
|
2161
|
+
id: randomUUID(),
|
|
2162
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2163
|
+
source: {
|
|
2164
|
+
type: "device",
|
|
2165
|
+
id: deviceId
|
|
2166
|
+
},
|
|
2167
|
+
category: EventCategory.DeviceMetaChanged,
|
|
2168
|
+
data: {
|
|
2169
|
+
deviceId,
|
|
2170
|
+
field: "display",
|
|
2171
|
+
value: normalized
|
|
2172
|
+
}
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Read the operator-authored per-role display defaults. Empty record when none
|
|
2177
|
+
* set. Not per-device — a plain top-level-key read.
|
|
2178
|
+
*/
|
|
2179
|
+
async function getRoleDisplayDefaults(pctx, _input) {
|
|
2180
|
+
return { defaults: (await pctx.metaStore.readStore()).roleDisplayDefaults ?? {} };
|
|
2181
|
+
}
|
|
2182
|
+
/**
|
|
2183
|
+
* Replace the per-role display defaults whole-record (full replace). Override
|
|
2184
|
+
* units are normalized (`normalizeUnit`) at write so the render path always
|
|
2185
|
+
* looks up canonical spellings. Single-writer top-level key — no lock, no RMW,
|
|
2186
|
+
* no interaction with the `deviceMeta` write lock. Not per-device, so no event
|
|
2187
|
+
* is emitted; the UI invalidates its own query on mutate.
|
|
2188
|
+
*/
|
|
2189
|
+
async function setRoleDisplayDefaults(pctx, input) {
|
|
2190
|
+
const normalized = Object.fromEntries(Object.entries(input.defaults).map(([role, def]) => [role, def.unit !== void 0 ? {
|
|
2191
|
+
...def,
|
|
2192
|
+
unit: normalizeUnit(def.unit) ?? def.unit
|
|
2193
|
+
} : def]));
|
|
2194
|
+
await pctx.settings.writeAddonStore({ roleDisplayDefaults: normalized });
|
|
2195
|
+
}
|
|
2196
|
+
/**
|
|
1945
2197
|
* Batched meta pre-seed. Applies every provided field to the
|
|
1946
2198
|
* device's meta row in ONE read-modify-write under a single
|
|
1947
2199
|
* `withMetaWriteLock` acquisition (one `deviceMeta` blob write),
|
|
@@ -2186,21 +2438,33 @@ async function removeLocation(pctx, input) {
|
|
|
2186
2438
|
function buildLinkIndexes(entries) {
|
|
2187
2439
|
const targets = /* @__PURE__ */ new Map();
|
|
2188
2440
|
const dependents = /* @__PURE__ */ new Map();
|
|
2189
|
-
for (const { targetDeviceId, link, sourceDeviceId } of entries) {
|
|
2441
|
+
for (const { targetDeviceId, link, sourceDeviceId, bindingSourceIds } of entries) {
|
|
2190
2442
|
const tKey = `${targetDeviceId}:${link.target.cap}`;
|
|
2191
2443
|
const tList = targets.get(tKey) ?? [];
|
|
2192
2444
|
tList.push({
|
|
2193
2445
|
link,
|
|
2194
|
-
sourceDeviceId
|
|
2446
|
+
sourceDeviceId,
|
|
2447
|
+
...bindingSourceIds !== void 0 ? { bindingSourceIds } : {}
|
|
2195
2448
|
});
|
|
2196
2449
|
targets.set(tKey, tList);
|
|
2197
|
-
const
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2450
|
+
const addDependent = (sourceId, sourceCap) => {
|
|
2451
|
+
const sKey = `${sourceId}:${sourceCap}`;
|
|
2452
|
+
const sList = dependents.get(sKey) ?? [];
|
|
2453
|
+
sList.push({
|
|
2454
|
+
targetDeviceId,
|
|
2455
|
+
targetCap: link.target.cap
|
|
2456
|
+
});
|
|
2457
|
+
dependents.set(sKey, sList);
|
|
2458
|
+
};
|
|
2459
|
+
if (link.source.kind === "expression") {
|
|
2460
|
+
if (bindingSourceIds !== void 0) for (const [name, bindingDeviceId] of Object.entries(bindingSourceIds)) {
|
|
2461
|
+
const binding = link.source.bindings[name];
|
|
2462
|
+
if (binding === void 0 || binding.kind === "literal") continue;
|
|
2463
|
+
addDependent(bindingDeviceId, binding.cap);
|
|
2464
|
+
}
|
|
2465
|
+
continue;
|
|
2466
|
+
}
|
|
2467
|
+
if (link.source.kind !== "literal") addDependent(sourceDeviceId, link.source.cap);
|
|
2204
2468
|
}
|
|
2205
2469
|
return {
|
|
2206
2470
|
targets,
|
|
@@ -2315,6 +2579,15 @@ var DeviceMetaStore = class {
|
|
|
2315
2579
|
for (const m of Object.values(meta)) if (m.stableId === wanted) return m.id;
|
|
2316
2580
|
return null;
|
|
2317
2581
|
};
|
|
2582
|
+
/** Resolve a GLOBAL link source (P2e) to a live device id: the device whose
|
|
2583
|
+
* FULL stableId equals `sourceStableId`, regardless of parent container.
|
|
2584
|
+
* First match wins (stableIds are effectively unique cluster-wide — see the
|
|
2585
|
+
* `DeviceLinkGlobalSource` docblock). Null when absent. Pure over a
|
|
2586
|
+
* pre-read meta map so a multi-link resolve reads the store once. */
|
|
2587
|
+
resolveGlobalSourceDeviceId = (sourceStableId, meta) => {
|
|
2588
|
+
for (const m of Object.values(meta)) if (m.stableId === sourceStableId) return m.id;
|
|
2589
|
+
return null;
|
|
2590
|
+
};
|
|
2318
2591
|
allocateNextDeviceId = async () => {
|
|
2319
2592
|
const current = (await this.readStore()).nextDeviceId ?? 1;
|
|
2320
2593
|
await this.settings.writeAddonStore({ nextDeviceId: current + 1 });
|
|
@@ -2345,8 +2618,35 @@ var DeviceMetaStore = class {
|
|
|
2345
2618
|
const container = targetMeta.parentDeviceId !== null ? stableIdById.get(targetMeta.parentDeviceId) ?? stableIdById.get(targetId) : stableIdById.get(targetId);
|
|
2346
2619
|
if (container === void 0) continue;
|
|
2347
2620
|
for (const link of targetMeta.deviceLinks ?? []) {
|
|
2348
|
-
|
|
2349
|
-
|
|
2621
|
+
const src = link.source;
|
|
2622
|
+
if (src.kind === "expression") {
|
|
2623
|
+
const bindingSourceIds = {};
|
|
2624
|
+
for (const [name, b] of Object.entries(src.bindings)) {
|
|
2625
|
+
if (b.kind === "literal") continue;
|
|
2626
|
+
const wanted = b.kind === "global" ? b.sourceStableId : `${container}-${b.sourceKey}`;
|
|
2627
|
+
expectedSources.add(wanted);
|
|
2628
|
+
const bid = idByStableId.get(wanted);
|
|
2629
|
+
if (bid !== void 0) bindingSourceIds[name] = bid;
|
|
2630
|
+
}
|
|
2631
|
+
entries.push({
|
|
2632
|
+
targetDeviceId: targetId,
|
|
2633
|
+
link,
|
|
2634
|
+
sourceDeviceId: -1,
|
|
2635
|
+
bindingSourceIds
|
|
2636
|
+
});
|
|
2637
|
+
continue;
|
|
2638
|
+
}
|
|
2639
|
+
if (src.kind === "literal") {
|
|
2640
|
+
entries.push({
|
|
2641
|
+
targetDeviceId: targetId,
|
|
2642
|
+
link,
|
|
2643
|
+
sourceDeviceId: -1
|
|
2644
|
+
});
|
|
2645
|
+
continue;
|
|
2646
|
+
}
|
|
2647
|
+
const wantedStableId = src.kind === "global" ? src.sourceStableId : `${container}-${src.sourceKey}`;
|
|
2648
|
+
expectedSources.add(wantedStableId);
|
|
2649
|
+
const srcId = idByStableId.get(wantedStableId);
|
|
2350
2650
|
if (srcId === void 0) continue;
|
|
2351
2651
|
entries.push({
|
|
2352
2652
|
targetDeviceId: targetId,
|
|
@@ -2453,6 +2753,7 @@ async function listAll(pctx, input) {
|
|
|
2453
2753
|
...m?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
|
|
2454
2754
|
...m?.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
|
|
2455
2755
|
...m?.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
|
|
2756
|
+
...m?.display !== void 0 ? { display: m.display } : {},
|
|
2456
2757
|
...(() => {
|
|
2457
2758
|
const si = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
|
|
2458
2759
|
return si !== void 0 ? { sourceInfo: si } : {};
|
|
@@ -2501,6 +2802,7 @@ async function getDevice(pctx, input) {
|
|
|
2501
2802
|
...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
|
|
2502
2803
|
...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
|
|
2503
2804
|
...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
|
|
2805
|
+
...m.display !== void 0 ? { display: m.display } : {},
|
|
2504
2806
|
...sourceInfoGetDevice !== void 0 ? { sourceInfo: sourceInfoGetDevice } : {}
|
|
2505
2807
|
};
|
|
2506
2808
|
}
|
|
@@ -2563,6 +2865,7 @@ async function getChildren(pctx, input) {
|
|
|
2563
2865
|
...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
|
|
2564
2866
|
...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
|
|
2565
2867
|
...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
|
|
2868
|
+
...m.display !== void 0 ? { display: m.display } : {},
|
|
2566
2869
|
...sourceInfoChild !== void 0 ? { sourceInfo: sourceInfoChild } : {}
|
|
2567
2870
|
});
|
|
2568
2871
|
}
|
|
@@ -2892,6 +3195,48 @@ async function testField(pctx, input) {
|
|
|
2892
3195
|
function isRecord(x) {
|
|
2893
3196
|
return x !== null && typeof x === "object" && !Array.isArray(x);
|
|
2894
3197
|
}
|
|
3198
|
+
/**
|
|
3199
|
+
* Resolve one link's transformed value. Field sources read via `readField`
|
|
3200
|
+
* (the caller supplies the sibling lookup — sync, e.g. the in-hub state
|
|
3201
|
+
* mirror); literal sources use their per-device constant and never consult
|
|
3202
|
+
* the reader. Returns `undefined` to skip the overlay (unreadable field).
|
|
3203
|
+
* Pure. Async source reads (`resolveLinkedStatus`'s provider `getStatus`
|
|
3204
|
+
* path) cannot use this helper for field sources — they branch on
|
|
3205
|
+
* `source.kind` directly.
|
|
3206
|
+
*/
|
|
3207
|
+
function resolveLinkValue(link, readField) {
|
|
3208
|
+
const src = link.source;
|
|
3209
|
+
if (src.kind === "expression") return void 0;
|
|
3210
|
+
return applyTransform(src.kind === "literal" ? src.value : readField(src.cap, src.fieldPath), link.transform);
|
|
3211
|
+
}
|
|
3212
|
+
/**
|
|
3213
|
+
* Sync-channel expression resolution: bindings read from the mirror via
|
|
3214
|
+
* `readField(deviceId, cap, fieldPath)` using the pre-resolved per-binding ids
|
|
3215
|
+
* on the `ResolvedTargetLink`. A literal binding uses its constant; an absent
|
|
3216
|
+
* binding id (dangling source) or a non-primitive read resolves to `null`.
|
|
3217
|
+
* Returns `undefined` on parse/eval failure (skip overlay) — silent by design:
|
|
3218
|
+
* the mirror path is hot and churn-free; the async channel logs. Compile
|
|
3219
|
+
* failures cost one parse ever thanks to the negative LRU compile cache. Pure.
|
|
3220
|
+
*/
|
|
3221
|
+
function resolveExpressionLinkValue(rl, readField, now) {
|
|
3222
|
+
const src = rl.link.source;
|
|
3223
|
+
if (src.kind !== "expression") return void 0;
|
|
3224
|
+
const bindingValues = {};
|
|
3225
|
+
for (const [name, b] of Object.entries(src.bindings)) {
|
|
3226
|
+
if (b.kind === "literal") {
|
|
3227
|
+
bindingValues[name] = b.value;
|
|
3228
|
+
continue;
|
|
3229
|
+
}
|
|
3230
|
+
const bId = rl.bindingSourceIds?.[name];
|
|
3231
|
+
if (bId === void 0) {
|
|
3232
|
+
bindingValues[name] = null;
|
|
3233
|
+
continue;
|
|
3234
|
+
}
|
|
3235
|
+
bindingValues[name] = toExpressionValue(readField(bId, b.cap, b.fieldPath)) ?? null;
|
|
3236
|
+
}
|
|
3237
|
+
const result = evaluateLinkExpression(src.expr, bindingValues, now);
|
|
3238
|
+
return result.ok ? applyTransform(result.value, rl.link.transform) : void 0;
|
|
3239
|
+
}
|
|
2895
3240
|
/** Narrow Zod v4's structural `$ZodType` (returned by `.unwrap()`) back to the
|
|
2896
3241
|
* concrete classic `z.ZodType`. Every runtime schema is a `z.ZodType`, so this
|
|
2897
3242
|
* is a true `instanceof` guard rather than a cast. */
|
|
@@ -2923,19 +3268,88 @@ function fillNullableDefaults(schema, value) {
|
|
|
2923
3268
|
return out;
|
|
2924
3269
|
}
|
|
2925
3270
|
/**
|
|
3271
|
+
* Group item-targeted links (`target.itemKey` set) by itemKey and upsert each
|
|
3272
|
+
* group as ONE item into the status' item array (see
|
|
3273
|
+
* `CapabilityStatusItemArray`). A NEW item is seeded from `emptyItem` with
|
|
3274
|
+
* `keyField` (and `labelField`, when declared) set to the itemKey; an
|
|
3275
|
+
* EXISTING item (matched by `keyField`) is cloned and overlaid in place, so
|
|
3276
|
+
* native fields a link doesn't touch are preserved. Each upserted item is
|
|
3277
|
+
* validated against `itemSchema` — an invalid item is skipped WITHOUT
|
|
3278
|
+
* discarding its valid siblings. Returns the new draft and whether anything
|
|
3279
|
+
* was applied. Pure + immutable (base array/items are never mutated).
|
|
3280
|
+
*/
|
|
3281
|
+
function upsertItemArrayLinks(draft, grouped, itemArray) {
|
|
3282
|
+
const arrRaw = getByPath(draft, itemArray.path);
|
|
3283
|
+
const arr = Array.isArray(arrRaw) ? [...arrRaw] : [];
|
|
3284
|
+
let touched = false;
|
|
3285
|
+
for (const [itemKey, group] of grouped) {
|
|
3286
|
+
const idx = arr.findIndex((el) => isRecord(el) && el[itemArray.keyField] === itemKey);
|
|
3287
|
+
const existing = idx >= 0 ? arr[idx] : null;
|
|
3288
|
+
let item = isRecord(existing) ? { ...existing } : {
|
|
3289
|
+
...itemArray.emptyItem,
|
|
3290
|
+
[itemArray.keyField]: itemKey,
|
|
3291
|
+
...itemArray.labelField !== void 0 ? { [itemArray.labelField]: itemKey } : {}
|
|
3292
|
+
};
|
|
3293
|
+
let itemTouched = false;
|
|
3294
|
+
for (const { link, sourceValue } of group) {
|
|
3295
|
+
if (sourceValue === void 0) continue;
|
|
3296
|
+
item = setByPath(item, link.target.fieldPath, sourceValue);
|
|
3297
|
+
itemTouched = true;
|
|
3298
|
+
}
|
|
3299
|
+
if (!itemTouched) continue;
|
|
3300
|
+
const repaired = fillNullableDefaults(itemArray.itemSchema, item);
|
|
3301
|
+
const parsed = itemArray.itemSchema.safeParse(repaired);
|
|
3302
|
+
if (!parsed.success) continue;
|
|
3303
|
+
if (isRecord(parsed.data)) item = parsed.data;
|
|
3304
|
+
if (idx >= 0) arr[idx] = item;
|
|
3305
|
+
else arr.push(item);
|
|
3306
|
+
touched = true;
|
|
3307
|
+
}
|
|
3308
|
+
if (!touched) return {
|
|
3309
|
+
draft,
|
|
3310
|
+
touched: false
|
|
3311
|
+
};
|
|
3312
|
+
return {
|
|
3313
|
+
draft: setByPath(draft, itemArray.path, arr),
|
|
3314
|
+
touched: true
|
|
3315
|
+
};
|
|
3316
|
+
}
|
|
3317
|
+
/**
|
|
2926
3318
|
* Overlay transformed source values onto `base` by dot-path, then validate the
|
|
2927
3319
|
* result against the target cap's `statusSchema`. On validation failure the
|
|
2928
3320
|
* overlay is discarded and `base` is returned unchanged (a misconfigured link
|
|
2929
3321
|
* must never corrupt a cap response). Pure — all I/O happens in the caller.
|
|
3322
|
+
*
|
|
3323
|
+
* Item-array grouping (P2b): when the cap declares `status.itemArray`, links
|
|
3324
|
+
* carrying a `target.itemKey` are grouped per key and upserted as items into
|
|
3325
|
+
* the array (see `upsertItemArrayLinks`); their `fieldPath` is relative to
|
|
3326
|
+
* ONE item ('level', 'status', 'label'). Links WITHOUT an itemKey keep the
|
|
3327
|
+
* scalar dot-path behavior unchanged. A link with an itemKey on a cap that
|
|
3328
|
+
* declares NO `itemArray` is dropped (its item-relative path must never be
|
|
3329
|
+
* scalar-applied to the status root).
|
|
2930
3330
|
*/
|
|
2931
|
-
function mergeLinkedStatus(base, resolved, statusSchema) {
|
|
3331
|
+
function mergeLinkedStatus(base, resolved, statusSchema, itemArray) {
|
|
2932
3332
|
let draft = base;
|
|
2933
3333
|
let touched = false;
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
3334
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
3335
|
+
for (const entry of resolved) {
|
|
3336
|
+
const itemKey = entry.link.target.itemKey;
|
|
3337
|
+
if (itemKey !== void 0) {
|
|
3338
|
+
if (!itemArray) continue;
|
|
3339
|
+
const group = grouped.get(itemKey);
|
|
3340
|
+
if (group) group.push(entry);
|
|
3341
|
+
else grouped.set(itemKey, [entry]);
|
|
3342
|
+
continue;
|
|
3343
|
+
}
|
|
3344
|
+
if (entry.sourceValue === void 0) continue;
|
|
3345
|
+
draft = setByPath(draft, entry.link.target.fieldPath, entry.sourceValue);
|
|
2937
3346
|
touched = true;
|
|
2938
3347
|
}
|
|
3348
|
+
if (itemArray && grouped.size > 0) {
|
|
3349
|
+
const upserted = upsertItemArrayLinks(draft, grouped, itemArray);
|
|
3350
|
+
draft = upserted.draft;
|
|
3351
|
+
touched = touched || upserted.touched;
|
|
3352
|
+
}
|
|
2939
3353
|
if (!touched) return base;
|
|
2940
3354
|
if (!statusSchema) return draft;
|
|
2941
3355
|
const repaired = fillNullableDefaults(statusSchema, draft);
|
|
@@ -3132,43 +3546,63 @@ var DeviceStateMirror = class DeviceStateMirror {
|
|
|
3132
3546
|
* Read-time overlay of a cap slice with its cross-device linked values.
|
|
3133
3547
|
* Returns a cloned raw mirror slice when the (device, cap) pair has no
|
|
3134
3548
|
* links. Sources are read from the same in-hub stateMirror — sync, no
|
|
3135
|
-
* cross-process call.
|
|
3136
|
-
*
|
|
3549
|
+
* cross-process call. When the raw slice is ABSENT but the cap is a link
|
|
3550
|
+
* target, the slice is SYNTHESIZED: the merge base is seeded from the
|
|
3551
|
+
* cap's declared `status.empty` default; a failed/empty synthesize
|
|
3552
|
+
* returns null (no phantom slices). The disk writer must NOT use this
|
|
3553
|
+
* method; it must persist raw provider truth via snapshotForDevice.
|
|
3137
3554
|
*/
|
|
3138
3555
|
overlayedSlice(deviceId, cap) {
|
|
3139
3556
|
const raw = this.stateMirror.get(deviceId)?.get(cap) ?? null;
|
|
3140
3557
|
const links = this.linkHost.linkTargets.get(`${deviceId}:${cap}`);
|
|
3141
3558
|
if (!links || links.length === 0) return raw ? { ...raw } : null;
|
|
3559
|
+
const now = Date.now();
|
|
3142
3560
|
const resolved = links.map((rl) => ({
|
|
3143
3561
|
link: rl.link,
|
|
3144
|
-
sourceValue:
|
|
3562
|
+
sourceValue: rl.link.source.kind === "expression" ? resolveExpressionLinkValue(rl, (srcDeviceId, srcCap, fieldPath) => getByPath(this.stateMirror.get(srcDeviceId)?.get(srcCap), fieldPath), now) : resolveLinkValue(rl.link, (srcCap, fieldPath) => getByPath(this.stateMirror.get(rl.sourceDeviceId)?.get(srcCap), fieldPath))
|
|
3145
3563
|
}));
|
|
3146
|
-
const
|
|
3147
|
-
|
|
3564
|
+
const capStatus = this.linkHost.capabilityRegistry?.getDefinition(cap)?.status;
|
|
3565
|
+
const schema = capStatus?.schema;
|
|
3566
|
+
const empty = capStatus?.empty;
|
|
3567
|
+
const base = raw ? { ...raw } : empty !== null && typeof empty === "object" && !Array.isArray(empty) ? { ...empty } : {};
|
|
3568
|
+
const merged = mergeLinkedStatus(base, resolved, schema, capStatus?.itemArray);
|
|
3569
|
+
if (!raw && merged === base) return null;
|
|
3570
|
+
return merged;
|
|
3148
3571
|
}
|
|
3149
3572
|
/**
|
|
3150
3573
|
* Like snapshotForDevice but applies the device-link overlay per cap.
|
|
3151
3574
|
* Used exclusively by the device-state READ methods (getSnapshot,
|
|
3152
|
-
* getAllSnapshots) so callers see overlayed values.
|
|
3153
|
-
*
|
|
3575
|
+
* getAllSnapshots) so callers see overlayed values. SYNTHESIZE-only
|
|
3576
|
+
* link-target caps (no base mirror slice) are unioned into the iterated
|
|
3577
|
+
* key set so they appear at warm-load — `overlayedSlice` builds them from
|
|
3578
|
+
* the cap's `status.empty` (and drops them when nothing resolves). The
|
|
3579
|
+
* debounced disk writer must continue to call snapshotForDevice (raw truth).
|
|
3154
3580
|
*/
|
|
3155
3581
|
snapshotForDeviceOverlayed(deviceId) {
|
|
3156
3582
|
const perCap = this.stateMirror.get(deviceId);
|
|
3157
|
-
|
|
3583
|
+
const capNames = new Set(perCap ? perCap.keys() : []);
|
|
3584
|
+
const prefix = `${deviceId}:`;
|
|
3585
|
+
for (const key of this.linkHost.linkTargets.keys()) if (key.startsWith(prefix)) capNames.add(key.slice(prefix.length));
|
|
3158
3586
|
const out = {};
|
|
3159
|
-
for (const capName of
|
|
3587
|
+
for (const capName of capNames) {
|
|
3160
3588
|
const s = this.overlayedSlice(deviceId, capName);
|
|
3161
3589
|
if (s) out[capName] = s;
|
|
3162
3590
|
}
|
|
3163
3591
|
return out;
|
|
3164
3592
|
}
|
|
3165
|
-
/** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`.
|
|
3593
|
+
/** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`.
|
|
3594
|
+
* Devices that only exist as link TARGETS (no mirror slice yet) are
|
|
3595
|
+
* included via the link reverse-index so their synthesized caps appear. */
|
|
3166
3596
|
allSnapshotsOverlayed() {
|
|
3597
|
+
const deviceIds = new Set(this.stateMirror.keys());
|
|
3598
|
+
for (const key of this.linkHost.linkTargets.keys()) {
|
|
3599
|
+
const id = Number(key.slice(0, key.indexOf(":")));
|
|
3600
|
+
if (Number.isFinite(id)) deviceIds.add(id);
|
|
3601
|
+
}
|
|
3167
3602
|
const out = {};
|
|
3168
|
-
for (const
|
|
3169
|
-
const dev =
|
|
3170
|
-
|
|
3171
|
-
out[String(deviceId)] = dev;
|
|
3603
|
+
for (const deviceId of deviceIds) {
|
|
3604
|
+
const dev = this.snapshotForDeviceOverlayed(deviceId);
|
|
3605
|
+
if (this.stateMirror.has(deviceId) || Object.keys(dev).length > 0) out[String(deviceId)] = dev;
|
|
3172
3606
|
}
|
|
3173
3607
|
return out;
|
|
3174
3608
|
}
|
|
@@ -3287,14 +3721,27 @@ function resolveNativeCapOwnerSync(pctx, capName, deviceId) {
|
|
|
3287
3721
|
* `getProviderForDevice` (routes cross-process); merge is pure.
|
|
3288
3722
|
*/
|
|
3289
3723
|
async function resolveLinkedStatus(pctx, input) {
|
|
3290
|
-
const { deviceId, cap
|
|
3724
|
+
const { deviceId, cap } = input;
|
|
3291
3725
|
if (!pctx.host.devicesWithLinks.has(deviceId)) return null;
|
|
3726
|
+
const inFlightKey = `${deviceId}:${cap}`;
|
|
3727
|
+
if (pctx.host.linkResolveInFlight.has(inFlightKey)) return null;
|
|
3728
|
+
pctx.host.linkResolveInFlight.add(inFlightKey);
|
|
3729
|
+
try {
|
|
3730
|
+
return await resolveLinkedStatusInner(pctx, input);
|
|
3731
|
+
} finally {
|
|
3732
|
+
pctx.host.linkResolveInFlight.delete(inFlightKey);
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
/** Body of `resolveLinkedStatus` — see the wrapper for the re-entrancy guard. */
|
|
3736
|
+
async function resolveLinkedStatusInner(pctx, input) {
|
|
3737
|
+
const { deviceId, cap, baseStatus } = input;
|
|
3292
3738
|
const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
|
|
3293
3739
|
if (!persisted) return null;
|
|
3294
3740
|
const links = (persisted.meta.deviceLinks ?? []).filter((l) => l.target.cap === cap);
|
|
3295
3741
|
if (links.length === 0) return null;
|
|
3296
3742
|
const capRegistry = pctx.host.capabilityRegistry;
|
|
3297
|
-
const
|
|
3743
|
+
const capStatus = capRegistry?.getDefinition(cap)?.status;
|
|
3744
|
+
const schema = capStatus?.schema;
|
|
3298
3745
|
if (!schema) return null;
|
|
3299
3746
|
const allMeta = await pctx.metaStore.readMeta();
|
|
3300
3747
|
let containerStableId = persisted.stableId;
|
|
@@ -3303,32 +3750,96 @@ async function resolveLinkedStatus(pctx, input) {
|
|
|
3303
3750
|
const parentMeta = allMeta[String(parentId)];
|
|
3304
3751
|
if (parentMeta) containerStableId = parentMeta.stableId;
|
|
3305
3752
|
}
|
|
3753
|
+
const now = Date.now();
|
|
3754
|
+
const statusMemo = /* @__PURE__ */ new Map();
|
|
3755
|
+
const readSourceStatus = async (srcId, srcCap) => {
|
|
3756
|
+
const key = `${srcId}:${srcCap}`;
|
|
3757
|
+
const cached = statusMemo.get(key);
|
|
3758
|
+
if (cached !== void 0) return cached;
|
|
3759
|
+
let result;
|
|
3760
|
+
try {
|
|
3761
|
+
const srcProvider = capRegistry?.getProviderForDevice(srcCap, srcId);
|
|
3762
|
+
result = {
|
|
3763
|
+
status: typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0,
|
|
3764
|
+
ok: true
|
|
3765
|
+
};
|
|
3766
|
+
} catch (err) {
|
|
3767
|
+
pctx.host.ctx.logger.warn("resolveLinkedStatus: source read failed", {
|
|
3768
|
+
tags: {
|
|
3769
|
+
deviceId,
|
|
3770
|
+
capName: cap
|
|
3771
|
+
},
|
|
3772
|
+
meta: {
|
|
3773
|
+
sourceCap: srcCap,
|
|
3774
|
+
sourceId: srcId,
|
|
3775
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3776
|
+
}
|
|
3777
|
+
});
|
|
3778
|
+
result = {
|
|
3779
|
+
status: void 0,
|
|
3780
|
+
ok: false
|
|
3781
|
+
};
|
|
3782
|
+
}
|
|
3783
|
+
statusMemo.set(key, result);
|
|
3784
|
+
return result;
|
|
3785
|
+
};
|
|
3306
3786
|
const resolved = [];
|
|
3307
3787
|
for (const link of links) {
|
|
3308
|
-
const
|
|
3309
|
-
if (
|
|
3310
|
-
try {
|
|
3311
|
-
const srcProvider = capRegistry?.getProviderForDevice(link.source.cap, srcId);
|
|
3312
|
-
const raw = getByPath(typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0, link.source.fieldPath);
|
|
3788
|
+
const src = link.source;
|
|
3789
|
+
if (src.kind === "literal") {
|
|
3313
3790
|
resolved.push({
|
|
3314
3791
|
link,
|
|
3315
|
-
sourceValue: applyTransform(
|
|
3792
|
+
sourceValue: applyTransform(src.value, link.transform)
|
|
3316
3793
|
});
|
|
3317
|
-
|
|
3318
|
-
|
|
3794
|
+
continue;
|
|
3795
|
+
}
|
|
3796
|
+
if (src.kind === "expression") {
|
|
3797
|
+
const bindingValues = {};
|
|
3798
|
+
for (const [name, b] of Object.entries(src.bindings)) {
|
|
3799
|
+
if (b.kind === "literal") {
|
|
3800
|
+
bindingValues[name] = b.value;
|
|
3801
|
+
continue;
|
|
3802
|
+
}
|
|
3803
|
+
const bId = b.kind === "global" ? pctx.metaStore.resolveGlobalSourceDeviceId(b.sourceStableId, allMeta) : pctx.metaStore.resolveSourceDeviceId(containerStableId, b.sourceKey, allMeta);
|
|
3804
|
+
if (bId === null) {
|
|
3805
|
+
bindingValues[name] = null;
|
|
3806
|
+
continue;
|
|
3807
|
+
}
|
|
3808
|
+
bindingValues[name] = toExpressionValue(getByPath((await readSourceStatus(bId, b.cap)).status, b.fieldPath)) ?? null;
|
|
3809
|
+
}
|
|
3810
|
+
const result = evaluateLinkExpression(src.expr, bindingValues, now);
|
|
3811
|
+
if (result.ok) resolved.push({
|
|
3812
|
+
link,
|
|
3813
|
+
sourceValue: applyTransform(result.value, link.transform)
|
|
3814
|
+
});
|
|
3815
|
+
else pctx.host.ctx.logger.warn("resolveLinkedStatus: expression skipped", {
|
|
3319
3816
|
tags: {
|
|
3320
3817
|
deviceId,
|
|
3321
3818
|
capName: cap
|
|
3322
3819
|
},
|
|
3323
3820
|
meta: {
|
|
3324
|
-
|
|
3325
|
-
error:
|
|
3821
|
+
linkId: link.id,
|
|
3822
|
+
error: result.error
|
|
3326
3823
|
}
|
|
3327
3824
|
});
|
|
3825
|
+
continue;
|
|
3328
3826
|
}
|
|
3827
|
+
const srcId = src.kind === "global" ? pctx.metaStore.resolveGlobalSourceDeviceId(src.sourceStableId, allMeta) : pctx.metaStore.resolveSourceDeviceId(containerStableId, src.sourceKey, allMeta);
|
|
3828
|
+
if (srcId === null) continue;
|
|
3829
|
+
const read = await readSourceStatus(srcId, src.cap);
|
|
3830
|
+
if (!read.ok) continue;
|
|
3831
|
+
const raw = getByPath(read.status, src.fieldPath);
|
|
3832
|
+
resolved.push({
|
|
3833
|
+
link,
|
|
3834
|
+
sourceValue: applyTransform(raw, link.transform)
|
|
3835
|
+
});
|
|
3329
3836
|
}
|
|
3330
3837
|
if (resolved.length === 0) return null;
|
|
3331
|
-
|
|
3838
|
+
const synthesizing = !isRecord$1(baseStatus);
|
|
3839
|
+
const base = isRecord$1(baseStatus) ? baseStatus : isRecord$1(capStatus.empty) ? { ...capStatus.empty } : {};
|
|
3840
|
+
const merged = mergeLinkedStatus(base, resolved, schema, capStatus.itemArray);
|
|
3841
|
+
if (synthesizing && merged === base) return null;
|
|
3842
|
+
return merged;
|
|
3332
3843
|
}
|
|
3333
3844
|
//#endregion
|
|
3334
3845
|
//#region src/builtins/device-manager/device-manager.addon.ts
|
|
@@ -3394,10 +3905,15 @@ var DeviceManagerAddon = class extends BaseAddon {
|
|
|
3394
3905
|
linkTargets = /* @__PURE__ */ new Map();
|
|
3395
3906
|
/** `${sourceDeviceId}:${sourceCap}` → targets to recompute when that source changes. */
|
|
3396
3907
|
linkDependents = /* @__PURE__ */ new Map();
|
|
3397
|
-
/** Expected source `stableId`s (`${container}-${sourceKey}`
|
|
3908
|
+
/** Expected source `stableId`s (`${container}-${sourceKey}` for sibling
|
|
3909
|
+
* sources, the full `sourceStableId` for global sources) across all links,
|
|
3398
3910
|
* resolved or not — gates the `registerDevice` rebuild so only a registering
|
|
3399
3911
|
* device that IS a link source triggers a reindex (not every boot restore). */
|
|
3400
3912
|
expectedSourceStableIds = /* @__PURE__ */ new Set();
|
|
3913
|
+
/** In-flight `${deviceId}:${cap}` pairs of `resolveLinkedStatus` — the P2e
|
|
3914
|
+
* defensive re-entrancy guard bounding link cycles at resolve time. Mutated
|
|
3915
|
+
* in place (never reassigned), so the host exposes a direct reference. */
|
|
3916
|
+
linkResolveInFlight = /* @__PURE__ */ new Set();
|
|
3401
3917
|
/** Test/diagnostic accessors. */
|
|
3402
3918
|
linkTargetKeys() {
|
|
3403
3919
|
return [...this.linkTargets.keys()];
|
|
@@ -3469,6 +3985,7 @@ var DeviceManagerAddon = class extends BaseAddon {
|
|
|
3469
3985
|
get expectedSourceStableIds() {
|
|
3470
3986
|
return expectedSourceStableIds();
|
|
3471
3987
|
},
|
|
3988
|
+
linkResolveInFlight: this.linkResolveInFlight,
|
|
3472
3989
|
dropDeviceOverlays: (deviceId) => this.stateMirror.dropDeviceOverlays(deviceId),
|
|
3473
3990
|
remoteNativeCaps: this.remoteNativeCaps,
|
|
3474
3991
|
seedMirror: (deviceId, blob) => this.stateMirror.seedMirror(deviceId, blob),
|
|
@@ -3517,12 +4034,15 @@ var DeviceManagerAddon = class extends BaseAddon {
|
|
|
3517
4034
|
}
|
|
3518
4035
|
}))).filter((id) => id !== null);
|
|
3519
4036
|
}
|
|
3520
|
-
/** Build the dependency context the extracted binding resolvers consume.
|
|
4037
|
+
/** Build the dependency context the extracted binding resolvers consume.
|
|
4038
|
+
* `devicesWithLinks` is mutated in place (never reassigned) so the direct
|
|
4039
|
+
* reference stays live — it gates the virtual `linked` binding step. */
|
|
3521
4040
|
get bindingsDeps() {
|
|
3522
4041
|
return {
|
|
3523
4042
|
ctx: this.ctx,
|
|
3524
4043
|
capabilityRegistry: this.capabilityRegistry,
|
|
3525
|
-
remoteNativeCaps: this.remoteNativeCaps
|
|
4044
|
+
remoteNativeCaps: this.remoteNativeCaps,
|
|
4045
|
+
devicesWithLinks: this.devicesWithLinks
|
|
3526
4046
|
};
|
|
3527
4047
|
}
|
|
3528
4048
|
async getBindings(input) {
|
|
@@ -3625,6 +4145,9 @@ var DeviceManagerAddon = class extends BaseAddon {
|
|
|
3625
4145
|
setChildLayout: (input) => setChildLayout(pctx, input),
|
|
3626
4146
|
setDeviceLinks: (input) => setDeviceLinks(pctx, input),
|
|
3627
4147
|
setRole: (input) => setRole(pctx, input),
|
|
4148
|
+
setDisplay: (input) => setDisplay(pctx, input),
|
|
4149
|
+
getRoleDisplayDefaults: (input) => getRoleDisplayDefaults(pctx, input),
|
|
4150
|
+
setRoleDisplayDefaults: (input) => setRoleDisplayDefaults(pctx, input),
|
|
3628
4151
|
applyInitialMeta: (input) => applyInitialMeta(pctx, input),
|
|
3629
4152
|
setMetadata: (input) => setMetadata(pctx, input),
|
|
3630
4153
|
setDisabled: (input) => setDisabled(pctx, input),
|