@camstack/system 1.1.23 → 1.1.25

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.
@@ -1,5 +1,5 @@
1
1
  import { canonicalDeviceFingerprint } from "@camstack/types/node";
2
- import { ALL_CAPABILITY_DEFINITIONS, BaseAddon, CAP_NAMES_WITH_STATUS, DeviceFeature, DeviceRole, DeviceStatusSchema, DeviceType, EventCategory, STREAM_PROFILE_META, WELL_KNOWN_TAB_MAP, applyTransform, buildStreamParamsConfigSchema, deviceManagerCapability, deviceStateCapability, deviceStatusCapability, enumerateItemArrayFields, enumerateSchemaFields, errMsg, getByPath, isDeviceConfigCap, parseStreamParamsFormPatch, setByPath, sleep } from "@camstack/types";
2
+ import { ALL_CAPABILITY_DEFINITIONS, BaseAddon, CAP_NAMES_WITH_STATUS, DeviceFeature, DeviceRole, DeviceStatusSchema, DeviceType, EventCategory, STREAM_PROFILE_META, WELL_KNOWN_TAB_MAP, applyTransform, buildStreamParamsConfigSchema, deviceManagerCapability, deviceStateCapability, deviceStatusCapability, enumerateItemArrayFields, enumerateSchemaFields, errMsg, evaluateLinkExpression, getByPath, isDeviceConfigCap, normalizeUnit, parseStreamParamsFormPatch, setByPath, sleep, toExpressionValue, validateExpressionSource } from "@camstack/types";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { z } from "zod";
5
5
  /**
@@ -295,7 +295,8 @@ function toDeviceInfo(addonId, device, metadata = null, metaRow = null) {
295
295
  ...metaRow?.linkDeviceId !== void 0 ? { linkDeviceId: metaRow.linkDeviceId } : {},
296
296
  ...metaRow?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: metaRow.primaryChildEntityId } : {},
297
297
  ...metaRow?.childLayout !== void 0 ? { childLayout: metaRow.childLayout } : {},
298
- ...metaRow?.deviceLinks !== void 0 ? { deviceLinks: metaRow.deviceLinks } : {}
298
+ ...metaRow?.deviceLinks !== void 0 ? { deviceLinks: metaRow.deviceLinks } : {},
299
+ ...metaRow?.display !== void 0 ? { display: metaRow.display } : {}
299
300
  };
300
301
  }
301
302
  function resolveDeviceById(registry, deviceId) {
@@ -1366,20 +1367,41 @@ var DeviceEventPropagator = class {
1366
1367
  function nodeKey(deviceId, cap) {
1367
1368
  return `${deviceId}:${cap}`;
1368
1369
  }
1370
+ /** The target's container stableId — its parent's (falling back to its own for
1371
+ * a top-level target). Sibling FIELD sources resolve relative to this, the same
1372
+ * rule `rebuildLinkDependents` / `resolveLinkedStatus` apply. */
1373
+ function containerStableId(targetRow, rowById) {
1374
+ return targetRow.parentDeviceId !== null ? rowById.get(targetRow.parentDeviceId)?.stableId ?? targetRow.stableId : targetRow.stableId;
1375
+ }
1376
+ /** Resolve ONE field/global/literal binding to its `(deviceId, cap)` node, or
1377
+ * null when it is a literal or resolves to no known device. */
1378
+ function resolveBindingNode(binding, targetRow, rowById, idByStableId) {
1379
+ if (binding.kind === "literal") return null;
1380
+ const wantedStableId = binding.kind === "global" ? binding.sourceStableId : `${containerStableId(targetRow, rowById)}-${binding.sourceKey}`;
1381
+ const srcId = idByStableId.get(wantedStableId);
1382
+ if (srcId === void 0) return null;
1383
+ return nodeKey(srcId, binding.cap);
1384
+ }
1369
1385
  /**
1370
- * Resolve a link source to its `(deviceId, cap)` node, or null when the
1371
- * source is a literal (no device) or does not resolve to a known device.
1372
- * Sibling FIELD sources resolve against the TARGET's container stableId
1373
- * (its parent's, falling back to its own for a top-level target) the same
1374
- * rule `rebuildLinkDependents` / `resolveLinkedStatus` apply.
1386
+ * Resolve a link source to the set of `(deviceId, cap)` nodes it depends on.
1387
+ * A literal source depends on nothing; a field/global source depends on exactly
1388
+ * one node; an EXPRESSION source depends on one node per resolvable non-literal
1389
+ * binding. Unresolvable (dangling) bindings contribute no edgethey cannot
1390
+ * loop while dangling; a cycle that only becomes resolvable later is bounded by
1391
+ * the resolve-time re-entrancy guard.
1375
1392
  */
