@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.
@@ -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. Behavior unchanged from the inline provider method.
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 schema = def.status?.schema;
1193
- if (!schema) continue;
1194
- const fields = (0, _camstack_types.enumerateSchemaFields)(schema).map((f) => f.enumValues !== void 0 ? {
1195
- path: f.path,
1196
- kind: f.kind,
1197
- enumValues: [...f.enumValues]
1198
- } : {
1199
- path: f.path,
1200
- kind: f.kind
1201
- });
1202
- if (fields.length > 0) caps.push({
1203
- cap: entry.capName,
1204
- fields
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 sKey = `${sourceDeviceId}:${link.source.cap}`;
2203
- const sList = dependents.get(sKey) ?? [];
2204
- sList.push({
2205
- targetDeviceId,
2206
- targetCap: link.target.cap
2207
- });
2208
- dependents.set(sKey, sList);
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
- expectedSources.add(`${container}-${link.source.sourceKey}`);
2354
- const srcId = idByStableId.get(`${container}-${link.source.sourceKey}`);
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
- for (const { link, sourceValue } of resolved) {
2940
- if (sourceValue === void 0) continue;
2941
- draft = (0, _camstack_types.setByPath)(draft, link.target.fieldPath, sourceValue);
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. The disk writer must NOT use this method; it must
3141
- * persist raw provider truth via snapshotForDevice.
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: (0, _camstack_types.applyTransform)((0, _camstack_types.getByPath)(this.stateMirror.get(rl.sourceDeviceId)?.get(rl.link.source.cap), rl.link.source.fieldPath), rl.link.transform)
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 schema = this.linkHost.capabilityRegistry?.getDefinition(cap)?.status?.schema;
3152
- return mergeLinkedStatus(raw ? { ...raw } : {}, resolved, schema);
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. The debounced disk
3158
- * writer must continue to call snapshotForDevice (raw truth).
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
- if (!perCap) return {};
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 perCap.keys()) {
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 [deviceId, perCap] of this.stateMirror) {
3174
- const dev = {};
3175
- for (const [capName, slice] of perCap) dev[capName] = this.overlayedSlice(deviceId, capName) ?? { ...slice };
3176
- out[String(deviceId)] = dev;
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, baseStatus } = input;
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 schema = capRegistry?.getDefinition(cap)?.status?.schema;
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 srcId = pctx.metaStore.resolveSourceDeviceId(containerStableId, link.source.sourceKey, allMeta);
3314
- if (srcId === null) continue;
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)(raw, link.transform)
3797
+ sourceValue: (0, _camstack_types.applyTransform)(src.value, link.transform)
3321
3798
  });
3322
- } catch (err) {
3323
- pctx.host.ctx.logger.warn("resolveLinkedStatus: source read failed", {
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
- sourceKey: link.source.sourceKey,
3330
- error: err instanceof Error ? err.message : String(err)
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
- return mergeLinkedStatus(isRecord$1(baseStatus) ? baseStatus : {}, resolved, schema);
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}`) across all links,
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),