@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
|
@@ -300,7 +300,8 @@ function toDeviceInfo(addonId, device, metadata = null, metaRow = null) {
|
|
|
300
300
|
...metaRow?.linkDeviceId !== void 0 ? { linkDeviceId: metaRow.linkDeviceId } : {},
|
|
301
301
|
...metaRow?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: metaRow.primaryChildEntityId } : {},
|
|
302
302
|
...metaRow?.childLayout !== void 0 ? { childLayout: metaRow.childLayout } : {},
|
|
303
|
-
...metaRow?.deviceLinks !== void 0 ? { deviceLinks: metaRow.deviceLinks } : {}
|
|
303
|
+
...metaRow?.deviceLinks !== void 0 ? { deviceLinks: metaRow.deviceLinks } : {},
|
|
304
|
+
...metaRow?.display !== void 0 ? { display: metaRow.display } : {}
|
|
304
305
|
};
|
|
305
306
|
}
|
|
306
307
|
function resolveDeviceById(registry, deviceId) {
|
|
@@ -509,6 +510,21 @@ async function getBindings(deps, input) {
|
|
|
509
510
|
});
|
|
510
511
|
seenCaps.add(entry.capName);
|
|
511
512
|
}
|
|
513
|
+
if (deps.devicesWithLinks?.has(input.deviceId)) {
|
|
514
|
+
const row = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(input.deviceId)];
|
|
515
|
+
for (const link of row?.deviceLinks ?? []) {
|
|
516
|
+
const capName = link.target.cap;
|
|
517
|
+
if (seenCaps.has(capName)) continue;
|
|
518
|
+
entries.push({
|
|
519
|
+
capName,
|
|
520
|
+
kind: "linked",
|
|
521
|
+
providerAddonId: deps.ctx.id,
|
|
522
|
+
providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
|
|
523
|
+
nativeAddonId: ""
|
|
524
|
+
});
|
|
525
|
+
seenCaps.add(capName);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
512
528
|
return {
|
|
513
529
|
deviceId: input.deviceId,
|
|
514
530
|
entries
|
|
@@ -1171,11 +1187,47 @@ async function setWrapperActive(deps, input) {
|
|
|
1171
1187
|
});
|
|
1172
1188
|
}
|
|
1173
1189
|
/**
|
|
1190
|
+
* Build the wireable-cap entry for one capability definition: the status
|
|
1191
|
+
* schema's scalar leaf fields plus — for an item-array cap
|
|
1192
|
+
* (`status.itemArray`) — the per-item fields tagged `item: true` (a link
|
|
1193
|
+
* targeting one must carry a `target.itemKey`) and the cap-level `itemArray`
|
|
1194
|
+
* descriptor so the UI can address items. Returns null when the cap exposes
|
|
1195
|
+
* nothing wireable (no status schema / no leaf fields).
|
|
1196
|
+
*/
|
|
1197
|
+
function wireableEntryForDef(capName, def) {
|
|
1198
|
+
const status = def.status;
|
|
1199
|
+
const schema = status?.schema;
|
|
1200
|
+
if (!schema) return null;
|
|
1201
|
+
const itemArray = status.itemArray;
|
|
1202
|
+
const fields = [...(0, _camstack_types.enumerateSchemaFields)(schema), ...itemArray ? (0, _camstack_types.enumerateItemArrayFields)(itemArray) : []].map((f) => ({
|
|
1203
|
+
path: f.path,
|
|
1204
|
+
kind: f.kind,
|
|
1205
|
+
...f.enumValues !== void 0 ? { enumValues: [...f.enumValues] } : {},
|
|
1206
|
+
...f.item === true ? { item: true } : {}
|
|
1207
|
+
}));
|
|
1208
|
+
if (fields.length === 0) return null;
|
|
1209
|
+
return {
|
|
1210
|
+
cap: capName,
|
|
1211
|
+
fields,
|
|
1212
|
+
...itemArray ? { itemArray: {
|
|
1213
|
+
path: itemArray.path,
|
|
1214
|
+
keyField: itemArray.keyField
|
|
1215
|
+
} } : {}
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1174
1219
|
* Per-device wireable-field catalog — the domain status fields a device's
|
|
1175
1220
|
* bound caps expose, for the operator's cross-device wiring UI. Binding-driven:
|
|
1176
1221
|
* walks `getBindings(deviceId)`, skips wrapper caps (their status schemas are
|
|
1177
1222
|
* internal book-keeping, not wireable domain data), and enumerates each cap's
|
|
1178
|
-
* status schema leaf fields
|
|
1223
|
+
* status schema leaf fields (per-item fields included for item-array caps).
|
|
1224
|
+
*
|
|
1225
|
+
* `includeSynthesizable: true` (TARGET pickers only) additionally unions in
|
|
1226
|
+
* UNBOUND device-scoped caps that declare `status.empty` (the synthesize-target
|
|
1227
|
+
* marker) and whose `deviceTypes` admit this device's persisted type — so the
|
|
1228
|
+
* FIRST link to a synthesize-only cap (consumables on an HA vacuum) can be
|
|
1229
|
+
* authored before any binding exists. Default (absent/false) behavior is
|
|
1230
|
+
* byte-identical to the binding-driven catalog.
|
|
1179
1231
|
*/
|
|
1180
1232
|
async function getWireableFields(deps, input) {
|
|
1181
1233
|
const { deviceId } = input;
|
|
@@ -1189,20 +1241,20 @@ async function getWireableFields(deps, input) {
|
|
|
1189
1241
|
seen.add(entry.capName);
|
|
1190
1242
|
const def = reg.getDefinition(entry.capName);
|
|
1191
1243
|
if (!def || def.kind === "wrapper") continue;
|
|
1192
|
-
const
|
|
1193
|
-
if (
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
}
|
|
1244
|
+
const wireable = wireableEntryForDef(entry.capName, def);
|
|
1245
|
+
if (wireable) caps.push(wireable);
|
|
1246
|
+
}
|
|
1247
|
+
if (input.includeSynthesizable === true) {
|
|
1248
|
+
const deviceType = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.type;
|
|
1249
|
+
if (deviceType !== void 0) for (const def of _camstack_types.ALL_CAPABILITY_DEFINITIONS) {
|
|
1250
|
+
if (seen.has(def.name)) continue;
|
|
1251
|
+
if (def.scope !== "device" || def.kind === "wrapper") continue;
|
|
1252
|
+
if (def.status?.empty === void 0) continue;
|
|
1253
|
+
if (def.deviceTypes && !def.deviceTypes.some((t) => t === deviceType)) continue;
|
|
1254
|
+
seen.add(def.name);
|
|
1255
|
+
const wireable = wireableEntryForDef(def.name, def);
|
|
1256
|
+
if (wireable) caps.push(wireable);
|
|
1257
|
+
}
|
|
1206
1258
|
}
|
|
1207
1259
|
return { caps };
|
|
1208
1260
|
}
|
|
@@ -1316,6 +1368,120 @@ var DeviceEventPropagator = class {
|
|
|
1316
1368
|
}
|
|
1317
1369
|
};
|
|
1318
1370
|
//#endregion
|
|
1371
|
+
//#region src/builtins/device-manager/device-link-cycle.ts
|
|
1372
|
+
function nodeKey(deviceId, cap) {
|
|
1373
|
+
return `${deviceId}:${cap}`;
|
|
1374
|
+
}
|
|
1375
|
+
/** The target's container stableId — its parent's (falling back to its own for
|
|
1376
|
+
* a top-level target). Sibling FIELD sources resolve relative to this, the same
|
|
1377
|
+
* rule `rebuildLinkDependents` / `resolveLinkedStatus` apply. */
|
|
1378
|
+
function containerStableId(targetRow, rowById) {
|
|
1379
|
+
return targetRow.parentDeviceId !== null ? rowById.get(targetRow.parentDeviceId)?.stableId ?? targetRow.stableId : targetRow.stableId;
|
|
1380
|
+
}
|
|
1381
|
+
/** Resolve ONE field/global/literal binding to its `(deviceId, cap)` node, or
|
|
1382
|
+
* null when it is a literal or resolves to no known device. */
|
|
1383
|
+
function resolveBindingNode(binding, targetRow, rowById, idByStableId) {
|
|
1384
|
+
if (binding.kind === "literal") return null;
|
|
1385
|
+
const wantedStableId = binding.kind === "global" ? binding.sourceStableId : `${containerStableId(targetRow, rowById)}-${binding.sourceKey}`;
|
|
1386
|
+
const srcId = idByStableId.get(wantedStableId);
|
|
1387
|
+
if (srcId === void 0) return null;
|
|
1388
|
+
return nodeKey(srcId, binding.cap);
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Resolve a link source to the set of `(deviceId, cap)` nodes it depends on.
|
|
1392
|
+
* A literal source depends on nothing; a field/global source depends on exactly
|
|
1393
|
+
* one node; an EXPRESSION source depends on one node per resolvable non-literal
|
|
1394
|
+
* binding. Unresolvable (dangling) bindings contribute no edge — they cannot
|
|
1395
|
+
* loop while dangling; a cycle that only becomes resolvable later is bounded by
|
|
1396
|
+
* the resolve-time re-entrancy guard.
|
|
1397
|
+
*/
|
|
1398
|
+
function resolveSourceNodes(link, targetRow, rowById, idByStableId) {
|
|
1399
|
+
const src = link.source;
|
|
1400
|
+
if (src.kind === "expression") {
|
|
1401
|
+
const nodes = [];
|
|
1402
|
+
for (const binding of Object.values(src.bindings)) {
|
|
1403
|
+
const node = resolveBindingNode(binding, targetRow, rowById, idByStableId);
|
|
1404
|
+
if (node !== null) nodes.push(node);
|
|
1405
|
+
}
|
|
1406
|
+
return nodes;
|
|
1407
|
+
}
|
|
1408
|
+
const node = resolveBindingNode(src, targetRow, rowById, idByStableId);
|
|
1409
|
+
return node !== null ? [node] : [];
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Detect whether replacing `editedDeviceId`'s links with `editedLinks` closes
|
|
1413
|
+
* a dependency cycle. Returns the cycle as an ordered list of node keys
|
|
1414
|
+
* (first node repeated at the end) or null when the set is safe.
|
|
1415
|
+
*/
|
|
1416
|
+
function findDeviceLinkCycle(allMeta, editedDeviceId, editedLinks) {
|
|
1417
|
+
const rows = Object.values(allMeta);
|
|
1418
|
+
const rowById = /* @__PURE__ */ new Map();
|
|
1419
|
+
const idByStableId = /* @__PURE__ */ new Map();
|
|
1420
|
+
for (const r of rows) {
|
|
1421
|
+
rowById.set(r.id, r);
|
|
1422
|
+
if (!idByStableId.has(r.stableId)) idByStableId.set(r.stableId, r.id);
|
|
1423
|
+
}
|
|
1424
|
+
const dependsOn = /* @__PURE__ */ new Map();
|
|
1425
|
+
const addEdges = (row, links) => {
|
|
1426
|
+
for (const link of links) {
|
|
1427
|
+
const srcNodes = resolveSourceNodes(link, row, rowById, idByStableId);
|
|
1428
|
+
if (srcNodes.length === 0) continue;
|
|
1429
|
+
const tNode = nodeKey(row.id, link.target.cap);
|
|
1430
|
+
const list = dependsOn.get(tNode) ?? [];
|
|
1431
|
+
for (const srcNode of srcNodes) list.push(srcNode);
|
|
1432
|
+
dependsOn.set(tNode, list);
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1435
|
+
for (const r of rows) {
|
|
1436
|
+
const links = r.id === editedDeviceId ? editedLinks : r.deviceLinks ?? [];
|
|
1437
|
+
if (links.length > 0) addEdges(r, links);
|
|
1438
|
+
}
|
|
1439
|
+
const roots = [...new Set(editedLinks.map((l) => nodeKey(editedDeviceId, l.target.cap)))];
|
|
1440
|
+
for (const root of roots) {
|
|
1441
|
+
const cycle = dfsFindCycle(root, dependsOn);
|
|
1442
|
+
if (cycle) return cycle;
|
|
1443
|
+
}
|
|
1444
|
+
return null;
|
|
1445
|
+
}
|
|
1446
|
+
/** Iterative DFS from `root` over `dependsOn`; returns the first back-edge
|
|
1447
|
+
* cycle path (closed — first node repeated last) or null. */
|
|
1448
|
+
function dfsFindCycle(root, dependsOn) {
|
|
1449
|
+
const onPath = /* @__PURE__ */ new Set();
|
|
1450
|
+
const done = /* @__PURE__ */ new Set();
|
|
1451
|
+
const path = [];
|
|
1452
|
+
const stack = [{
|
|
1453
|
+
node: root,
|
|
1454
|
+
nextChild: 0
|
|
1455
|
+
}];
|
|
1456
|
+
onPath.add(root);
|
|
1457
|
+
path.push(root);
|
|
1458
|
+
while (stack.length > 0) {
|
|
1459
|
+
const frame = stack[stack.length - 1];
|
|
1460
|
+
const children = dependsOn.get(frame.node) ?? [];
|
|
1461
|
+
if (frame.nextChild < children.length) {
|
|
1462
|
+
const child = children[frame.nextChild];
|
|
1463
|
+
frame.nextChild += 1;
|
|
1464
|
+
if (onPath.has(child)) {
|
|
1465
|
+
const start = path.indexOf(child);
|
|
1466
|
+
return [...path.slice(start), child];
|
|
1467
|
+
}
|
|
1468
|
+
if (done.has(child)) continue;
|
|
1469
|
+
stack.push({
|
|
1470
|
+
node: child,
|
|
1471
|
+
nextChild: 0
|
|
1472
|
+
});
|
|
1473
|
+
onPath.add(child);
|
|
1474
|
+
path.push(child);
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
stack.pop();
|
|
1478
|
+
onPath.delete(frame.node);
|
|
1479
|
+
path.pop();
|
|
1480
|
+
done.add(frame.node);
|
|
1481
|
+
}
|
|
1482
|
+
return null;
|
|
1483
|
+
}
|
|
1484
|
+
//#endregion
|
|
1319
1485
|
//#region src/builtins/device-manager/device-meta-actions.ts
|
|
1320
1486
|
/**
|
|
1321
1487
|
* Device meta-mutation + persistence actions for the device-manager addon —
|
|
@@ -1329,7 +1495,7 @@ var DeviceEventPropagator = class {
|
|
|
1329
1495
|
* removeDevice), config persistence (persistConfig, loadConfig), the meta
|
|
1330
1496
|
* surface load (loadMeta, loadRuntimeState), every meta setter (setName,
|
|
1331
1497
|
* setLocation, setType, setIntegrationId, setLinkDeviceId,
|
|
1332
|
-
* setPrimaryChildEntityId, setChildLayout, setDeviceLinks, setRole,
|
|
1498
|
+
* setPrimaryChildEntityId, setChildLayout, setDeviceLinks, setRole, setDisplay,
|
|
1333
1499
|
* applyInitialMeta, setMetadata, setDisabled), and the location registry
|
|
1334
1500
|
* (listLocations, addLocation, removeLocation).
|
|
1335
1501
|
*
|
|
@@ -1438,6 +1604,7 @@ async function registerDevice(pctx, input) {
|
|
|
1438
1604
|
...existingMeta?.childLayout !== void 0 ? { childLayout: existingMeta.childLayout } : {},
|
|
1439
1605
|
...existingMeta?.deviceLinks !== void 0 ? { deviceLinks: existingMeta.deviceLinks } : {},
|
|
1440
1606
|
...existingMeta?.role !== void 0 ? { role: existingMeta.role } : {},
|
|
1607
|
+
...existingMeta?.display !== void 0 ? { display: existingMeta.display } : {},
|
|
1441
1608
|
parentDeviceId,
|
|
1442
1609
|
id,
|
|
1443
1610
|
features: featuresArr,
|
|
@@ -1877,6 +2044,13 @@ async function setChildLayout(pctx, input) {
|
|
|
1877
2044
|
*/
|
|
1878
2045
|
async function setDeviceLinks(pctx, input) {
|
|
1879
2046
|
const { deviceId, deviceLinks } = input;
|
|
2047
|
+
const cycle = findDeviceLinkCycle(await pctx.metaStore.readMeta(), deviceId, deviceLinks);
|
|
2048
|
+
if (cycle) throw new Error(`[device-manager] setDeviceLinks: cross-device link cycle: ${cycle.join(" → ")}`);
|
|
2049
|
+
for (const link of deviceLinks) {
|
|
2050
|
+
if (link.source.kind !== "expression") continue;
|
|
2051
|
+
const err = (0, _camstack_types.validateExpressionSource)(link.source);
|
|
2052
|
+
if (err) throw new Error(`[device-manager] setDeviceLinks: invalid expression on link '${link.id}': ${err}`);
|
|
2053
|
+
}
|
|
1880
2054
|
await pctx.metaStore.withMetaWriteLock(async () => {
|
|
1881
2055
|
const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
|
|
1882
2056
|
if (!persisted) throw new Error(`[device-manager] setDeviceLinks: unknown device id=${deviceId}`);
|
|
@@ -1947,6 +2121,84 @@ async function setRole(pctx, input) {
|
|
|
1947
2121
|
});
|
|
1948
2122
|
}
|
|
1949
2123
|
/**
|
|
2124
|
+
* Normalize an operator-authored display override at write time so the render
|
|
2125
|
+
* path's `UNIT_TABLE` lookups always hit canonical spellings — maps `unit` and
|
|
2126
|
+
* every `perCap[*].unit` through `normalizeUnit`. Pure: rebuilds new objects,
|
|
2127
|
+
* never mutates the input. A unit `normalizeUnit` cannot canonicalize is left
|
|
2128
|
+
* as-is (the render path refuses conversion for unknown spellings).
|
|
2129
|
+
*/
|
|
2130
|
+
function normalizeDisplayOverride(display) {
|
|
2131
|
+
return {
|
|
2132
|
+
...display,
|
|
2133
|
+
...display.unit !== void 0 ? { unit: (0, _camstack_types.normalizeUnit)(display.unit) ?? display.unit } : {},
|
|
2134
|
+
...display.perCap !== void 0 ? { perCap: Object.fromEntries(Object.entries(display.perCap).map(([cap, refine]) => [cap, refine.unit !== void 0 ? {
|
|
2135
|
+
...refine,
|
|
2136
|
+
unit: (0, _camstack_types.normalizeUnit)(refine.unit) ?? refine.unit
|
|
2137
|
+
} : refine])) } : {}
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Set (or clear) the per-device display override on a device's meta row.
|
|
2142
|
+
* Mirrors `setChildLayout` persistence; `null` REMOVES the `display` key
|
|
2143
|
+
* entirely (immutable rest-destructure — matches the "absent ⇒ no override"
|
|
2144
|
+
* projection contract, never persists `display: undefined`). Override units are
|
|
2145
|
+
* normalized at write so the render path always looks up canonical spellings.
|
|
2146
|
+
*/
|
|
2147
|
+
async function setDisplay(pctx, input) {
|
|
2148
|
+
const { deviceId, display } = input;
|
|
2149
|
+
const normalized = display === null ? null : normalizeDisplayOverride(display);
|
|
2150
|
+
await pctx.metaStore.withMetaWriteLock(async () => {
|
|
2151
|
+
const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
|
|
2152
|
+
if (!persisted) throw new Error(`[device-manager] setDisplay: unknown device id=${deviceId}`);
|
|
2153
|
+
const { meta: m } = persisted;
|
|
2154
|
+
const key = String(deviceId);
|
|
2155
|
+
const allMeta = await pctx.metaStore.readMeta();
|
|
2156
|
+
const nextRow = normalized === null ? (({ display: _drop, ...rest }) => rest)(m) : {
|
|
2157
|
+
...m,
|
|
2158
|
+
display: normalized
|
|
2159
|
+
};
|
|
2160
|
+
await pctx.settings.writeAddonStore({ deviceMeta: {
|
|
2161
|
+
...allMeta,
|
|
2162
|
+
[key]: nextRow
|
|
2163
|
+
} });
|
|
2164
|
+
});
|
|
2165
|
+
pctx.host.ctx.eventBus.emit({
|
|
2166
|
+
id: (0, node_crypto.randomUUID)(),
|
|
2167
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
2168
|
+
source: {
|
|
2169
|
+
type: "device",
|
|
2170
|
+
id: deviceId
|
|
2171
|
+
},
|
|
2172
|
+
category: _camstack_types.EventCategory.DeviceMetaChanged,
|
|
2173
|
+
data: {
|
|
2174
|
+
deviceId,
|
|
2175
|
+
field: "display",
|
|
2176
|
+
value: normalized
|
|
2177
|
+
}
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2181
|
+
* Read the operator-authored per-role display defaults. Empty record when none
|
|
2182
|
+
* set. Not per-device — a plain top-level-key read.
|
|
2183
|
+
*/
|
|
2184
|
+
async function getRoleDisplayDefaults(pctx, _input) {
|
|
2185
|
+
return { defaults: (await pctx.metaStore.readStore()).roleDisplayDefaults ?? {} };
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Replace the per-role display defaults whole-record (full replace). Override
|
|
2189
|
+
* units are normalized (`normalizeUnit`) at write so the render path always
|
|
2190
|
+
* looks up canonical spellings. Single-writer top-level key — no lock, no RMW,
|
|
2191
|
+
* no interaction with the `deviceMeta` write lock. Not per-device, so no event
|
|
2192
|
+
* is emitted; the UI invalidates its own query on mutate.
|
|
2193
|
+
*/
|
|
2194
|
+
async function setRoleDisplayDefaults(pctx, input) {
|
|
2195
|
+
const normalized = Object.fromEntries(Object.entries(input.defaults).map(([role, def]) => [role, def.unit !== void 0 ? {
|
|
2196
|
+
...def,
|
|
2197
|
+
unit: (0, _camstack_types.normalizeUnit)(def.unit) ?? def.unit
|
|
2198
|
+
} : def]));
|
|
2199
|
+
await pctx.settings.writeAddonStore({ roleDisplayDefaults: normalized });
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
1950
2202
|
* Batched meta pre-seed. Applies every provided field to the
|
|
1951
2203
|
* device's meta row in ONE read-modify-write under a single
|
|
1952
2204
|
* `withMetaWriteLock` acquisition (one `deviceMeta` blob write),
|
|
@@ -2191,21 +2443,33 @@ async function removeLocation(pctx, input) {
|
|
|
2191
2443
|
function buildLinkIndexes(entries) {
|
|
2192
2444
|
const targets = /* @__PURE__ */ new Map();
|
|
2193
2445
|
const dependents = /* @__PURE__ */ new Map();
|
|
2194
|
-
for (const { targetDeviceId, link, sourceDeviceId } of entries) {
|
|
2446
|
+
for (const { targetDeviceId, link, sourceDeviceId, bindingSourceIds } of entries) {
|
|
2195
2447
|
const tKey = `${targetDeviceId}:${link.target.cap}`;
|
|
2196
2448
|
const tList = targets.get(tKey) ?? [];
|
|
2197
2449
|
tList.push({
|
|
2198
2450
|
link,
|
|
2199
|
-
sourceDeviceId
|
|
2451
|
+
sourceDeviceId,
|
|
2452
|
+
...bindingSourceIds !== void 0 ? { bindingSourceIds } : {}
|
|
2200
2453
|
});
|
|
2201
2454
|
targets.set(tKey, tList);
|
|
2202
|
-
const
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2455
|
+
const addDependent = (sourceId, sourceCap) => {
|
|
2456
|
+
const sKey = `${sourceId}:${sourceCap}`;
|
|
2457
|
+
const sList = dependents.get(sKey) ?? [];
|
|
2458
|
+
sList.push({
|
|
2459
|
+
targetDeviceId,
|
|
2460
|
+
targetCap: link.target.cap
|
|
2461
|
+
});
|
|
2462
|
+
dependents.set(sKey, sList);
|
|
2463
|
+
};
|
|
2464
|
+
if (link.source.kind === "expression") {
|
|
2465
|
+
if (bindingSourceIds !== void 0) for (const [name, bindingDeviceId] of Object.entries(bindingSourceIds)) {
|
|
2466
|
+
const binding = link.source.bindings[name];
|
|
2467
|
+
if (binding === void 0 || binding.kind === "literal") continue;
|
|
2468
|
+
addDependent(bindingDeviceId, binding.cap);
|
|
2469
|
+
}
|
|
2470
|
+
continue;
|
|
2471
|
+
}
|
|
2472
|
+
if (link.source.kind !== "literal") addDependent(sourceDeviceId, link.source.cap);
|
|
2209
2473
|
}
|
|
2210
2474
|
return {
|
|
2211
2475
|
targets,
|
|
@@ -2320,6 +2584,15 @@ var DeviceMetaStore = class {
|
|
|
2320
2584
|
for (const m of Object.values(meta)) if (m.stableId === wanted) return m.id;
|
|
2321
2585
|
return null;
|
|
2322
2586
|
};
|
|
2587
|
+
/** Resolve a GLOBAL link source (P2e) to a live device id: the device whose
|
|
2588
|
+
* FULL stableId equals `sourceStableId`, regardless of parent container.
|
|
2589
|
+
* First match wins (stableIds are effectively unique cluster-wide — see the
|
|
2590
|
+
* `DeviceLinkGlobalSource` docblock). Null when absent. Pure over a
|
|
2591
|
+
* pre-read meta map so a multi-link resolve reads the store once. */
|
|
2592
|
+
resolveGlobalSourceDeviceId = (sourceStableId, meta) => {
|
|
2593
|
+
for (const m of Object.values(meta)) if (m.stableId === sourceStableId) return m.id;
|
|
2594
|
+
return null;
|
|
2595
|
+
};
|
|
2323
2596
|
allocateNextDeviceId = async () => {
|
|
2324
2597
|
const current = (await this.readStore()).nextDeviceId ?? 1;
|
|
2325
2598
|
await this.settings.writeAddonStore({ nextDeviceId: current + 1 });
|
|
@@ -2350,8 +2623,35 @@ var DeviceMetaStore = class {
|
|
|
2350
2623
|
const container = targetMeta.parentDeviceId !== null ? stableIdById.get(targetMeta.parentDeviceId) ?? stableIdById.get(targetId) : stableIdById.get(targetId);
|
|
2351
2624
|
if (container === void 0) continue;
|
|
2352
2625
|
for (const link of targetMeta.deviceLinks ?? []) {
|
|
2353
|
-
|
|
2354
|
-
|
|
2626
|
+
const src = link.source;
|
|
2627
|
+
if (src.kind === "expression") {
|
|
2628
|
+
const bindingSourceIds = {};
|
|
2629
|
+
for (const [name, b] of Object.entries(src.bindings)) {
|
|
2630
|
+
if (b.kind === "literal") continue;
|
|
2631
|
+
const wanted = b.kind === "global" ? b.sourceStableId : `${container}-${b.sourceKey}`;
|
|
2632
|
+
expectedSources.add(wanted);
|
|
2633
|
+
const bid = idByStableId.get(wanted);
|
|
2634
|
+
if (bid !== void 0) bindingSourceIds[name] = bid;
|
|
2635
|
+
}
|
|
2636
|
+
entries.push({
|
|
2637
|
+
targetDeviceId: targetId,
|
|
2638
|
+
link,
|
|
2639
|
+
sourceDeviceId: -1,
|
|
2640
|
+
bindingSourceIds
|
|
2641
|
+
});
|
|
2642
|
+
continue;
|
|
2643
|
+
}
|
|
2644
|
+
if (src.kind === "literal") {
|
|
2645
|
+
entries.push({
|
|
2646
|
+
targetDeviceId: targetId,
|
|
2647
|
+
link,
|
|
2648
|
+
sourceDeviceId: -1
|
|
2649
|
+
});
|
|
2650
|
+
continue;
|
|
2651
|
+
}
|
|
2652
|
+
const wantedStableId = src.kind === "global" ? src.sourceStableId : `${container}-${src.sourceKey}`;
|
|
2653
|
+
expectedSources.add(wantedStableId);
|
|
2654
|
+
const srcId = idByStableId.get(wantedStableId);
|
|
2355
2655
|
if (srcId === void 0) continue;
|
|
2356
2656
|
entries.push({
|
|
2357
2657
|
targetDeviceId: targetId,
|
|
@@ -2458,6 +2758,7 @@ async function listAll(pctx, input) {
|
|
|
2458
2758
|
...m?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
|
|
2459
2759
|
...m?.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
|
|
2460
2760
|
...m?.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
|
|
2761
|
+
...m?.display !== void 0 ? { display: m.display } : {},
|
|
2461
2762
|
...(() => {
|
|
2462
2763
|
const si = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
|
|
2463
2764
|
return si !== void 0 ? { sourceInfo: si } : {};
|
|
@@ -2506,6 +2807,7 @@ async function getDevice(pctx, input) {
|
|
|
2506
2807
|
...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
|
|
2507
2808
|
...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
|
|
2508
2809
|
...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
|
|
2810
|
+
...m.display !== void 0 ? { display: m.display } : {},
|
|
2509
2811
|
...sourceInfoGetDevice !== void 0 ? { sourceInfo: sourceInfoGetDevice } : {}
|
|
2510
2812
|
};
|
|
2511
2813
|
}
|
|
@@ -2568,6 +2870,7 @@ async function getChildren(pctx, input) {
|
|
|
2568
2870
|
...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
|
|
2569
2871
|
...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
|
|
2570
2872
|
...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
|
|
2873
|
+
...m.display !== void 0 ? { display: m.display } : {},
|
|
2571
2874
|
...sourceInfoChild !== void 0 ? { sourceInfo: sourceInfoChild } : {}
|
|
2572
2875
|
});
|
|
2573
2876
|
}
|
|
@@ -2897,6 +3200,48 @@ async function testField(pctx, input) {
|
|
|
2897
3200
|
function isRecord(x) {
|
|
2898
3201
|
return x !== null && typeof x === "object" && !Array.isArray(x);
|
|
2899
3202
|
}
|
|
3203
|
+
/**
|
|
3204
|
+
* Resolve one link's transformed value. Field sources read via `readField`
|
|
3205
|
+
* (the caller supplies the sibling lookup — sync, e.g. the in-hub state
|
|
3206
|
+
* mirror); literal sources use their per-device constant and never consult
|
|
3207
|
+
* the reader. Returns `undefined` to skip the overlay (unreadable field).
|
|
3208
|
+
* Pure. Async source reads (`resolveLinkedStatus`'s provider `getStatus`
|
|
3209
|
+
* path) cannot use this helper for field sources — they branch on
|
|
3210
|
+
* `source.kind` directly.
|
|
3211
|
+
*/
|
|
3212
|
+
function resolveLinkValue(link, readField) {
|
|
3213
|
+
const src = link.source;
|
|
3214
|
+
if (src.kind === "expression") return void 0;
|
|
3215
|
+
return (0, _camstack_types.applyTransform)(src.kind === "literal" ? src.value : readField(src.cap, src.fieldPath), link.transform);
|
|
3216
|
+
}
|
|
3217
|
+
/**
|
|
3218
|
+
* Sync-channel expression resolution: bindings read from the mirror via
|
|
3219
|
+
* `readField(deviceId, cap, fieldPath)` using the pre-resolved per-binding ids
|
|
3220
|
+
* on the `ResolvedTargetLink`. A literal binding uses its constant; an absent
|
|
3221
|
+
* binding id (dangling source) or a non-primitive read resolves to `null`.
|
|
3222
|
+
* Returns `undefined` on parse/eval failure (skip overlay) — silent by design:
|
|
3223
|
+
* the mirror path is hot and churn-free; the async channel logs. Compile
|
|
3224
|
+
* failures cost one parse ever thanks to the negative LRU compile cache. Pure.
|
|
3225
|
+
*/
|
|
3226
|
+
function resolveExpressionLinkValue(rl, readField, now) {
|
|
3227
|
+
const src = rl.link.source;
|
|
3228
|
+
if (src.kind !== "expression") return void 0;
|
|
3229
|
+
const bindingValues = {};
|
|
3230
|
+
for (const [name, b] of Object.entries(src.bindings)) {
|
|
3231
|
+
if (b.kind === "literal") {
|
|
3232
|
+
bindingValues[name] = b.value;
|
|
3233
|
+
continue;
|
|
3234
|
+
}
|
|
3235
|
+
const bId = rl.bindingSourceIds?.[name];
|
|
3236
|
+
if (bId === void 0) {
|
|
3237
|
+
bindingValues[name] = null;
|
|
3238
|
+
continue;
|
|
3239
|
+
}
|
|
3240
|
+
bindingValues[name] = (0, _camstack_types.toExpressionValue)(readField(bId, b.cap, b.fieldPath)) ?? null;
|
|
3241
|
+
}
|
|
3242
|
+
const result = (0, _camstack_types.evaluateLinkExpression)(src.expr, bindingValues, now);
|
|
3243
|
+
return result.ok ? (0, _camstack_types.applyTransform)(result.value, rl.link.transform) : void 0;
|
|
3244
|
+
}
|
|
2900
3245
|
/** Narrow Zod v4's structural `$ZodType` (returned by `.unwrap()`) back to the
|
|
2901
3246
|
* concrete classic `z.ZodType`. Every runtime schema is a `z.ZodType`, so this
|
|
2902
3247
|
* is a true `instanceof` guard rather than a cast. */
|
|
@@ -2928,19 +3273,88 @@ function fillNullableDefaults(schema, value) {
|
|
|
2928
3273
|
return out;
|
|
2929
3274
|
}
|
|
2930
3275
|
/**
|
|
3276
|
+
* Group item-targeted links (`target.itemKey` set) by itemKey and upsert each
|
|
3277
|
+
* group as ONE item into the status' item array (see
|
|
3278
|
+
* `CapabilityStatusItemArray`). A NEW item is seeded from `emptyItem` with
|
|
3279
|
+
* `keyField` (and `labelField`, when declared) set to the itemKey; an
|
|
3280
|
+
* EXISTING item (matched by `keyField`) is cloned and overlaid in place, so
|
|
3281
|
+
* native fields a link doesn't touch are preserved. Each upserted item is
|
|
3282
|
+
* validated against `itemSchema` — an invalid item is skipped WITHOUT
|
|
3283
|
+
* discarding its valid siblings. Returns the new draft and whether anything
|
|
3284
|
+
* was applied. Pure + immutable (base array/items are never mutated).
|
|
3285
|
+
*/
|
|
3286
|
+
function upsertItemArrayLinks(draft, grouped, itemArray) {
|
|
3287
|
+
const arrRaw = (0, _camstack_types.getByPath)(draft, itemArray.path);
|
|
3288
|
+
const arr = Array.isArray(arrRaw) ? [...arrRaw] : [];
|
|
3289
|
+
let touched = false;
|
|
3290
|
+
for (const [itemKey, group] of grouped) {
|
|
3291
|
+
const idx = arr.findIndex((el) => isRecord(el) && el[itemArray.keyField] === itemKey);
|
|
3292
|
+
const existing = idx >= 0 ? arr[idx] : null;
|
|
3293
|
+
let item = isRecord(existing) ? { ...existing } : {
|
|
3294
|
+
...itemArray.emptyItem,
|
|
3295
|
+
[itemArray.keyField]: itemKey,
|
|
3296
|
+
...itemArray.labelField !== void 0 ? { [itemArray.labelField]: itemKey } : {}
|
|
3297
|
+
};
|
|
3298
|
+
let itemTouched = false;
|
|
3299
|
+
for (const { link, sourceValue } of group) {
|
|
3300
|
+
if (sourceValue === void 0) continue;
|
|
3301
|
+
item = (0, _camstack_types.setByPath)(item, link.target.fieldPath, sourceValue);
|
|
3302
|
+
itemTouched = true;
|
|
3303
|
+
}
|
|
3304
|
+
if (!itemTouched) continue;
|
|
3305
|
+
const repaired = fillNullableDefaults(itemArray.itemSchema, item);
|
|
3306
|
+
const parsed = itemArray.itemSchema.safeParse(repaired);
|
|
3307
|
+
if (!parsed.success) continue;
|
|
3308
|
+
if (isRecord(parsed.data)) item = parsed.data;
|
|
3309
|
+
if (idx >= 0) arr[idx] = item;
|
|
3310
|
+
else arr.push(item);
|
|
3311
|
+
touched = true;
|
|
3312
|
+
}
|
|
3313
|
+
if (!touched) return {
|
|
3314
|
+
draft,
|
|
3315
|
+
touched: false
|
|
3316
|
+
};
|
|
3317
|
+
return {
|
|
3318
|
+
draft: (0, _camstack_types.setByPath)(draft, itemArray.path, arr),
|
|
3319
|
+
touched: true
|
|
3320
|
+
};
|
|
3321
|
+
}
|
|
3322
|
+
/**
|
|
2931
3323
|
* Overlay transformed source values onto `base` by dot-path, then validate the
|
|
2932
3324
|
* result against the target cap's `statusSchema`. On validation failure the
|
|
2933
3325
|
* overlay is discarded and `base` is returned unchanged (a misconfigured link
|
|
2934
3326
|
* must never corrupt a cap response). Pure — all I/O happens in the caller.
|
|
3327
|
+
*
|
|
3328
|
+
* Item-array grouping (P2b): when the cap declares `status.itemArray`, links
|
|
3329
|
+
* carrying a `target.itemKey` are grouped per key and upserted as items into
|
|
3330
|
+
* the array (see `upsertItemArrayLinks`); their `fieldPath` is relative to
|
|
3331
|
+
* ONE item ('level', 'status', 'label'). Links WITHOUT an itemKey keep the
|
|
3332
|
+
* scalar dot-path behavior unchanged. A link with an itemKey on a cap that
|
|
3333
|
+
* declares NO `itemArray` is dropped (its item-relative path must never be
|
|
3334
|
+
* scalar-applied to the status root).
|
|
2935
3335
|
*/
|
|
2936
|
-
function mergeLinkedStatus(base, resolved, statusSchema) {
|
|
3336
|
+
function mergeLinkedStatus(base, resolved, statusSchema, itemArray) {
|
|
2937
3337
|
let draft = base;
|
|
2938
3338
|
let touched = false;
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
3339
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
3340
|
+
for (const entry of resolved) {
|
|
3341
|
+
const itemKey = entry.link.target.itemKey;
|
|
3342
|
+
if (itemKey !== void 0) {
|
|
3343
|
+
if (!itemArray) continue;
|
|
3344
|
+
const group = grouped.get(itemKey);
|
|
3345
|
+
if (group) group.push(entry);
|
|
3346
|
+
else grouped.set(itemKey, [entry]);
|
|
3347
|
+
continue;
|
|
3348
|
+
}
|
|
3349
|
+
if (entry.sourceValue === void 0) continue;
|
|
3350
|
+
draft = (0, _camstack_types.setByPath)(draft, entry.link.target.fieldPath, entry.sourceValue);
|
|
2942
3351
|
touched = true;
|
|
2943
3352
|
}
|
|
3353
|
+
if (itemArray && grouped.size > 0) {
|
|
3354
|
+
const upserted = upsertItemArrayLinks(draft, grouped, itemArray);
|
|
3355
|
+
draft = upserted.draft;
|
|
3356
|
+
touched = touched || upserted.touched;
|
|
3357
|
+
}
|
|
2944
3358
|
if (!touched) return base;
|
|
2945
3359
|
if (!statusSchema) return draft;
|
|
2946
3360
|
const repaired = fillNullableDefaults(statusSchema, draft);
|
|
@@ -3137,43 +3551,63 @@ var DeviceStateMirror = class DeviceStateMirror {
|
|
|
3137
3551
|
* Read-time overlay of a cap slice with its cross-device linked values.
|
|
3138
3552
|
* Returns a cloned raw mirror slice when the (device, cap) pair has no
|
|
3139
3553
|
* links. Sources are read from the same in-hub stateMirror — sync, no
|
|
3140
|
-
* cross-process call.
|
|
3141
|
-
*
|
|
3554
|
+
* cross-process call. When the raw slice is ABSENT but the cap is a link
|
|
3555
|
+
* target, the slice is SYNTHESIZED: the merge base is seeded from the
|
|
3556
|
+
* cap's declared `status.empty` default; a failed/empty synthesize
|
|
3557
|
+
* returns null (no phantom slices). The disk writer must NOT use this
|
|
3558
|
+
* method; it must persist raw provider truth via snapshotForDevice.
|
|
3142
3559
|
*/
|
|
3143
3560
|
overlayedSlice(deviceId, cap) {
|
|
3144
3561
|
const raw = this.stateMirror.get(deviceId)?.get(cap) ?? null;
|
|
3145
3562
|
const links = this.linkHost.linkTargets.get(`${deviceId}:${cap}`);
|
|
3146
3563
|
if (!links || links.length === 0) return raw ? { ...raw } : null;
|
|
3564
|
+
const now = Date.now();
|
|
3147
3565
|
const resolved = links.map((rl) => ({
|
|
3148
3566
|
link: rl.link,
|
|
3149
|
-
sourceValue: (
|
|
3567
|
+
sourceValue: rl.link.source.kind === "expression" ? resolveExpressionLinkValue(rl, (srcDeviceId, srcCap, fieldPath) => (0, _camstack_types.getByPath)(this.stateMirror.get(srcDeviceId)?.get(srcCap), fieldPath), now) : resolveLinkValue(rl.link, (srcCap, fieldPath) => (0, _camstack_types.getByPath)(this.stateMirror.get(rl.sourceDeviceId)?.get(srcCap), fieldPath))
|
|
3150
3568
|
}));
|
|
3151
|
-
const
|
|
3152
|
-
|
|
3569
|
+
const capStatus = this.linkHost.capabilityRegistry?.getDefinition(cap)?.status;
|
|
3570
|
+
const schema = capStatus?.schema;
|
|
3571
|
+
const empty = capStatus?.empty;
|
|
3572
|
+
const base = raw ? { ...raw } : empty !== null && typeof empty === "object" && !Array.isArray(empty) ? { ...empty } : {};
|
|
3573
|
+
const merged = mergeLinkedStatus(base, resolved, schema, capStatus?.itemArray);
|
|
3574
|
+
if (!raw && merged === base) return null;
|
|
3575
|
+
return merged;
|
|
3153
3576
|
}
|
|
3154
3577
|
/**
|
|
3155
3578
|
* Like snapshotForDevice but applies the device-link overlay per cap.
|
|
3156
3579
|
* Used exclusively by the device-state READ methods (getSnapshot,
|
|
3157
|
-
* getAllSnapshots) so callers see overlayed values.
|
|
3158
|
-
*
|
|
3580
|
+
* getAllSnapshots) so callers see overlayed values. SYNTHESIZE-only
|
|
3581
|
+
* link-target caps (no base mirror slice) are unioned into the iterated
|
|
3582
|
+
* key set so they appear at warm-load — `overlayedSlice` builds them from
|
|
3583
|
+
* the cap's `status.empty` (and drops them when nothing resolves). The
|
|
3584
|
+
* debounced disk writer must continue to call snapshotForDevice (raw truth).
|
|
3159
3585
|
*/
|
|
3160
3586
|
snapshotForDeviceOverlayed(deviceId) {
|
|
3161
3587
|
const perCap = this.stateMirror.get(deviceId);
|
|
3162
|
-
|
|
3588
|
+
const capNames = new Set(perCap ? perCap.keys() : []);
|
|
3589
|
+
const prefix = `${deviceId}:`;
|
|
3590
|
+
for (const key of this.linkHost.linkTargets.keys()) if (key.startsWith(prefix)) capNames.add(key.slice(prefix.length));
|
|
3163
3591
|
const out = {};
|
|
3164
|
-
for (const capName of
|
|
3592
|
+
for (const capName of capNames) {
|
|
3165
3593
|
const s = this.overlayedSlice(deviceId, capName);
|
|
3166
3594
|
if (s) out[capName] = s;
|
|
3167
3595
|
}
|
|
3168
3596
|
return out;
|
|
3169
3597
|
}
|
|
3170
|
-
/** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`.
|
|
3598
|
+
/** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`.
|
|
3599
|
+
* Devices that only exist as link TARGETS (no mirror slice yet) are
|
|
3600
|
+
* included via the link reverse-index so their synthesized caps appear. */
|
|
3171
3601
|
allSnapshotsOverlayed() {
|
|
3602
|
+
const deviceIds = new Set(this.stateMirror.keys());
|
|
3603
|
+
for (const key of this.linkHost.linkTargets.keys()) {
|
|
3604
|
+
const id = Number(key.slice(0, key.indexOf(":")));
|
|
3605
|
+
if (Number.isFinite(id)) deviceIds.add(id);
|
|
3606
|
+
}
|
|
3172
3607
|
const out = {};
|
|
3173
|
-
for (const
|
|
3174
|
-
const dev =
|
|
3175
|
-
|
|
3176
|
-
out[String(deviceId)] = dev;
|
|
3608
|
+
for (const deviceId of deviceIds) {
|
|
3609
|
+
const dev = this.snapshotForDeviceOverlayed(deviceId);
|
|
3610
|
+
if (this.stateMirror.has(deviceId) || Object.keys(dev).length > 0) out[String(deviceId)] = dev;
|
|
3177
3611
|
}
|
|
3178
3612
|
return out;
|
|
3179
3613
|
}
|
|
@@ -3292,14 +3726,27 @@ function resolveNativeCapOwnerSync(pctx, capName, deviceId) {
|
|
|
3292
3726
|
* `getProviderForDevice` (routes cross-process); merge is pure.
|
|
3293
3727
|
*/
|
|
3294
3728
|
async function resolveLinkedStatus(pctx, input) {
|
|
3295
|
-
const { deviceId, cap
|
|
3729
|
+
const { deviceId, cap } = input;
|
|
3296
3730
|
if (!pctx.host.devicesWithLinks.has(deviceId)) return null;
|
|
3731
|
+
const inFlightKey = `${deviceId}:${cap}`;
|
|
3732
|
+
if (pctx.host.linkResolveInFlight.has(inFlightKey)) return null;
|
|
3733
|
+
pctx.host.linkResolveInFlight.add(inFlightKey);
|
|
3734
|
+
try {
|
|
3735
|
+
return await resolveLinkedStatusInner(pctx, input);
|
|
3736
|
+
} finally {
|
|
3737
|
+
pctx.host.linkResolveInFlight.delete(inFlightKey);
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
/** Body of `resolveLinkedStatus` — see the wrapper for the re-entrancy guard. */
|
|
3741
|
+
async function resolveLinkedStatusInner(pctx, input) {
|
|
3742
|
+
const { deviceId, cap, baseStatus } = input;
|
|
3297
3743
|
const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
|
|
3298
3744
|
if (!persisted) return null;
|
|
3299
3745
|
const links = (persisted.meta.deviceLinks ?? []).filter((l) => l.target.cap === cap);
|
|
3300
3746
|
if (links.length === 0) return null;
|
|
3301
3747
|
const capRegistry = pctx.host.capabilityRegistry;
|
|
3302
|
-
const
|
|
3748
|
+
const capStatus = capRegistry?.getDefinition(cap)?.status;
|
|
3749
|
+
const schema = capStatus?.schema;
|
|
3303
3750
|
if (!schema) return null;
|
|
3304
3751
|
const allMeta = await pctx.metaStore.readMeta();
|
|
3305
3752
|
let containerStableId = persisted.stableId;
|
|
@@ -3308,32 +3755,96 @@ async function resolveLinkedStatus(pctx, input) {
|
|
|
3308
3755
|
const parentMeta = allMeta[String(parentId)];
|
|
3309
3756
|
if (parentMeta) containerStableId = parentMeta.stableId;
|
|
3310
3757
|
}
|
|
3758
|
+
const now = Date.now();
|
|
3759
|
+
const statusMemo = /* @__PURE__ */ new Map();
|
|
3760
|
+
const readSourceStatus = async (srcId, srcCap) => {
|
|
3761
|
+
const key = `${srcId}:${srcCap}`;
|
|
3762
|
+
const cached = statusMemo.get(key);
|
|
3763
|
+
if (cached !== void 0) return cached;
|
|
3764
|
+
let result;
|
|
3765
|
+
try {
|
|
3766
|
+
const srcProvider = capRegistry?.getProviderForDevice(srcCap, srcId);
|
|
3767
|
+
result = {
|
|
3768
|
+
status: typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0,
|
|
3769
|
+
ok: true
|
|
3770
|
+
};
|
|
3771
|
+
} catch (err) {
|
|
3772
|
+
pctx.host.ctx.logger.warn("resolveLinkedStatus: source read failed", {
|
|
3773
|
+
tags: {
|
|
3774
|
+
deviceId,
|
|
3775
|
+
capName: cap
|
|
3776
|
+
},
|
|
3777
|
+
meta: {
|
|
3778
|
+
sourceCap: srcCap,
|
|
3779
|
+
sourceId: srcId,
|
|
3780
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3781
|
+
}
|
|
3782
|
+
});
|
|
3783
|
+
result = {
|
|
3784
|
+
status: void 0,
|
|
3785
|
+
ok: false
|
|
3786
|
+
};
|
|
3787
|
+
}
|
|
3788
|
+
statusMemo.set(key, result);
|
|
3789
|
+
return result;
|
|
3790
|
+
};
|
|
3311
3791
|
const resolved = [];
|
|
3312
3792
|
for (const link of links) {
|
|
3313
|
-
const
|
|
3314
|
-
if (
|
|
3315
|
-
try {
|
|
3316
|
-
const srcProvider = capRegistry?.getProviderForDevice(link.source.cap, srcId);
|
|
3317
|
-
const raw = (0, _camstack_types.getByPath)(typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0, link.source.fieldPath);
|
|
3793
|
+
const src = link.source;
|
|
3794
|
+
if (src.kind === "literal") {
|
|
3318
3795
|
resolved.push({
|
|
3319
3796
|
link,
|
|
3320
|
-
sourceValue: (0, _camstack_types.applyTransform)(
|
|
3797
|
+
sourceValue: (0, _camstack_types.applyTransform)(src.value, link.transform)
|
|
3321
3798
|
});
|
|
3322
|
-
|
|
3323
|
-
|
|
3799
|
+
continue;
|
|
3800
|
+
}
|
|
3801
|
+
if (src.kind === "expression") {
|
|
3802
|
+
const bindingValues = {};
|
|
3803
|
+
for (const [name, b] of Object.entries(src.bindings)) {
|
|
3804
|
+
if (b.kind === "literal") {
|
|
3805
|
+
bindingValues[name] = b.value;
|
|
3806
|
+
continue;
|
|
3807
|
+
}
|
|
3808
|
+
const bId = b.kind === "global" ? pctx.metaStore.resolveGlobalSourceDeviceId(b.sourceStableId, allMeta) : pctx.metaStore.resolveSourceDeviceId(containerStableId, b.sourceKey, allMeta);
|
|
3809
|
+
if (bId === null) {
|
|
3810
|
+
bindingValues[name] = null;
|
|
3811
|
+
continue;
|
|
3812
|
+
}
|
|
3813
|
+
bindingValues[name] = (0, _camstack_types.toExpressionValue)((0, _camstack_types.getByPath)((await readSourceStatus(bId, b.cap)).status, b.fieldPath)) ?? null;
|
|
3814
|
+
}
|
|
3815
|
+
const result = (0, _camstack_types.evaluateLinkExpression)(src.expr, bindingValues, now);
|
|
3816
|
+
if (result.ok) resolved.push({
|
|
3817
|
+
link,
|
|
3818
|
+
sourceValue: (0, _camstack_types.applyTransform)(result.value, link.transform)
|
|
3819
|
+
});
|
|
3820
|
+
else pctx.host.ctx.logger.warn("resolveLinkedStatus: expression skipped", {
|
|
3324
3821
|
tags: {
|
|
3325
3822
|
deviceId,
|
|
3326
3823
|
capName: cap
|
|
3327
3824
|
},
|
|
3328
3825
|
meta: {
|
|
3329
|
-
|
|
3330
|
-
error:
|
|
3826
|
+
linkId: link.id,
|
|
3827
|
+
error: result.error
|
|
3331
3828
|
}
|
|
3332
3829
|
});
|
|
3830
|
+
continue;
|
|
3333
3831
|
}
|
|
3832
|
+
const srcId = src.kind === "global" ? pctx.metaStore.resolveGlobalSourceDeviceId(src.sourceStableId, allMeta) : pctx.metaStore.resolveSourceDeviceId(containerStableId, src.sourceKey, allMeta);
|
|
3833
|
+
if (srcId === null) continue;
|
|
3834
|
+
const read = await readSourceStatus(srcId, src.cap);
|
|
3835
|
+
if (!read.ok) continue;
|
|
3836
|
+
const raw = (0, _camstack_types.getByPath)(read.status, src.fieldPath);
|
|
3837
|
+
resolved.push({
|
|
3838
|
+
link,
|
|
3839
|
+
sourceValue: (0, _camstack_types.applyTransform)(raw, link.transform)
|
|
3840
|
+
});
|
|
3334
3841
|
}
|
|
3335
3842
|
if (resolved.length === 0) return null;
|
|
3336
|
-
|
|
3843
|
+
const synthesizing = !isRecord$1(baseStatus);
|
|
3844
|
+
const base = isRecord$1(baseStatus) ? baseStatus : isRecord$1(capStatus.empty) ? { ...capStatus.empty } : {};
|
|
3845
|
+
const merged = mergeLinkedStatus(base, resolved, schema, capStatus.itemArray);
|
|
3846
|
+
if (synthesizing && merged === base) return null;
|
|
3847
|
+
return merged;
|
|
3337
3848
|
}
|
|
3338
3849
|
//#endregion
|
|
3339
3850
|
//#region src/builtins/device-manager/device-manager.addon.ts
|
|
@@ -3399,10 +3910,15 @@ var DeviceManagerAddon = class extends _camstack_types.BaseAddon {
|
|
|
3399
3910
|
linkTargets = /* @__PURE__ */ new Map();
|
|
3400
3911
|
/** `${sourceDeviceId}:${sourceCap}` → targets to recompute when that source changes. */
|
|
3401
3912
|
linkDependents = /* @__PURE__ */ new Map();
|
|
3402
|
-
/** Expected source `stableId`s (`${container}-${sourceKey}`
|
|
3913
|
+
/** Expected source `stableId`s (`${container}-${sourceKey}` for sibling
|
|
3914
|
+
* sources, the full `sourceStableId` for global sources) across all links,
|
|
3403
3915
|
* resolved or not — gates the `registerDevice` rebuild so only a registering
|
|
3404
3916
|
* device that IS a link source triggers a reindex (not every boot restore). */
|
|
3405
3917
|
expectedSourceStableIds = /* @__PURE__ */ new Set();
|
|
3918
|
+
/** In-flight `${deviceId}:${cap}` pairs of `resolveLinkedStatus` — the P2e
|
|
3919
|
+
* defensive re-entrancy guard bounding link cycles at resolve time. Mutated
|
|
3920
|
+
* in place (never reassigned), so the host exposes a direct reference. */
|
|
3921
|
+
linkResolveInFlight = /* @__PURE__ */ new Set();
|
|
3406
3922
|
/** Test/diagnostic accessors. */
|
|
3407
3923
|
linkTargetKeys() {
|
|
3408
3924
|
return [...this.linkTargets.keys()];
|
|
@@ -3474,6 +3990,7 @@ var DeviceManagerAddon = class extends _camstack_types.BaseAddon {
|
|
|
3474
3990
|
get expectedSourceStableIds() {
|
|
3475
3991
|
return expectedSourceStableIds();
|
|
3476
3992
|
},
|
|
3993
|
+
linkResolveInFlight: this.linkResolveInFlight,
|
|
3477
3994
|
dropDeviceOverlays: (deviceId) => this.stateMirror.dropDeviceOverlays(deviceId),
|
|
3478
3995
|
remoteNativeCaps: this.remoteNativeCaps,
|
|
3479
3996
|
seedMirror: (deviceId, blob) => this.stateMirror.seedMirror(deviceId, blob),
|
|
@@ -3522,12 +4039,15 @@ var DeviceManagerAddon = class extends _camstack_types.BaseAddon {
|
|
|
3522
4039
|
}
|
|
3523
4040
|
}))).filter((id) => id !== null);
|
|
3524
4041
|
}
|
|
3525
|
-
/** Build the dependency context the extracted binding resolvers consume.
|
|
4042
|
+
/** Build the dependency context the extracted binding resolvers consume.
|
|
4043
|
+
* `devicesWithLinks` is mutated in place (never reassigned) so the direct
|
|
4044
|
+
* reference stays live — it gates the virtual `linked` binding step. */
|
|
3526
4045
|
get bindingsDeps() {
|
|
3527
4046
|
return {
|
|
3528
4047
|
ctx: this.ctx,
|
|
3529
4048
|
capabilityRegistry: this.capabilityRegistry,
|
|
3530
|
-
remoteNativeCaps: this.remoteNativeCaps
|
|
4049
|
+
remoteNativeCaps: this.remoteNativeCaps,
|
|
4050
|
+
devicesWithLinks: this.devicesWithLinks
|
|
3531
4051
|
};
|
|
3532
4052
|
}
|
|
3533
4053
|
async getBindings(input) {
|
|
@@ -3630,6 +4150,9 @@ var DeviceManagerAddon = class extends _camstack_types.BaseAddon {
|
|
|
3630
4150
|
setChildLayout: (input) => setChildLayout(pctx, input),
|
|
3631
4151
|
setDeviceLinks: (input) => setDeviceLinks(pctx, input),
|
|
3632
4152
|
setRole: (input) => setRole(pctx, input),
|
|
4153
|
+
setDisplay: (input) => setDisplay(pctx, input),
|
|
4154
|
+
getRoleDisplayDefaults: (input) => getRoleDisplayDefaults(pctx, input),
|
|
4155
|
+
setRoleDisplayDefaults: (input) => setRoleDisplayDefaults(pctx, input),
|
|
3633
4156
|
applyInitialMeta: (input) => applyInitialMeta(pctx, input),
|
|
3634
4157
|
setMetadata: (input) => setMetadata(pctx, input),
|
|
3635
4158
|
setDisabled: (input) => setDisabled(pctx, input),
|