@camstack/system 1.1.22 → 1.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builtins/device-manager/device-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-resolver.d.ts +20 -2
- package/dist/builtins/device-manager/device-manager.addon.d.ts +9 -2
- package/dist/builtins/device-manager/device-manager.addon.js +353 -52
- package/dist/builtins/device-manager/device-manager.addon.mjs +354 -53
- package/dist/builtins/device-manager/device-meta-store.d.ts +6 -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/package.json +1 -1
|
@@ -509,6 +509,21 @@ async function getBindings(deps, input) {
|
|
|
509
509
|
});
|
|
510
510
|
seenCaps.add(entry.capName);
|
|
511
511
|
}
|
|
512
|
+
if (deps.devicesWithLinks?.has(input.deviceId)) {
|
|
513
|
+
const row = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(input.deviceId)];
|
|
514
|
+
for (const link of row?.deviceLinks ?? []) {
|
|
515
|
+
const capName = link.target.cap;
|
|
516
|
+
if (seenCaps.has(capName)) continue;
|
|
517
|
+
entries.push({
|
|
518
|
+
capName,
|
|
519
|
+
kind: "linked",
|
|
520
|
+
providerAddonId: deps.ctx.id,
|
|
521
|
+
providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
|
|
522
|
+
nativeAddonId: ""
|
|
523
|
+
});
|
|
524
|
+
seenCaps.add(capName);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
512
527
|
return {
|
|
513
528
|
deviceId: input.deviceId,
|
|
514
529
|
entries
|
|
@@ -1171,11 +1186,47 @@ async function setWrapperActive(deps, input) {
|
|
|
1171
1186
|
});
|
|
1172
1187
|
}
|
|
1173
1188
|
/**
|
|
1189
|
+
* Build the wireable-cap entry for one capability definition: the status
|
|
1190
|
+
* schema's scalar leaf fields plus — for an item-array cap
|
|
1191
|
+
* (`status.itemArray`) — the per-item fields tagged `item: true` (a link
|
|
1192
|
+
* targeting one must carry a `target.itemKey`) and the cap-level `itemArray`
|
|
1193
|
+
* descriptor so the UI can address items. Returns null when the cap exposes
|
|
1194
|
+
* nothing wireable (no status schema / no leaf fields).
|
|
1195
|
+
*/
|
|
1196
|
+
function wireableEntryForDef(capName, def) {
|
|
1197
|
+
const status = def.status;
|
|
1198
|
+
const schema = status?.schema;
|
|
1199
|
+
if (!schema) return null;
|
|
1200
|
+
const itemArray = status.itemArray;
|
|
1201
|
+
const fields = [...(0, _camstack_types.enumerateSchemaFields)(schema), ...itemArray ? (0, _camstack_types.enumerateItemArrayFields)(itemArray) : []].map((f) => ({
|
|
1202
|
+
path: f.path,
|
|
1203
|
+
kind: f.kind,
|
|
1204
|
+
...f.enumValues !== void 0 ? { enumValues: [...f.enumValues] } : {},
|
|
1205
|
+
...f.item === true ? { item: true } : {}
|
|
1206
|
+
}));
|
|
1207
|
+
if (fields.length === 0) return null;
|
|
1208
|
+
return {
|
|
1209
|
+
cap: capName,
|
|
1210
|
+
fields,
|
|
1211
|
+
...itemArray ? { itemArray: {
|
|
1212
|
+
path: itemArray.path,
|
|
1213
|
+
keyField: itemArray.keyField
|
|
1214
|
+
} } : {}
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1174
1218
|
* Per-device wireable-field catalog — the domain status fields a device's
|
|
1175
1219
|
* bound caps expose, for the operator's cross-device wiring UI. Binding-driven:
|
|
1176
1220
|
* walks `getBindings(deviceId)`, skips wrapper caps (their status schemas are
|
|
1177
1221
|
* internal book-keeping, not wireable domain data), and enumerates each cap's
|
|
1178
|
-
* status schema leaf fields
|
|
1222
|
+
* status schema leaf fields (per-item fields included for item-array caps).
|
|
1223
|
+
*
|
|
1224
|
+
* `includeSynthesizable: true` (TARGET pickers only) additionally unions in
|
|
1225
|
+
* UNBOUND device-scoped caps that declare `status.empty` (the synthesize-target
|
|
1226
|
+
* marker) and whose `deviceTypes` admit this device's persisted type — so the
|
|
1227
|
+
* FIRST link to a synthesize-only cap (consumables on an HA vacuum) can be
|
|
1228
|
+
* authored before any binding exists. Default (absent/false) behavior is
|
|
1229
|
+
* byte-identical to the binding-driven catalog.
|
|
1179
1230
|
*/
|
|
1180
1231
|
async function getWireableFields(deps, input) {
|
|
1181
1232
|
const { deviceId } = input;
|
|
@@ -1189,20 +1240,20 @@ async function getWireableFields(deps, input) {
|
|
|
1189
1240
|
seen.add(entry.capName);
|
|
1190
1241
|
const def = reg.getDefinition(entry.capName);
|
|
1191
1242
|
if (!def || def.kind === "wrapper") continue;
|
|
1192
|
-
const
|
|
1193
|
-
if (
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
}
|
|
1243
|
+
const wireable = wireableEntryForDef(entry.capName, def);
|
|
1244
|
+
if (wireable) caps.push(wireable);
|
|
1245
|
+
}
|
|
1246
|
+
if (input.includeSynthesizable === true) {
|
|
1247
|
+
const deviceType = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.type;
|
|
1248
|
+
if (deviceType !== void 0) for (const def of _camstack_types.ALL_CAPABILITY_DEFINITIONS) {
|
|
1249
|
+
if (seen.has(def.name)) continue;
|
|
1250
|
+
if (def.scope !== "device" || def.kind === "wrapper") continue;
|
|
1251
|
+
if (def.status?.empty === void 0) continue;
|
|
1252
|
+
if (def.deviceTypes && !def.deviceTypes.some((t) => t === deviceType)) continue;
|
|
1253
|
+
seen.add(def.name);
|
|
1254
|
+
const wireable = wireableEntryForDef(def.name, def);
|
|
1255
|
+
if (wireable) caps.push(wireable);
|
|
1256
|
+
}
|
|
1206
1257
|
}
|
|
1207
1258
|
return { caps };
|
|
1208
1259
|
}
|
|
@@ -1316,6 +1367,99 @@ var DeviceEventPropagator = class {
|
|
|
1316
1367
|
}
|
|
1317
1368
|
};
|
|
1318
1369
|
//#endregion
|
|
1370
|
+
//#region src/builtins/device-manager/device-link-cycle.ts
|
|
1371
|
+
function nodeKey(deviceId, cap) {
|
|
1372
|
+
return `${deviceId}:${cap}`;
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Resolve a link source to its `(deviceId, cap)` node, or null when the
|
|
1376
|
+
* source is a literal (no device) or does not resolve to a known device.
|
|
1377
|
+
* Sibling FIELD sources resolve against the TARGET's container stableId
|
|
1378
|
+
* (its parent's, falling back to its own for a top-level target) — the same
|
|
1379
|
+
* rule `rebuildLinkDependents` / `resolveLinkedStatus` apply.
|
|
1380
|
+
*/
|
|
1381
|
+
function resolveSourceNode(link, targetRow, rowById, idByStableId) {
|
|
1382
|
+
const src = link.source;
|
|
1383
|
+
if (src.kind === "literal") return null;
|
|
1384
|
+
const wantedStableId = src.kind === "global" ? src.sourceStableId : `${targetRow.parentDeviceId !== null ? rowById.get(targetRow.parentDeviceId)?.stableId ?? targetRow.stableId : targetRow.stableId}-${src.sourceKey}`;
|
|
1385
|
+
const srcId = idByStableId.get(wantedStableId);
|
|
1386
|
+
if (srcId === void 0) return null;
|
|
1387
|
+
return nodeKey(srcId, src.cap);
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Detect whether replacing `editedDeviceId`'s links with `editedLinks` closes
|
|
1391
|
+
* a dependency cycle. Returns the cycle as an ordered list of node keys
|
|
1392
|
+
* (first node repeated at the end) or null when the set is safe.
|
|
1393
|
+
*/
|
|
1394
|
+
function findDeviceLinkCycle(allMeta, editedDeviceId, editedLinks) {
|
|
1395
|
+
const rows = Object.values(allMeta);
|
|
1396
|
+
const rowById = /* @__PURE__ */ new Map();
|
|
1397
|
+
const idByStableId = /* @__PURE__ */ new Map();
|
|
1398
|
+
for (const r of rows) {
|
|
1399
|
+
rowById.set(r.id, r);
|
|
1400
|
+
if (!idByStableId.has(r.stableId)) idByStableId.set(r.stableId, r.id);
|
|
1401
|
+
}
|
|
1402
|
+
const dependsOn = /* @__PURE__ */ new Map();
|
|
1403
|
+
const addEdges = (row, links) => {
|
|
1404
|
+
for (const link of links) {
|
|
1405
|
+
const srcNode = resolveSourceNode(link, row, rowById, idByStableId);
|
|
1406
|
+
if (srcNode === null) continue;
|
|
1407
|
+
const tNode = nodeKey(row.id, link.target.cap);
|
|
1408
|
+
const list = dependsOn.get(tNode) ?? [];
|
|
1409
|
+
list.push(srcNode);
|
|
1410
|
+
dependsOn.set(tNode, list);
|
|
1411
|
+
}
|
|
1412
|
+
};
|
|
1413
|
+
for (const r of rows) {
|
|
1414
|
+
const links = r.id === editedDeviceId ? editedLinks : r.deviceLinks ?? [];
|
|
1415
|
+
if (links.length > 0) addEdges(r, links);
|
|
1416
|
+
}
|
|
1417
|
+
const roots = [...new Set(editedLinks.map((l) => nodeKey(editedDeviceId, l.target.cap)))];
|
|
1418
|
+
for (const root of roots) {
|
|
1419
|
+
const cycle = dfsFindCycle(root, dependsOn);
|
|
1420
|
+
if (cycle) return cycle;
|
|
1421
|
+
}
|
|
1422
|
+
return null;
|
|
1423
|
+
}
|
|
1424
|
+
/** Iterative DFS from `root` over `dependsOn`; returns the first back-edge
|
|
1425
|
+
* cycle path (closed — first node repeated last) or null. */
|
|
1426
|
+
function dfsFindCycle(root, dependsOn) {
|
|
1427
|
+
const onPath = /* @__PURE__ */ new Set();
|
|
1428
|
+
const done = /* @__PURE__ */ new Set();
|
|
1429
|
+
const path = [];
|
|
1430
|
+
const stack = [{
|
|
1431
|
+
node: root,
|
|
1432
|
+
nextChild: 0
|
|
1433
|
+
}];
|
|
1434
|
+
onPath.add(root);
|
|
1435
|
+
path.push(root);
|
|
1436
|
+
while (stack.length > 0) {
|
|
1437
|
+
const frame = stack[stack.length - 1];
|
|
1438
|
+
const children = dependsOn.get(frame.node) ?? [];
|
|
1439
|
+
if (frame.nextChild < children.length) {
|
|
1440
|
+
const child = children[frame.nextChild];
|
|
1441
|
+
frame.nextChild += 1;
|
|
1442
|
+
if (onPath.has(child)) {
|
|
1443
|
+
const start = path.indexOf(child);
|
|
1444
|
+
return [...path.slice(start), child];
|
|
1445
|
+
}
|
|
1446
|
+
if (done.has(child)) continue;
|
|
1447
|
+
stack.push({
|
|
1448
|
+
node: child,
|
|
1449
|
+
nextChild: 0
|
|
1450
|
+
});
|
|
1451
|
+
onPath.add(child);
|
|
1452
|
+
path.push(child);
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
stack.pop();
|
|
1456
|
+
onPath.delete(frame.node);
|
|
1457
|
+
path.pop();
|
|
1458
|
+
done.add(frame.node);
|
|
1459
|
+
}
|
|
1460
|
+
return null;
|
|
1461
|
+
}
|
|
1462
|
+
//#endregion
|
|
1319
1463
|
//#region src/builtins/device-manager/device-meta-actions.ts
|
|
1320
1464
|
/**
|
|
1321
1465
|
* Device meta-mutation + persistence actions for the device-manager addon —
|
|
@@ -1877,6 +2021,8 @@ async function setChildLayout(pctx, input) {
|
|
|
1877
2021
|
*/
|
|
1878
2022
|
async function setDeviceLinks(pctx, input) {
|
|
1879
2023
|
const { deviceId, deviceLinks } = input;
|
|
2024
|
+
const cycle = findDeviceLinkCycle(await pctx.metaStore.readMeta(), deviceId, deviceLinks);
|
|
2025
|
+
if (cycle) throw new Error(`[device-manager] setDeviceLinks: cross-device link cycle: ${cycle.join(" → ")}`);
|
|
1880
2026
|
await pctx.metaStore.withMetaWriteLock(async () => {
|
|
1881
2027
|
const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
|
|
1882
2028
|
if (!persisted) throw new Error(`[device-manager] setDeviceLinks: unknown device id=${deviceId}`);
|
|
@@ -2199,13 +2345,15 @@ function buildLinkIndexes(entries) {
|
|
|
2199
2345
|
sourceDeviceId
|
|
2200
2346
|
});
|
|
2201
2347
|
targets.set(tKey, tList);
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2348
|
+
if (link.source.kind !== "literal") {
|
|
2349
|
+
const sKey = `${sourceDeviceId}:${link.source.cap}`;
|
|
2350
|
+
const sList = dependents.get(sKey) ?? [];
|
|
2351
|
+
sList.push({
|
|
2352
|
+
targetDeviceId,
|
|
2353
|
+
targetCap: link.target.cap
|
|
2354
|
+
});
|
|
2355
|
+
dependents.set(sKey, sList);
|
|
2356
|
+
}
|
|
2209
2357
|
}
|
|
2210
2358
|
return {
|
|
2211
2359
|
targets,
|
|
@@ -2320,6 +2468,15 @@ var DeviceMetaStore = class {
|
|
|
2320
2468
|
for (const m of Object.values(meta)) if (m.stableId === wanted) return m.id;
|
|
2321
2469
|
return null;
|
|
2322
2470
|
};
|
|
2471
|
+
/** Resolve a GLOBAL link source (P2e) to a live device id: the device whose
|
|
2472
|
+
* FULL stableId equals `sourceStableId`, regardless of parent container.
|
|
2473
|
+
* First match wins (stableIds are effectively unique cluster-wide — see the
|
|
2474
|
+
* `DeviceLinkGlobalSource` docblock). Null when absent. Pure over a
|
|
2475
|
+
* pre-read meta map so a multi-link resolve reads the store once. */
|
|
2476
|
+
resolveGlobalSourceDeviceId = (sourceStableId, meta) => {
|
|
2477
|
+
for (const m of Object.values(meta)) if (m.stableId === sourceStableId) return m.id;
|
|
2478
|
+
return null;
|
|
2479
|
+
};
|
|
2323
2480
|
allocateNextDeviceId = async () => {
|
|
2324
2481
|
const current = (await this.readStore()).nextDeviceId ?? 1;
|
|
2325
2482
|
await this.settings.writeAddonStore({ nextDeviceId: current + 1 });
|
|
@@ -2350,8 +2507,18 @@ var DeviceMetaStore = class {
|
|
|
2350
2507
|
const container = targetMeta.parentDeviceId !== null ? stableIdById.get(targetMeta.parentDeviceId) ?? stableIdById.get(targetId) : stableIdById.get(targetId);
|
|
2351
2508
|
if (container === void 0) continue;
|
|
2352
2509
|
for (const link of targetMeta.deviceLinks ?? []) {
|
|
2353
|
-
|
|
2354
|
-
|
|
2510
|
+
const src = link.source;
|
|
2511
|
+
if (src.kind === "literal") {
|
|
2512
|
+
entries.push({
|
|
2513
|
+
targetDeviceId: targetId,
|
|
2514
|
+
link,
|
|
2515
|
+
sourceDeviceId: -1
|
|
2516
|
+
});
|
|
2517
|
+
continue;
|
|
2518
|
+
}
|
|
2519
|
+
const wantedStableId = src.kind === "global" ? src.sourceStableId : `${container}-${src.sourceKey}`;
|
|
2520
|
+
expectedSources.add(wantedStableId);
|
|
2521
|
+
const srcId = idByStableId.get(wantedStableId);
|
|
2355
2522
|
if (srcId === void 0) continue;
|
|
2356
2523
|
entries.push({
|
|
2357
2524
|
targetDeviceId: targetId,
|
|
@@ -2897,6 +3064,18 @@ async function testField(pctx, input) {
|
|
|
2897
3064
|
function isRecord(x) {
|
|
2898
3065
|
return x !== null && typeof x === "object" && !Array.isArray(x);
|
|
2899
3066
|
}
|
|
3067
|
+
/**
|
|
3068
|
+
* Resolve one link's transformed value. Field sources read via `readField`
|
|
3069
|
+
* (the caller supplies the sibling lookup — sync, e.g. the in-hub state
|
|
3070
|
+
* mirror); literal sources use their per-device constant and never consult
|
|
3071
|
+
* the reader. Returns `undefined` to skip the overlay (unreadable field).
|
|
3072
|
+
* Pure. Async source reads (`resolveLinkedStatus`'s provider `getStatus`
|
|
3073
|
+
* path) cannot use this helper for field sources — they branch on
|
|
3074
|
+
* `source.kind` directly.
|
|
3075
|
+
*/
|
|
3076
|
+
function resolveLinkValue(link, readField) {
|
|
3077
|
+
return (0, _camstack_types.applyTransform)(link.source.kind === "literal" ? link.source.value : readField(link.source.cap, link.source.fieldPath), link.transform);
|
|
3078
|
+
}
|
|
2900
3079
|
/** Narrow Zod v4's structural `$ZodType` (returned by `.unwrap()`) back to the
|
|
2901
3080
|
* concrete classic `z.ZodType`. Every runtime schema is a `z.ZodType`, so this
|
|
2902
3081
|
* is a true `instanceof` guard rather than a cast. */
|
|
@@ -2928,19 +3107,88 @@ function fillNullableDefaults(schema, value) {
|
|
|
2928
3107
|
return out;
|
|
2929
3108
|
}
|
|
2930
3109
|
/**
|
|
3110
|
+
* Group item-targeted links (`target.itemKey` set) by itemKey and upsert each
|
|
3111
|
+
* group as ONE item into the status' item array (see
|
|
3112
|
+
* `CapabilityStatusItemArray`). A NEW item is seeded from `emptyItem` with
|
|
3113
|
+
* `keyField` (and `labelField`, when declared) set to the itemKey; an
|
|
3114
|
+
* EXISTING item (matched by `keyField`) is cloned and overlaid in place, so
|
|
3115
|
+
* native fields a link doesn't touch are preserved. Each upserted item is
|
|
3116
|
+
* validated against `itemSchema` — an invalid item is skipped WITHOUT
|
|
3117
|
+
* discarding its valid siblings. Returns the new draft and whether anything
|
|
3118
|
+
* was applied. Pure + immutable (base array/items are never mutated).
|
|
3119
|
+
*/
|
|
3120
|
+
function upsertItemArrayLinks(draft, grouped, itemArray) {
|
|
3121
|
+
const arrRaw = (0, _camstack_types.getByPath)(draft, itemArray.path);
|
|
3122
|
+
const arr = Array.isArray(arrRaw) ? [...arrRaw] : [];
|
|
3123
|
+
let touched = false;
|
|
3124
|
+
for (const [itemKey, group] of grouped) {
|
|
3125
|
+
const idx = arr.findIndex((el) => isRecord(el) && el[itemArray.keyField] === itemKey);
|
|
3126
|
+
const existing = idx >= 0 ? arr[idx] : null;
|
|
3127
|
+
let item = isRecord(existing) ? { ...existing } : {
|
|
3128
|
+
...itemArray.emptyItem,
|
|
3129
|
+
[itemArray.keyField]: itemKey,
|
|
3130
|
+
...itemArray.labelField !== void 0 ? { [itemArray.labelField]: itemKey } : {}
|
|
3131
|
+
};
|
|
3132
|
+
let itemTouched = false;
|
|
3133
|
+
for (const { link, sourceValue } of group) {
|
|
3134
|
+
if (sourceValue === void 0) continue;
|
|
3135
|
+
item = (0, _camstack_types.setByPath)(item, link.target.fieldPath, sourceValue);
|
|
3136
|
+
itemTouched = true;
|
|
3137
|
+
}
|
|
3138
|
+
if (!itemTouched) continue;
|
|
3139
|
+
const repaired = fillNullableDefaults(itemArray.itemSchema, item);
|
|
3140
|
+
const parsed = itemArray.itemSchema.safeParse(repaired);
|
|
3141
|
+
if (!parsed.success) continue;
|
|
3142
|
+
if (isRecord(parsed.data)) item = parsed.data;
|
|
3143
|
+
if (idx >= 0) arr[idx] = item;
|
|
3144
|
+
else arr.push(item);
|
|
3145
|
+
touched = true;
|
|
3146
|
+
}
|
|
3147
|
+
if (!touched) return {
|
|
3148
|
+
draft,
|
|
3149
|
+
touched: false
|
|
3150
|
+
};
|
|
3151
|
+
return {
|
|
3152
|
+
draft: (0, _camstack_types.setByPath)(draft, itemArray.path, arr),
|
|
3153
|
+
touched: true
|
|
3154
|
+
};
|
|
3155
|
+
}
|
|
3156
|
+
/**
|
|
2931
3157
|
* Overlay transformed source values onto `base` by dot-path, then validate the
|
|
2932
3158
|
* result against the target cap's `statusSchema`. On validation failure the
|
|
2933
3159
|
* overlay is discarded and `base` is returned unchanged (a misconfigured link
|
|
2934
3160
|
* must never corrupt a cap response). Pure — all I/O happens in the caller.
|
|
3161
|
+
*
|
|
3162
|
+
* Item-array grouping (P2b): when the cap declares `status.itemArray`, links
|
|
3163
|
+
* carrying a `target.itemKey` are grouped per key and upserted as items into
|
|
3164
|
+
* the array (see `upsertItemArrayLinks`); their `fieldPath` is relative to
|
|
3165
|
+
* ONE item ('level', 'status', 'label'). Links WITHOUT an itemKey keep the
|
|
3166
|
+
* scalar dot-path behavior unchanged. A link with an itemKey on a cap that
|
|
3167
|
+
* declares NO `itemArray` is dropped (its item-relative path must never be
|
|
3168
|
+
* scalar-applied to the status root).
|
|
2935
3169
|
*/
|
|
2936
|
-
function mergeLinkedStatus(base, resolved, statusSchema) {
|
|
3170
|
+
function mergeLinkedStatus(base, resolved, statusSchema, itemArray) {
|
|
2937
3171
|
let draft = base;
|
|
2938
3172
|
let touched = false;
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
3173
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
3174
|
+
for (const entry of resolved) {
|
|
3175
|
+
const itemKey = entry.link.target.itemKey;
|
|
3176
|
+
if (itemKey !== void 0) {
|
|
3177
|
+
if (!itemArray) continue;
|
|
3178
|
+
const group = grouped.get(itemKey);
|
|
3179
|
+
if (group) group.push(entry);
|
|
3180
|
+
else grouped.set(itemKey, [entry]);
|
|
3181
|
+
continue;
|
|
3182
|
+
}
|
|
3183
|
+
if (entry.sourceValue === void 0) continue;
|
|
3184
|
+
draft = (0, _camstack_types.setByPath)(draft, entry.link.target.fieldPath, entry.sourceValue);
|
|
2942
3185
|
touched = true;
|
|
2943
3186
|
}
|
|
3187
|
+
if (itemArray && grouped.size > 0) {
|
|
3188
|
+
const upserted = upsertItemArrayLinks(draft, grouped, itemArray);
|
|
3189
|
+
draft = upserted.draft;
|
|
3190
|
+
touched = touched || upserted.touched;
|
|
3191
|
+
}
|
|
2944
3192
|
if (!touched) return base;
|
|
2945
3193
|
if (!statusSchema) return draft;
|
|
2946
3194
|
const repaired = fillNullableDefaults(statusSchema, draft);
|
|
@@ -3137,8 +3385,11 @@ var DeviceStateMirror = class DeviceStateMirror {
|
|
|
3137
3385
|
* Read-time overlay of a cap slice with its cross-device linked values.
|
|
3138
3386
|
* Returns a cloned raw mirror slice when the (device, cap) pair has no
|
|
3139
3387
|
* links. Sources are read from the same in-hub stateMirror — sync, no
|
|
3140
|
-
* cross-process call.
|
|
3141
|
-
*
|
|
3388
|
+
* cross-process call. When the raw slice is ABSENT but the cap is a link
|
|
3389
|
+
* target, the slice is SYNTHESIZED: the merge base is seeded from the
|
|
3390
|
+
* cap's declared `status.empty` default; a failed/empty synthesize
|
|
3391
|
+
* returns null (no phantom slices). The disk writer must NOT use this
|
|
3392
|
+
* method; it must persist raw provider truth via snapshotForDevice.
|
|
3142
3393
|
*/
|
|
3143
3394
|
overlayedSlice(deviceId, cap) {
|
|
3144
3395
|
const raw = this.stateMirror.get(deviceId)?.get(cap) ?? null;
|
|
@@ -3146,34 +3397,50 @@ var DeviceStateMirror = class DeviceStateMirror {
|
|
|
3146
3397
|
if (!links || links.length === 0) return raw ? { ...raw } : null;
|
|
3147
3398
|
const resolved = links.map((rl) => ({
|
|
3148
3399
|
link: rl.link,
|
|
3149
|
-
sourceValue: (
|
|
3400
|
+
sourceValue: resolveLinkValue(rl.link, (srcCap, fieldPath) => (0, _camstack_types.getByPath)(this.stateMirror.get(rl.sourceDeviceId)?.get(srcCap), fieldPath))
|
|
3150
3401
|
}));
|
|
3151
|
-
const
|
|
3152
|
-
|
|
3402
|
+
const capStatus = this.linkHost.capabilityRegistry?.getDefinition(cap)?.status;
|
|
3403
|
+
const schema = capStatus?.schema;
|
|
3404
|
+
const empty = capStatus?.empty;
|
|
3405
|
+
const base = raw ? { ...raw } : empty !== null && typeof empty === "object" && !Array.isArray(empty) ? { ...empty } : {};
|
|
3406
|
+
const merged = mergeLinkedStatus(base, resolved, schema, capStatus?.itemArray);
|
|
3407
|
+
if (!raw && merged === base) return null;
|
|
3408
|
+
return merged;
|
|
3153
3409
|
}
|
|
3154
3410
|
/**
|
|
3155
3411
|
* Like snapshotForDevice but applies the device-link overlay per cap.
|
|
3156
3412
|
* Used exclusively by the device-state READ methods (getSnapshot,
|
|
3157
|
-
* getAllSnapshots) so callers see overlayed values.
|
|
3158
|
-
*
|
|
3413
|
+
* getAllSnapshots) so callers see overlayed values. SYNTHESIZE-only
|
|
3414
|
+
* link-target caps (no base mirror slice) are unioned into the iterated
|
|
3415
|
+
* key set so they appear at warm-load — `overlayedSlice` builds them from
|
|
3416
|
+
* the cap's `status.empty` (and drops them when nothing resolves). The
|
|
3417
|
+
* debounced disk writer must continue to call snapshotForDevice (raw truth).
|
|
3159
3418
|
*/
|
|
3160
3419
|
snapshotForDeviceOverlayed(deviceId) {
|
|
3161
3420
|
const perCap = this.stateMirror.get(deviceId);
|
|
3162
|
-
|
|
3421
|
+
const capNames = new Set(perCap ? perCap.keys() : []);
|
|
3422
|
+
const prefix = `${deviceId}:`;
|
|
3423
|
+
for (const key of this.linkHost.linkTargets.keys()) if (key.startsWith(prefix)) capNames.add(key.slice(prefix.length));
|
|
3163
3424
|
const out = {};
|
|
3164
|
-
for (const capName of
|
|
3425
|
+
for (const capName of capNames) {
|
|
3165
3426
|
const s = this.overlayedSlice(deviceId, capName);
|
|
3166
3427
|
if (s) out[capName] = s;
|
|
3167
3428
|
}
|
|
3168
3429
|
return out;
|
|
3169
3430
|
}
|
|
3170
|
-
/** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`.
|
|
3431
|
+
/** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`.
|
|
3432
|
+
* Devices that only exist as link TARGETS (no mirror slice yet) are
|
|
3433
|
+
* included via the link reverse-index so their synthesized caps appear. */
|
|
3171
3434
|
allSnapshotsOverlayed() {
|
|
3435
|
+
const deviceIds = new Set(this.stateMirror.keys());
|
|
3436
|
+
for (const key of this.linkHost.linkTargets.keys()) {
|
|
3437
|
+
const id = Number(key.slice(0, key.indexOf(":")));
|
|
3438
|
+
if (Number.isFinite(id)) deviceIds.add(id);
|
|
3439
|
+
}
|
|
3172
3440
|
const out = {};
|
|
3173
|
-
for (const
|
|
3174
|
-
const dev =
|
|
3175
|
-
|
|
3176
|
-
out[String(deviceId)] = dev;
|
|
3441
|
+
for (const deviceId of deviceIds) {
|
|
3442
|
+
const dev = this.snapshotForDeviceOverlayed(deviceId);
|
|
3443
|
+
if (this.stateMirror.has(deviceId) || Object.keys(dev).length > 0) out[String(deviceId)] = dev;
|
|
3177
3444
|
}
|
|
3178
3445
|
return out;
|
|
3179
3446
|
}
|
|
@@ -3292,14 +3559,27 @@ function resolveNativeCapOwnerSync(pctx, capName, deviceId) {
|
|
|
3292
3559
|
* `getProviderForDevice` (routes cross-process); merge is pure.
|
|
3293
3560
|
*/
|
|
3294
3561
|
async function resolveLinkedStatus(pctx, input) {
|
|
3295
|
-
const { deviceId, cap
|
|
3562
|
+
const { deviceId, cap } = input;
|
|
3296
3563
|
if (!pctx.host.devicesWithLinks.has(deviceId)) return null;
|
|
3564
|
+
const inFlightKey = `${deviceId}:${cap}`;
|
|
3565
|
+
if (pctx.host.linkResolveInFlight.has(inFlightKey)) return null;
|
|
3566
|
+
pctx.host.linkResolveInFlight.add(inFlightKey);
|
|
3567
|
+
try {
|
|
3568
|
+
return await resolveLinkedStatusInner(pctx, input);
|
|
3569
|
+
} finally {
|
|
3570
|
+
pctx.host.linkResolveInFlight.delete(inFlightKey);
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
/** Body of `resolveLinkedStatus` — see the wrapper for the re-entrancy guard. */
|
|
3574
|
+
async function resolveLinkedStatusInner(pctx, input) {
|
|
3575
|
+
const { deviceId, cap, baseStatus } = input;
|
|
3297
3576
|
const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
|
|
3298
3577
|
if (!persisted) return null;
|
|
3299
3578
|
const links = (persisted.meta.deviceLinks ?? []).filter((l) => l.target.cap === cap);
|
|
3300
3579
|
if (links.length === 0) return null;
|
|
3301
3580
|
const capRegistry = pctx.host.capabilityRegistry;
|
|
3302
|
-
const
|
|
3581
|
+
const capStatus = capRegistry?.getDefinition(cap)?.status;
|
|
3582
|
+
const schema = capStatus?.schema;
|
|
3303
3583
|
if (!schema) return null;
|
|
3304
3584
|
const allMeta = await pctx.metaStore.readMeta();
|
|
3305
3585
|
let containerStableId = persisted.stableId;
|
|
@@ -3310,11 +3590,19 @@ async function resolveLinkedStatus(pctx, input) {
|
|
|
3310
3590
|
}
|
|
3311
3591
|
const resolved = [];
|
|
3312
3592
|
for (const link of links) {
|
|
3313
|
-
const
|
|
3593
|
+
const src = link.source;
|
|
3594
|
+
if (src.kind === "literal") {
|
|
3595
|
+
resolved.push({
|
|
3596
|
+
link,
|
|
3597
|
+
sourceValue: (0, _camstack_types.applyTransform)(src.value, link.transform)
|
|
3598
|
+
});
|
|
3599
|
+
continue;
|
|
3600
|
+
}
|
|
3601
|
+
const srcId = src.kind === "global" ? pctx.metaStore.resolveGlobalSourceDeviceId(src.sourceStableId, allMeta) : pctx.metaStore.resolveSourceDeviceId(containerStableId, src.sourceKey, allMeta);
|
|
3314
3602
|
if (srcId === null) continue;
|
|
3315
3603
|
try {
|
|
3316
|
-
const srcProvider = capRegistry?.getProviderForDevice(
|
|
3317
|
-
const raw = (0, _camstack_types.getByPath)(typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0,
|
|
3604
|
+
const srcProvider = capRegistry?.getProviderForDevice(src.cap, srcId);
|
|
3605
|
+
const raw = (0, _camstack_types.getByPath)(typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0, src.fieldPath);
|
|
3318
3606
|
resolved.push({
|
|
3319
3607
|
link,
|
|
3320
3608
|
sourceValue: (0, _camstack_types.applyTransform)(raw, link.transform)
|
|
@@ -3326,14 +3614,18 @@ async function resolveLinkedStatus(pctx, input) {
|
|
|
3326
3614
|
capName: cap
|
|
3327
3615
|
},
|
|
3328
3616
|
meta: {
|
|
3329
|
-
sourceKey:
|
|
3617
|
+
sourceKey: src.kind === "global" ? src.sourceStableId : src.sourceKey,
|
|
3330
3618
|
error: err instanceof Error ? err.message : String(err)
|
|
3331
3619
|
}
|
|
3332
3620
|
});
|
|
3333
3621
|
}
|
|
3334
3622
|
}
|
|
3335
3623
|
if (resolved.length === 0) return null;
|
|
3336
|
-
|
|
3624
|
+
const synthesizing = !isRecord$1(baseStatus);
|
|
3625
|
+
const base = isRecord$1(baseStatus) ? baseStatus : isRecord$1(capStatus.empty) ? { ...capStatus.empty } : {};
|
|
3626
|
+
const merged = mergeLinkedStatus(base, resolved, schema, capStatus.itemArray);
|
|
3627
|
+
if (synthesizing && merged === base) return null;
|
|
3628
|
+
return merged;
|
|
3337
3629
|
}
|
|
3338
3630
|
//#endregion
|
|
3339
3631
|
//#region src/builtins/device-manager/device-manager.addon.ts
|
|
@@ -3399,10 +3691,15 @@ var DeviceManagerAddon = class extends _camstack_types.BaseAddon {
|
|
|
3399
3691
|
linkTargets = /* @__PURE__ */ new Map();
|
|
3400
3692
|
/** `${sourceDeviceId}:${sourceCap}` → targets to recompute when that source changes. */
|
|
3401
3693
|
linkDependents = /* @__PURE__ */ new Map();
|
|
3402
|
-
/** Expected source `stableId`s (`${container}-${sourceKey}`
|
|
3694
|
+
/** Expected source `stableId`s (`${container}-${sourceKey}` for sibling
|
|
3695
|
+
* sources, the full `sourceStableId` for global sources) across all links,
|
|
3403
3696
|
* resolved or not — gates the `registerDevice` rebuild so only a registering
|
|
3404
3697
|
* device that IS a link source triggers a reindex (not every boot restore). */
|
|
3405
3698
|
expectedSourceStableIds = /* @__PURE__ */ new Set();
|
|
3699
|
+
/** In-flight `${deviceId}:${cap}` pairs of `resolveLinkedStatus` — the P2e
|
|
3700
|
+
* defensive re-entrancy guard bounding link cycles at resolve time. Mutated
|
|
3701
|
+
* in place (never reassigned), so the host exposes a direct reference. */
|
|
3702
|
+
linkResolveInFlight = /* @__PURE__ */ new Set();
|
|
3406
3703
|
/** Test/diagnostic accessors. */
|
|
3407
3704
|
linkTargetKeys() {
|
|
3408
3705
|
return [...this.linkTargets.keys()];
|
|
@@ -3474,6 +3771,7 @@ var DeviceManagerAddon = class extends _camstack_types.BaseAddon {
|
|
|
3474
3771
|
get expectedSourceStableIds() {
|
|
3475
3772
|
return expectedSourceStableIds();
|
|
3476
3773
|
},
|
|
3774
|
+
linkResolveInFlight: this.linkResolveInFlight,
|
|
3477
3775
|
dropDeviceOverlays: (deviceId) => this.stateMirror.dropDeviceOverlays(deviceId),
|
|
3478
3776
|
remoteNativeCaps: this.remoteNativeCaps,
|
|
3479
3777
|
seedMirror: (deviceId, blob) => this.stateMirror.seedMirror(deviceId, blob),
|
|
@@ -3522,12 +3820,15 @@ var DeviceManagerAddon = class extends _camstack_types.BaseAddon {
|
|
|
3522
3820
|
}
|
|
3523
3821
|
}))).filter((id) => id !== null);
|
|
3524
3822
|
}
|
|
3525
|
-
/** Build the dependency context the extracted binding resolvers consume.
|
|
3823
|
+
/** Build the dependency context the extracted binding resolvers consume.
|
|
3824
|
+
* `devicesWithLinks` is mutated in place (never reassigned) so the direct
|
|
3825
|
+
* reference stays live — it gates the virtual `linked` binding step. */
|
|
3526
3826
|
get bindingsDeps() {
|
|
3527
3827
|
return {
|
|
3528
3828
|
ctx: this.ctx,
|
|
3529
3829
|
capabilityRegistry: this.capabilityRegistry,
|
|
3530
|
-
remoteNativeCaps: this.remoteNativeCaps
|
|
3830
|
+
remoteNativeCaps: this.remoteNativeCaps,
|
|
3831
|
+
devicesWithLinks: this.devicesWithLinks
|
|
3531
3832
|
};
|
|
3532
3833
|
}
|
|
3533
3834
|
async getBindings(input) {
|