1376
- function resolveSourceNode(link, targetRow, rowById, idByStableId) {
1393
+ function resolveSourceNodes(link, targetRow, rowById, idByStableId) {
1377
1394
  const src = link.source;
1378
- if (src.kind === "literal") return null;
1379
- const wantedStableId = src.kind === "global" ? src.sourceStableId : `${targetRow.parentDeviceId !== null ? rowById.get(targetRow.parentDeviceId)?.stableId ?? targetRow.stableId : targetRow.stableId}-${src.sourceKey}`;
1380
- const srcId = idByStableId.get(wantedStableId);
1381
- if (srcId === void 0) return null;
1382
- return nodeKey(srcId, src.cap);
1395
+ if (src.kind === "expression") {
1396
+ const nodes = [];
1397
+ for (const binding of Object.values(src.bindings)) {
1398
+ const node = resolveBindingNode(binding, targetRow, rowById, idByStableId);
1399
+ if (node !== null) nodes.push(node);
1400
+ }
1401
+ return nodes;
1402
+ }
1403
+ const node = resolveBindingNode(src, targetRow, rowById, idByStableId);
1404
+ return node !== null ? [node] : [];
1383
1405
  }
1384
1406
  /**
1385
1407
  * Detect whether replacing `editedDeviceId`'s links with `editedLinks` closes
@@ -1397,11 +1419,11 @@ function findDeviceLinkCycle(allMeta, editedDeviceId, editedLinks) {
1397
1419
  const dependsOn = /* @__PURE__ */ new Map();
1398
1420
  const addEdges = (row, links) => {
1399
1421
  for (const link of links) {
1400
- const srcNode = resolveSourceNode(link, row, rowById, idByStableId);
1401
- if (srcNode === null) continue;
1422
+ const srcNodes = resolveSourceNodes(link, row, rowById, idByStableId);
1423
+ if (srcNodes.length === 0) continue;
1402
1424
  const tNode = nodeKey(row.id, link.target.cap);
1403
1425
  const list = dependsOn.get(tNode) ?? [];
1404
- list.push(srcNode);
1426
+ for (const srcNode of srcNodes) list.push(srcNode);
1405
1427
  dependsOn.set(tNode, list);
1406
1428
  }
1407
1429
  };
@@ -1468,7 +1490,7 @@ function dfsFindCycle(root, dependsOn) {
1468
1490
  * removeDevice), config persistence (persistConfig, loadConfig), the meta
1469
1491
  * surface load (loadMeta, loadRuntimeState), every meta setter (setName,
1470
1492
  * setLocation, setType, setIntegrationId, setLinkDeviceId,
1471
- * setPrimaryChildEntityId, setChildLayout, setDeviceLinks, setRole,
1493
+ * setPrimaryChildEntityId, setChildLayout, setDeviceLinks, setRole, setDisplay,
1472
1494
  * applyInitialMeta, setMetadata, setDisabled), and the location registry
1473
1495
  * (listLocations, addLocation, removeLocation).
1474
1496
  *
@@ -1577,6 +1599,7 @@ async function registerDevice(pctx, input) {
1577
1599
  ...existingMeta?.childLayout !== void 0 ? { childLayout: existingMeta.childLayout } : {},
1578
1600
  ...existingMeta?.deviceLinks !== void 0 ? { deviceLinks: existingMeta.deviceLinks } : {},
1579
1601
  ...existingMeta?.role !== void 0 ? { role: existingMeta.role } : {},
1602
+ ...existingMeta?.display !== void 0 ? { display: existingMeta.display } : {},
1580
1603
  parentDeviceId,
1581
1604
  id,
1582
1605
  features: featuresArr,
@@ -2018,6 +2041,11 @@ async function setDeviceLinks(pctx, input) {
2018
2041
  const { deviceId, deviceLinks } = input;
2019
2042
  const cycle = findDeviceLinkCycle(await pctx.metaStore.readMeta(), deviceId, deviceLinks);
2020
2043
  if (cycle) throw new Error(`[device-manager] setDeviceLinks: cross-device link cycle: ${cycle.join(" → ")}`);
2044
+ for (const link of deviceLinks) {
2045
+ if (link.source.kind !== "expression") continue;
2046
+ const err = validateExpressionSource(link.source);
2047
+ if (err) throw new Error(`[device-manager] setDeviceLinks: invalid expression on link '${link.id}': ${err}`);
2048
+ }
2021
2049
  await pctx.metaStore.withMetaWriteLock(async () => {
2022
2050
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2023
2051
  if (!persisted) throw new Error(`[device-manager] setDeviceLinks: unknown device id=${deviceId}`);
@@ -2088,6 +2116,84 @@ async function setRole(pctx, input) {
2088
2116
  });
2089
2117
  }
2090
2118
  /**
2119
+ * Normalize an operator-authored display override at write time so the render
2120
+ * path's `UNIT_TABLE` lookups always hit canonical spellings — maps `unit` and
2121
+ * every `perCap[*].unit` through `normalizeUnit`. Pure: rebuilds new objects,
2122
+ * never mutates the input. A unit `normalizeUnit` cannot canonicalize is left
2123
+ * as-is (the render path refuses conversion for unknown spellings).
2124
+ */
2125
+ function normalizeDisplayOverride(display) {
2126
+ return {
2127
+ ...display,
2128
+ ...display.unit !== void 0 ? { unit: normalizeUnit(display.unit) ?? display.unit } : {},
2129
+ ...display.perCap !== void 0 ? { perCap: Object.fromEntries(Object.entries(display.perCap).map(([cap, refine]) => [cap, refine.unit !== void 0 ? {
2130
+ ...refine,
2131
+ unit: normalizeUnit(refine.unit) ?? refine.unit
2132
+ } : refine])) } : {}
2133
+ };
2134
+ }
2135
+ /**
2136
+ * Set (or clear) the per-device display override on a device's meta row.
2137
+ * Mirrors `setChildLayout` persistence; `null` REMOVES the `display` key
2138
+ * entirely (immutable rest-destructure — matches the "absent ⇒ no override"
2139
+ * projection contract, never persists `display: undefined`). Override units are
2140
+ * normalized at write so the render path always looks up canonical spellings.
2141
+ */
2142
+ async function setDisplay(pctx, input) {
2143
+ const { deviceId, display } = input;
2144
+ const normalized = display === null ? null : normalizeDisplayOverride(display);
2145
+ await pctx.metaStore.withMetaWriteLock(async () => {
2146
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2147
+ if (!persisted) throw new Error(`[device-manager] setDisplay: unknown device id=${deviceId}`);
2148
+ const { meta: m } = persisted;
2149
+ const key = String(deviceId);
2150
+ const allMeta = await pctx.metaStore.readMeta();
2151
+ const nextRow = normalized === null ? (({ display: _drop, ...rest }) => rest)(m) : {
2152
+ ...m,
2153
+ display: normalized
2154
+ };
2155
+ await pctx.settings.writeAddonStore({ deviceMeta: {
2156
+ ...allMeta,
2157
+ [key]: nextRow
2158
+ } });
2159
+ });
2160
+ pctx.host.ctx.eventBus.emit({
2161
+ id: randomUUID(),
2162
+ timestamp: /* @__PURE__ */ new Date(),
2163
+ source: {
2164
+ type: "device",
2165
+ id: deviceId
2166
+ },
2167
+ category: EventCategory.DeviceMetaChanged,
2168
+ data: {
2169
+ deviceId,
2170
+ field: "display",
2171
+ value: normalized
2172
+ }
2173
+ });
2174
+ }
2175
+ /**
2176
+ * Read the operator-authored per-role display defaults. Empty record when none
2177
+ * set. Not per-device — a plain top-level-key read.
2178
+ */
2179
+ async function getRoleDisplayDefaults(pctx, _input) {
2180
+ return { defaults: (await pctx.metaStore.readStore()).roleDisplayDefaults ?? {} };
2181
+ }
2182
+ /**
2183
+ * Replace the per-role display defaults whole-record (full replace). Override
2184
+ * units are normalized (`normalizeUnit`) at write so the render path always
2185
+ * looks up canonical spellings. Single-writer top-level key — no lock, no RMW,
2186
+ * no interaction with the `deviceMeta` write lock. Not per-device, so no event
2187
+ * is emitted; the UI invalidates its own query on mutate.
2188
+ */
2189
+ async function setRoleDisplayDefaults(pctx, input) {
2190
+ const normalized = Object.fromEntries(Object.entries(input.defaults).map(([role, def]) => [role, def.unit !== void 0 ? {
2191
+ ...def,
2192
+ unit: normalizeUnit(def.unit) ?? def.unit
2193
+ } : def]));
2194
+ await pctx.settings.writeAddonStore({ roleDisplayDefaults: normalized });
2195
+ }
2196
+ /**
2091
2197
  * Batched meta pre-seed. Applies every provided field to the
2092
2198
  * device's meta row in ONE read-modify-write under a single
2093
2199
  * `withMetaWriteLock` acquisition (one `deviceMeta` blob write),
@@ -2332,23 +2438,33 @@ async function removeLocation(pctx, input) {
2332
2438
  function buildLinkIndexes(entries) {
2333
2439
  const targets = /* @__PURE__ */ new Map();
2334
2440
  const dependents = /* @__PURE__ */ new Map();
2335
- for (const { targetDeviceId, link, sourceDeviceId } of entries) {
2441
+ for (const { targetDeviceId, link, sourceDeviceId, bindingSourceIds } of entries) {
2336
2442
  const tKey = `${targetDeviceId}:${link.target.cap}`;
2337
2443
  const tList = targets.get(tKey) ?? [];
2338
2444
  tList.push({
2339
2445
  link,
2340
- sourceDeviceId
2446
+ sourceDeviceId,
2447
+ ...bindingSourceIds !== void 0 ? { bindingSourceIds } : {}
2341
2448
  });
2342
2449
  targets.set(tKey, tList);
2343
- if (link.source.kind !== "literal") {
2344
- const sKey = `${sourceDeviceId}:${link.source.cap}`;
2450
+ const addDependent = (sourceId, sourceCap) => {
2451
+ const sKey = `${sourceId}:${sourceCap}`;
2345
2452
  const sList = dependents.get(sKey) ?? [];
2346
2453
  sList.push({
2347
2454
  targetDeviceId,
2348
2455
  targetCap: link.target.cap
2349
2456
  });
2350
2457
  dependents.set(sKey, sList);
2458
+ };
2459
+ if (link.source.kind === "expression") {
2460
+ if (bindingSourceIds !== void 0) for (const [name, bindingDeviceId] of Object.entries(bindingSourceIds)) {
2461
+ const binding = link.source.bindings[name];
2462
+ if (binding === void 0 || binding.kind === "literal") continue;
2463
+ addDependent(bindingDeviceId, binding.cap);
2464
+ }
2465
+ continue;
2351
2466
  }
2467
+ if (link.source.kind !== "literal") addDependent(sourceDeviceId, link.source.cap);
2352
2468
  }
2353
2469
  return {
2354
2470
  targets,
@@ -2503,6 +2619,23 @@ var DeviceMetaStore = class {
2503
2619
  if (container === void 0) continue;
2504
2620
  for (const link of targetMeta.deviceLinks ?? []) {
2505
2621
  const src = link.source;
2622
+ if (src.kind === "expression") {
2623
+ const bindingSourceIds = {};
2624
+ for (const [name, b] of Object.entries(src.bindings)) {
2625
+ if (b.kind === "literal") continue;
2626
+ const wanted = b.kind === "global" ? b.sourceStableId : `${container}-${b.sourceKey}`;
2627
+ expectedSources.add(wanted);
2628
+ const bid = idByStableId.get(wanted);
2629
+ if (bid !== void 0) bindingSourceIds[name] = bid;
2630
+ }
2631
+ entries.push({
2632
+ targetDeviceId: targetId,
2633
+ link,
2634
+ sourceDeviceId: -1,
2635
+ bindingSourceIds
2636
+ });
2637
+ continue;
2638
+ }
2506
2639
  if (src.kind === "literal") {
2507
2640
  entries.push({
2508
2641
  targetDeviceId: targetId,
@@ -2620,6 +2753,7 @@ async function listAll(pctx, input) {
2620
2753
  ...m?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2621
2754
  ...m?.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2622
2755
  ...m?.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2756
+ ...m?.display !== void 0 ? { display: m.display } : {},
2623
2757
  ...(() => {
2624
2758
  const si = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
2625
2759
  return si !== void 0 ? { sourceInfo: si } : {};
@@ -2668,6 +2802,7 @@ async function getDevice(pctx, input) {
2668
2802
  ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2669
2803
  ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2670
2804
  ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2805
+ ...m.display !== void 0 ? { display: m.display } : {},
2671
2806
  ...sourceInfoGetDevice !== void 0 ? { sourceInfo: sourceInfoGetDevice } : {}
2672
2807
  };
2673
2808
  }
@@ -2730,6 +2865,7 @@ async function getChildren(pctx, input) {
2730
2865
  ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2731
2866
  ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2732
2867
  ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2868
+ ...m.display !== void 0 ? { display: m.display } : {},
2733
2869
  ...sourceInfoChild !== void 0 ? { sourceInfo: sourceInfoChild } : {}
2734
2870
  });
2735
2871
  }
@@ -3069,7 +3205,37 @@ function isRecord(x) {
3069
3205
  * `source.kind` directly.
3070
3206
  */
3071
3207
  function resolveLinkValue(link, readField) {
3072
- return applyTransform(link.source.kind === "literal" ? link.source.value : readField(link.source.cap, link.source.fieldPath), link.transform);
3208
+ const src = link.source;
3209
+ if (src.kind === "expression") return void 0;
3210
+ return applyTransform(src.kind === "literal" ? src.value : readField(src.cap, src.fieldPath), link.transform);
3211
+ }
3212
+ /**
3213
+ * Sync-channel expression resolution: bindings read from the mirror via
3214
+ * `readField(deviceId, cap, fieldPath)` using the pre-resolved per-binding ids
3215
+ * on the `ResolvedTargetLink`. A literal binding uses its constant; an absent
3216
+ * binding id (dangling source) or a non-primitive read resolves to `null`.
3217
+ * Returns `undefined` on parse/eval failure (skip overlay) — silent by design:
3218
+ * the mirror path is hot and churn-free; the async channel logs. Compile
3219
+ * failures cost one parse ever thanks to the negative LRU compile cache. Pure.
3220
+ */
3221
+ function resolveExpressionLinkValue(rl, readField, now) {
3222
+ const src = rl.link.source;
3223
+ if (src.kind !== "expression") return void 0;
3224
+ const bindingValues = {};
3225
+ for (const [name, b] of Object.entries(src.bindings)) {
3226
+ if (b.kind === "literal") {
3227
+ bindingValues[name] = b.value;
3228
+ continue;
3229
+ }
3230
+ const bId = rl.bindingSourceIds?.[name];
3231
+ if (bId === void 0) {
3232
+ bindingValues[name] = null;
3233
+ continue;
3234
+ }
3235
+ bindingValues[name] = toExpressionValue(readField(bId, b.cap, b.fieldPath)) ?? null;
3236
+ }
3237
+ const result = evaluateLinkExpression(src.expr, bindingValues, now);
3238
+ return result.ok ? applyTransform(result.value, rl.link.transform) : void 0;
3073
3239
  }
3074
3240
  /** Narrow Zod v4's structural `$ZodType` (returned by `.unwrap()`) back to the
3075
3241
  * concrete classic `z.ZodType`. Every runtime schema is a `z.ZodType`, so this
@@ -3390,9 +3556,10 @@ var DeviceStateMirror = class DeviceStateMirror {
3390
3556
  const raw = this.stateMirror.get(deviceId)?.get(cap) ?? null;
3391
3557
  const links = this.linkHost.linkTargets.get(`${deviceId}:${cap}`);
3392
3558
  if (!links || links.length === 0) return raw ? { ...raw } : null;
3559
+ const now = Date.now();
3393
3560
  const resolved = links.map((rl) => ({
3394
3561
  link: rl.link,
3395
- sourceValue: resolveLinkValue(rl.link, (srcCap, fieldPath) => getByPath(this.stateMirror.get(rl.sourceDeviceId)?.get(srcCap), fieldPath))
3562
+ sourceValue: rl.link.source.kind === "expression" ? resolveExpressionLinkValue(rl, (srcDeviceId, srcCap, fieldPath) => getByPath(this.stateMirror.get(srcDeviceId)?.get(srcCap), fieldPath), now) : resolveLinkValue(rl.link, (srcCap, fieldPath) => getByPath(this.stateMirror.get(rl.sourceDeviceId)?.get(srcCap), fieldPath))
3396
3563
  }));
3397
3564
  const capStatus = this.linkHost.capabilityRegistry?.getDefinition(cap)?.status;
3398
3565
  const schema = capStatus?.schema;
@@ -3583,6 +3750,39 @@ async function resolveLinkedStatusInner(pctx, input) {
3583
3750
  const parentMeta = allMeta[String(parentId)];
3584
3751
  if (parentMeta) containerStableId = parentMeta.stableId;
3585
3752
  }
3753
+ const now = Date.now();
3754
+ const statusMemo = /* @__PURE__ */ new Map();
3755
+ const readSourceStatus = async (srcId, srcCap) => {
3756
+ const key = `${srcId}:${srcCap}`;
3757
+ const cached = statusMemo.get(key);
3758
+ if (cached !== void 0) return cached;
3759
+ let result;
3760
+ try {
3761
+ const srcProvider = capRegistry?.getProviderForDevice(srcCap, srcId);
3762
+ result = {
3763
+ status: typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0,
3764
+ ok: true
3765
+ };
3766
+ } catch (err) {
3767
+ pctx.host.ctx.logger.warn("resolveLinkedStatus: source read failed", {
3768
+ tags: {
3769
+ deviceId,
3770
+ capName: cap
3771
+ },
3772
+ meta: {
3773
+ sourceCap: srcCap,
3774
+ sourceId: srcId,
3775
+ error: err instanceof Error ? err.message : String(err)
3776
+ }
3777
+ });
3778
+ result = {
3779
+ status: void 0,
3780
+ ok: false
3781
+ };
3782
+ }
3783
+ statusMemo.set(key, result);
3784
+ return result;
3785
+ };
3586
3786
  const resolved = [];
3587
3787
  for (const link of links) {
3588
3788
  const src = link.source;
@@ -3593,27 +3793,46 @@ async function resolveLinkedStatusInner(pctx, input) {
3593
3793
  });
3594
3794
  continue;
3595
3795
  }
3596
- const srcId = src.kind === "global" ? pctx.metaStore.resolveGlobalSourceDeviceId(src.sourceStableId, allMeta) : pctx.metaStore.resolveSourceDeviceId(containerStableId, src.sourceKey, allMeta);
3597
- if (srcId === null) continue;
3598
- try {
3599
- const srcProvider = capRegistry?.getProviderForDevice(src.cap, srcId);
3600
- const raw = getByPath(typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0, src.fieldPath);
3601
- resolved.push({
3796
+ if (src.kind === "expression") {
3797
+ const bindingValues = {};
3798
+ for (const [name, b] of Object.entries(src.bindings)) {
3799
+ if (b.kind === "literal") {
3800
+ bindingValues[name] = b.value;
3801
+ continue;
3802
+ }
3803
+ const bId = b.kind === "global" ? pctx.metaStore.resolveGlobalSourceDeviceId(b.sourceStableId, allMeta) : pctx.metaStore.resolveSourceDeviceId(containerStableId, b.sourceKey, allMeta);
3804
+ if (bId === null) {
3805
+ bindingValues[name] = null;
3806
+ continue;
3807
+ }
3808
+ bindingValues[name] = toExpressionValue(getByPath((await readSourceStatus(bId, b.cap)).status, b.fieldPath)) ?? null;
3809
+ }
3810
+ const result = evaluateLinkExpression(src.expr, bindingValues, now);
3811
+ if (result.ok) resolved.push({
3602
3812
  link,
3603
- sourceValue: applyTransform(raw, link.transform)
3813
+ sourceValue: applyTransform(result.value, link.transform)
3604
3814
  });
3605
- } catch (err) {
3606
- pctx.host.ctx.logger.warn("resolveLinkedStatus: source read failed", {
3815
+ else pctx.host.ctx.logger.warn("resolveLinkedStatus: expression skipped", {
3607
3816
  tags: {
3608
3817
  deviceId,
3609
3818
  capName: cap
3610
3819
  },
3611
3820
  meta: {
3612
- sourceKey: src.kind === "global" ? src.sourceStableId : src.sourceKey,
3613
- error: err instanceof Error ? err.message : String(err)
3821
+ linkId: link.id,
3822
+ error: result.error
3614
3823
  }
3615
3824
  });
3825
+ continue;
3616
3826
  }
3827
+ const srcId = src.kind === "global" ? pctx.metaStore.resolveGlobalSourceDeviceId(src.sourceStableId, allMeta) : pctx.metaStore.resolveSourceDeviceId(containerStableId, src.sourceKey, allMeta);
3828
+ if (srcId === null) continue;
3829
+ const read = await readSourceStatus(srcId, src.cap);
3830
+ if (!read.ok) continue;
3831
+ const raw = getByPath(read.status, src.fieldPath);
3832
+ resolved.push({
3833
+ link,
3834
+ sourceValue: applyTransform(raw, link.transform)
3835
+ });
3617
3836
  }
3618
3837
  if (resolved.length === 0) return null;
3619
3838
  const synthesizing = !isRecord$1(baseStatus);
@@ -3926,6 +4145,9 @@ var DeviceManagerAddon = class extends BaseAddon {
3926
4145
  setChildLayout: (input) => setChildLayout(pctx, input),
3927
4146
  setDeviceLinks: (input) => setDeviceLinks(pctx, input),
3928
4147
  setRole: (input) => setRole(pctx, input),
4148
+ setDisplay: (input) => setDisplay(pctx, input),
4149
+ getRoleDisplayDefaults: (input) => getRoleDisplayDefaults(pctx, input),
4150
+ setRoleDisplayDefaults: (input) => setRoleDisplayDefaults(pctx, input),
3929
4151
  applyInitialMeta: (input) => applyInitialMeta(pctx, input),
3930
4152
  setMetadata: (input) => setMetadata(pctx, input),
3931
4153
  setDisabled: (input) => setDisabled(pctx, input),
@@ -97,6 +97,27 @@ export declare function setDeviceLinks(pctx: ProviderContext, input: Parameters<
97
97
  * `setIntegrationId`). Idempotent. `null` clears a previous role.
98
98
  */
99
99
  export declare function setRole(pctx: ProviderContext, input: Parameters<IDeviceManagerProvider['setRole']>[0]): ReturnType<IDeviceManagerProvider['setRole']>;
100
+ /**
101
+ * Set (or clear) the per-device display override on a device's meta row.
102
+ * Mirrors `setChildLayout` persistence; `null` REMOVES the `display` key
103
+ * entirely (immutable rest-destructure — matches the "absent ⇒ no override"
104
+ * projection contract, never persists `display: undefined`). Override units are
105
+ * normalized at write so the render path always looks up canonical spellings.
106
+ */
107
+ export declare function setDisplay(pctx: ProviderContext, input: Parameters<IDeviceManagerProvider['setDisplay']>[0]): ReturnType<IDeviceManagerProvider['setDisplay']>;
108
+ /**
109
+ * Read the operator-authored per-role display defaults. Empty record when none
110
+ * set. Not per-device — a plain top-level-key read.
111
+ */
112
+ export declare function getRoleDisplayDefaults(pctx: ProviderContext, _input: Parameters<IDeviceManagerProvider['getRoleDisplayDefaults']>[0]): ReturnType<IDeviceManagerProvider['getRoleDisplayDefaults']>;
113
+ /**
114
+ * Replace the per-role display defaults whole-record (full replace). Override
115
+ * units are normalized (`normalizeUnit`) at write so the render path always
116
+ * looks up canonical spellings. Single-writer top-level key — no lock, no RMW,
117
+ * no interaction with the `deviceMeta` write lock. Not per-device, so no event
118
+ * is emitted; the UI invalidates its own query on mutate.
119
+ */
120
+ export declare function setRoleDisplayDefaults(pctx: ProviderContext, input: Parameters<IDeviceManagerProvider['setRoleDisplayDefaults']>[0]): ReturnType<IDeviceManagerProvider['setRoleDisplayDefaults']>;
100
121
  /**
101
122
  * Batched meta pre-seed. Applies every provided field to the
102
123
  * device's meta row in ONE read-modify-write under a single
@@ -1,4 +1,4 @@
1
- import { ChildLayout, DeviceLinks } from '@camstack/types';
1
+ import { ChildLayout, DeviceDisplayOverride, DeviceLinks, RoleDisplayDefault } from '@camstack/types';
2
2
  /**
3
3
  * Wire shape matching `z.infer<typeof SettingsSchemaWithValuesSchema>` —
4
4
  * duplicated as a plain interface because importing the Zod schema across
@@ -132,6 +132,11 @@ export interface PersistedDeviceMeta {
132
132
  * Optional: only present for accessory children that carry a known role
133
133
  * (e.g. 'numeric-sensor', 'binary-sensor'). `null` clears a previous role. */
134
134
  role?: string | null;
135
+ /** Operator-authored per-device display override (icon/label/unit/precision/
136
+ * hidden), set via `setDisplay`. Same create/persist/project/restore
137
+ * lifecycle as `deviceLinks`. Absent ⇒ no override. Applied at render time
138
+ * only — storage stays in source units. */
139
+ display?: DeviceDisplayOverride;
135
140
  }
136
141
  export interface AddonStore {
137
142
  deviceIndex?: Record<string, string[]>;
@@ -166,4 +171,10 @@ export interface AddonStore {
166
171
  * the operator forgot to register a label before assigning it.
167
172
  */
168
173
  locations?: readonly string[];
174
+ /** Operator-authored per-role display defaults (unit/precision/icon), keyed
175
+ * by `DeviceRole` string. Resolution merges these UNDER any per-device
176
+ * `display` override. Written whole-record by `setRoleDisplayDefaults`
177
+ * (full replace — no read-modify-write, single writer, so it never
178
+ * interacts with the `deviceMeta` write lock). */
179
+ roleDisplayDefaults?: Record<string, RoleDisplayDefault>;
169
180
  }
@@ -47,6 +47,7 @@ export declare function isDeviceRole(value: string): value is DeviceRole;
47
47
  * never leaks an off-enum string onto the wire shape. */
48
48
  export declare function toDeviceRole(value: string | null | undefined): DeviceRole | null;
49
49
  export declare function toDeviceInfo(addonId: string, device: IDevice, metadata?: Record<string, unknown> | null, metaRow?: PersistedDeviceMeta | null): {
50
+ display?: import('@camstack/types').DeviceDisplayOverride | undefined;
50
51
  deviceLinks?: import('@camstack/types').DeviceLinks | undefined;
51
52
  childLayout?: import('@camstack/types').ChildLayout | undefined;
52
53
  primaryChildEntityId?: string | null | undefined;
@@ -8,7 +8,29 @@ export declare class PlatformProbeNativeAddon extends BaseAddon {
8
8
  private encoderProber;
9
9
  private decodeAccelProber;
10
10
  private cachedCaps;
11
+ /**
12
+ * Per-boot generation stamp for the manual readiness emissions below.
13
+ * Constant for the lifetime of this addon instance (== one process boot);
14
+ * consumer-side registries derive a monotonic epoch from generation
15
+ * transitions. Mirrors `BaseAddon._readinessGeneration` (private there).
16
+ */
17
+ private readonly readinessGeneration;
11
18
  constructor();
19
+ /**
20
+ * Manual readiness protocol. The provider registers synchronously from
21
+ * `onInitialize`, but the REAL hardware + EP scoring is the async
22
+ * `probePromise` (embedded-Python install included). The BaseAddon
23
+ * auto-emit would flip `ready` at registration time — BEFORE any
24
+ * accelerator is visible — so probe-gated consumers (detection-pipeline
25
+ * engine auto-pick) would read accelerator-blind results and stick on
26
+ * onnx-CPU. Instead: `starting` at init, the single authoritative
27
+ * `ready` once the probe resolves, `down` on shutdown.
28
+ */
29
+ protected get autoEmitReadiness(): boolean;
30
+ /** Bare cluster node id — readiness is scoped `{type:'node', nodeId}`. */
31
+ private bareLocalNodeId;
32
+ /** Emit a `system.ready-state` transition for the platform-probe cap. */
33
+ private emitProbeReadiness;
12
34
  /**
13
35
  * Resolve the ffmpeg binary the encoder probe should test, from the cluster
14
36
  * `ffmpeg` config section (`binaryPath`). The probe MUST exercise the same
@@ -594,15 +594,86 @@ var HardwareDecodeAccelProber = class {
594
594
  };
595
595
  //#endregion
596
596
  //#region src/builtins/platform-probe/index.ts
597
+ /**
598
+ * The decode-hwaccel backends `ctx.kernel.hwaccel.resolve` accepts. The cap
599
+ * input enum is WIDER (it also carries EP-only names — coreml/openvino/… —
600
+ * shared with other probe surfaces), so the provider param must stay
601
+ * `string`-typed to satisfy the `InferProvider` contract. We narrow it here
602
+ * with a type guard instead of a cast: any value that is not a known decode
603
+ * backend (or the `'none'` sentinel) resolves to `null` → auto-probe.
604
+ */
605
+ var HWACCEL_DECODE_BACKENDS = [
606
+ "videotoolbox",
607
+ "cuda",
608
+ "nvdec",
609
+ "vaapi",
610
+ "qsv",
611
+ "d3d11va",
612
+ "dxva2",
613
+ "amf",
614
+ "vdpau",
615
+ "drm"
616
+ ];
617
+ function isHwAccelBackend(value) {
618
+ return HWACCEL_DECODE_BACKENDS.includes(value);
619
+ }
620
+ function narrowHwAccelPrefer(value) {
621
+ if (value === "none") return "none";
622
+ if (typeof value === "string" && isHwAccelBackend(value)) return value;
623
+ return null;
624
+ }
597
625
  var PlatformProbeNativeAddon = class extends _camstack_types.BaseAddon {
598
626
  scorer = null;
599
627
  encoderProber = null;
600
628
  decodeAccelProber = null;
601
629
  cachedCaps = null;
630
+ /**
631
+ * Per-boot generation stamp for the manual readiness emissions below.
632
+ * Constant for the lifetime of this addon instance (== one process boot);
633
+ * consumer-side registries derive a monotonic epoch from generation
634
+ * transitions. Mirrors `BaseAddon._readinessGeneration` (private there).
635
+ */
636
+ readinessGeneration = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2, 14);
602
637
  constructor() {
603
638
  super({});
604
639
  }
605
640
  /**
641
+ * Manual readiness protocol. The provider registers synchronously from
642
+ * `onInitialize`, but the REAL hardware + EP scoring is the async
643
+ * `probePromise` (embedded-Python install included). The BaseAddon
644
+ * auto-emit would flip `ready` at registration time — BEFORE any
645
+ * accelerator is visible — so probe-gated consumers (detection-pipeline
646
+ * engine auto-pick) would read accelerator-blind results and stick on
647
+ * onnx-CPU. Instead: `starting` at init, the single authoritative
648
+ * `ready` once the probe resolves, `down` on shutdown.
649
+ */
650
+ get autoEmitReadiness() {
651
+ return false;
652
+ }
653
+ /** Bare cluster node id — readiness is scoped `{type:'node', nodeId}`. */
654
+ bareLocalNodeId() {
655
+ const raw = this.ctx.kernel?.localNodeId ?? "hub";
656
+ return raw.includes("/") ? raw.split("/")[0] : raw;
657
+ }
658
+ /** Emit a `system.ready-state` transition for the platform-probe cap. */
659
+ emitProbeReadiness(state) {
660
+ const ctx = this.ctxIfReady;
661
+ if (!ctx) return;
662
+ const nodeId = this.bareLocalNodeId();
663
+ try {
664
+ (0, _camstack_types.emitReadiness)(ctx.eventBus, {
665
+ capName: _camstack_types.platformProbeCapability.name,
666
+ scope: {
667
+ type: "node",
668
+ nodeId
669
+ },
670
+ state,
671
+ generation: this.readinessGeneration,
672
+ sourceNodeId: nodeId
673
+ });
674
+ } catch {}
675
+ }
676
+ /**
606
677
  * Resolve the ffmpeg binary the encoder probe should test, from the cluster
607
678
  * `ffmpeg` config section (`binaryPath`). The probe MUST exercise the same
608
679
  * binary the broker/recorder spawn, or it may report encoders for a different
@@ -617,6 +688,7 @@ var PlatformProbeNativeAddon = class extends _camstack_types.BaseAddon {
617
688
  }
618
689
  }
619
690
  async onInitialize() {
691
+ this.emitProbeReadiness("starting");
620
692
  const embeddedPython = await this.ctx.deps.ensurePython().catch((err) => {
621
693
  this.ctx.logger.debug("ensurePython unavailable for platform probe", { meta: { error: (0, _camstack_types.errMsg)(err) } });
622
694
  return null;
@@ -651,6 +723,7 @@ var PlatformProbeNativeAddon = class extends _camstack_types.BaseAddon {
651
723
  bestReason: caps.bestScore.reason,
652
724
  bestScore: caps.bestScore.score
653
725
  } });
726
+ this.emitProbeReadiness("ready");
654
727
  return caps;
655
728
  }).catch((err) => {
656
729
  const msg = (0, _camstack_types.errMsg)(err);
@@ -676,8 +749,15 @@ var PlatformProbeNativeAddon = class extends _camstack_types.BaseAddon {
676
749
  },
677
750
  resolveHwAccel: async (input) => {
678
751
  const hwaccel = this.ctx.kernel.hwaccel;
679
- if (!hwaccel) return { preferred: [] };
680
- return { preferred: (await hwaccel.resolve(input.prefer ?? null)).preferred };
752
+ if (!hwaccel) return {
753
+ preferred: [],
754
+ rationale: "kernel hwaccel unavailable"
755
+ };
756
+ const res = await hwaccel.resolve(narrowHwAccelPrefer(input.prefer));
757
+ return {
758
+ preferred: res.preferred,
759
+ rationale: res.rationale
760
+ };
681
761
  },
682
762
  getHardwareEncoders: async () => {
683
763
  const prober = this.encoderProber;
@@ -707,6 +787,7 @@ var PlatformProbeNativeAddon = class extends _camstack_types.BaseAddon {
707
787
  }];
708
788
  }
709
789
  async onShutdown() {
790
+ this.emitProbeReadiness("down");
710
791
  this.scorer = null;
711
792
  this.encoderProber = null;
712
793
  this.decodeAccelProber = null;