@camstack/system 1.1.23 → 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.
@@ -1,5 +1,5 @@
1
1
  const require_chunk = require("./chunk-Cek0wNdY.js");
2
- const require_manifest_python_deps = require("./manifest-python-deps-D7iR07uA.js");
2
+ const require_manifest_python_deps = require("./manifest-python-deps-XWJwKYDx.js");
3
3
  const require_custom_action_registry = require("./custom-action-registry-vLYEFTtv.js");
4
4
  let node_fs = require("node:fs");
5
5
  node_fs = require_chunk.__toESM(node_fs);
@@ -1,4 +1,4 @@
1
- import { I as createUdsLoggerWithControl, L as LocalChildClient, at as setWorkerNativeCapsChangeListener, bt as resolveAddonClass, i as createUdsAddonContext, nt as getWorkerNativeCapProvider, rt as getWorkerNativeCapSnapshot, st as validateProviderRegistrations, t as installManifestPythonDeps, yt as installManifestNativeDeps } from "./manifest-python-deps-hDWMDe_b.mjs";
1
+ import { I as createUdsLoggerWithControl, L as LocalChildClient, at as setWorkerNativeCapsChangeListener, bt as resolveAddonClass, i as createUdsAddonContext, nt as getWorkerNativeCapProvider, rt as getWorkerNativeCapSnapshot, st as validateProviderRegistrations, t as installManifestPythonDeps, yt as installManifestNativeDeps } from "./manifest-python-deps-CPJXzrZt.mjs";
2
2
  import { t as CustomActionRegistry } from "./custom-action-registry-BEXwC-oo.mjs";
3
3
  import { register } from "node:module";
4
4
  import * as fs from "node:fs";
@@ -2,7 +2,12 @@ import { DeviceLink } from '@camstack/types';
2
2
  /** A target link with its source already resolved to a live numeric id. */
3
3
  export interface ResolvedTargetLink {
4
4
  readonly link: DeviceLink;
5
+ /** Resolved source device id for scalar sources; `-1` sentinel for a literal
6
+ * source AND for an expression source (whose per-binding ids live below). */
5
7
  readonly sourceDeviceId: number;
8
+ /** Expression links only: binding name → resolved source device id. Literal
9
+ * bindings and unresolved bindings are ABSENT from the map. */
10
+ readonly bindingSourceIds?: Readonly<Record<string, number>>;
6
11
  }
7
12
  /** A target that depends on a given source slice (for reactive re-emit). */
8
13
  export interface DependentLink {
@@ -15,9 +20,14 @@ export interface LinkIndexes {
15
20
  /** `${sourceDeviceId}:${sourceCap}` → targets to recompute on a source change. */
16
21
  readonly dependents: Map<string, DependentLink[]>;
17
22
  }
23
+ /** One resolved link entry fed to `buildLinkIndexes`. */
24
+ export interface LinkIndexEntry {
25
+ readonly targetDeviceId: number;
26
+ readonly link: DeviceLink;
27
+ /** Resolved scalar source id; `-1` for literal/expression sources. */
28
+ readonly sourceDeviceId: number;
29
+ /** Expression links: binding name → resolved source device id. */
30
+ readonly bindingSourceIds?: Readonly<Record<string, number>>;
31
+ }
18
32
  /** Build both lookup maps from resolved link entries. Pure — order-preserving. */
19
- export declare function buildLinkIndexes(entries: ReadonlyArray<{
20
- targetDeviceId: number;
21
- link: DeviceLink;
22
- sourceDeviceId: number;
23
- }>): LinkIndexes;
33
+ export declare function buildLinkIndexes(entries: ReadonlyArray<LinkIndexEntry>): LinkIndexes;
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { CapabilityStatusItemArray, DeviceLink } from '@camstack/types';
3
+ import { ResolvedTargetLink } from './device-link-overlay.js';
3
4
  /** One resolved source reading paired with the link that produced it. The
4
5
  * `sourceValue` is already transformed; `undefined` skips the overlay. */
5
6
  export interface ResolvedLinkValue {
@@ -16,6 +17,16 @@ export interface ResolvedLinkValue {
16
17
  * `source.kind` directly.
17
18
  */
18
19
  export declare function resolveLinkValue(link: DeviceLink, readField: (cap: string, fieldPath: string) => unknown): unknown;
20
+ /**
21
+ * Sync-channel expression resolution: bindings read from the mirror via
22
+ * `readField(deviceId, cap, fieldPath)` using the pre-resolved per-binding ids
23
+ * on the `ResolvedTargetLink`. A literal binding uses its constant; an absent
24
+ * binding id (dangling source) or a non-primitive read resolves to `null`.
25
+ * Returns `undefined` on parse/eval failure (skip overlay) — silent by design:
26
+ * the mirror path is hot and churn-free; the async channel logs. Compile
27
+ * failures cost one parse ever thanks to the negative LRU compile cache. Pure.
28
+ */
29
+ export declare function resolveExpressionLinkValue(rl: ResolvedTargetLink, readField: (deviceId: number, cap: string, fieldPath: string) => unknown, now: number): unknown;
19
30
  /**
20
31
  * Overlay transformed source values onto `base` by dot-path, then validate the
21
32
  * result against the target cap's `statusSchema`. On validation failure the
@@ -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) {
@@ -1371,20 +1372,41 @@ var DeviceEventPropagator = class {
1371
1372
  function nodeKey(deviceId, cap) {
1372
1373
  return `${deviceId}:${cap}`;
1373
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
+ }
1374
1390
  /**
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.
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 edgethey cannot
1395
+ * loop while dangling; a cycle that only becomes resolvable later is bounded by
1396
+ * the resolve-time re-entrancy guard.
1380
1397
  */
1381
- function resolveSourceNode(link, targetRow, rowById, idByStableId) {
1398
+ function resolveSourceNodes(link, targetRow, rowById, idByStableId) {
1382
1399
  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);
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] : [];
1388
1410
  }
1389
1411
  /**
1390
1412
  * Detect whether replacing `editedDeviceId`'s links with `editedLinks` closes
@@ -1402,11 +1424,11 @@ function findDeviceLinkCycle(allMeta, editedDeviceId, editedLinks) {
1402
1424
  const dependsOn = /* @__PURE__ */ new Map();
1403
1425
  const addEdges = (row, links) => {
1404
1426
  for (const link of links) {
1405
- const srcNode = resolveSourceNode(link, row, rowById, idByStableId);
1406
- if (srcNode === null) continue;
1427
+ const srcNodes = resolveSourceNodes(link, row, rowById, idByStableId);
1428
+ if (srcNodes.length === 0) continue;
1407
1429
  const tNode = nodeKey(row.id, link.target.cap);
1408
1430
  const list = dependsOn.get(tNode) ?? [];
1409
- list.push(srcNode);
1431
+ for (const srcNode of srcNodes) list.push(srcNode);
1410
1432
  dependsOn.set(tNode, list);
1411
1433
  }
1412
1434
  };
@@ -1473,7 +1495,7 @@ function dfsFindCycle(root, dependsOn) {
1473
1495
  * removeDevice), config persistence (persistConfig, loadConfig), the meta
1474
1496
  * surface load (loadMeta, loadRuntimeState), every meta setter (setName,
1475
1497
  * setLocation, setType, setIntegrationId, setLinkDeviceId,
1476
- * setPrimaryChildEntityId, setChildLayout, setDeviceLinks, setRole,
1498
+ * setPrimaryChildEntityId, setChildLayout, setDeviceLinks, setRole, setDisplay,
1477
1499
  * applyInitialMeta, setMetadata, setDisabled), and the location registry
1478
1500
  * (listLocations, addLocation, removeLocation).
1479
1501
  *
@@ -1582,6 +1604,7 @@ async function registerDevice(pctx, input) {
1582
1604
  ...existingMeta?.childLayout !== void 0 ? { childLayout: existingMeta.childLayout } : {},
1583
1605
  ...existingMeta?.deviceLinks !== void 0 ? { deviceLinks: existingMeta.deviceLinks } : {},
1584
1606
  ...existingMeta?.role !== void 0 ? { role: existingMeta.role } : {},
1607
+ ...existingMeta?.display !== void 0 ? { display: existingMeta.display } : {},
1585
1608
  parentDeviceId,
1586
1609
  id,
1587
1610
  features: featuresArr,
@@ -2023,6 +2046,11 @@ async function setDeviceLinks(pctx, input) {
2023
2046
  const { deviceId, deviceLinks } = input;
2024
2047
  const cycle = findDeviceLinkCycle(await pctx.metaStore.readMeta(), deviceId, deviceLinks);
2025
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
+ }
2026
2054
  await pctx.metaStore.withMetaWriteLock(async () => {
2027
2055
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2028
2056
  if (!persisted) throw new Error(`[device-manager] setDeviceLinks: unknown device id=${deviceId}`);
@@ -2093,6 +2121,84 @@ async function setRole(pctx, input) {
2093
2121
  });
2094
2122
  }
2095
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
+ /**
2096
2202
  * Batched meta pre-seed. Applies every provided field to the
2097
2203
  * device's meta row in ONE read-modify-write under a single
2098
2204
  * `withMetaWriteLock` acquisition (one `deviceMeta` blob write),
@@ -2337,23 +2443,33 @@ async function removeLocation(pctx, input) {
2337
2443
  function buildLinkIndexes(entries) {
2338
2444
  const targets = /* @__PURE__ */ new Map();
2339
2445
  const dependents = /* @__PURE__ */ new Map();
2340
- for (const { targetDeviceId, link, sourceDeviceId } of entries) {
2446
+ for (const { targetDeviceId, link, sourceDeviceId, bindingSourceIds } of entries) {
2341
2447
  const tKey = `${targetDeviceId}:${link.target.cap}`;
2342
2448
  const tList = targets.get(tKey) ?? [];
2343
2449
  tList.push({
2344
2450
  link,
2345
- sourceDeviceId
2451
+ sourceDeviceId,
2452
+ ...bindingSourceIds !== void 0 ? { bindingSourceIds } : {}
2346
2453
  });
2347
2454
  targets.set(tKey, tList);
2348
- if (link.source.kind !== "literal") {
2349
- const sKey = `${sourceDeviceId}:${link.source.cap}`;
2455
+ const addDependent = (sourceId, sourceCap) => {
2456
+ const sKey = `${sourceId}:${sourceCap}`;
2350
2457
  const sList = dependents.get(sKey) ?? [];
2351
2458
  sList.push({
2352
2459
  targetDeviceId,
2353
2460
  targetCap: link.target.cap
2354
2461
  });
2355
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;
2356
2471
  }
2472
+ if (link.source.kind !== "literal") addDependent(sourceDeviceId, link.source.cap);
2357
2473
  }
2358
2474
  return {
2359
2475
  targets,
@@ -2508,6 +2624,23 @@ var DeviceMetaStore = class {
2508
2624
  if (container === void 0) continue;
2509
2625
  for (const link of targetMeta.deviceLinks ?? []) {
2510
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
+ }
2511
2644
  if (src.kind === "literal") {
2512
2645
  entries.push({
2513
2646
  targetDeviceId: targetId,
@@ -2625,6 +2758,7 @@ async function listAll(pctx, input) {
2625
2758
  ...m?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2626
2759
  ...m?.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2627
2760
  ...m?.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2761
+ ...m?.display !== void 0 ? { display: m.display } : {},
2628
2762
  ...(() => {
2629
2763
  const si = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
2630
2764
  return si !== void 0 ? { sourceInfo: si } : {};
@@ -2673,6 +2807,7 @@ async function getDevice(pctx, input) {
2673
2807
  ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2674
2808
  ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2675
2809
  ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2810
+ ...m.display !== void 0 ? { display: m.display } : {},
2676
2811
  ...sourceInfoGetDevice !== void 0 ? { sourceInfo: sourceInfoGetDevice } : {}
2677
2812
  };
2678
2813
  }
@@ -2735,6 +2870,7 @@ async function getChildren(pctx, input) {
2735
2870
  ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2736
2871
  ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2737
2872
  ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2873
+ ...m.display !== void 0 ? { display: m.display } : {},
2738
2874
  ...sourceInfoChild !== void 0 ? { sourceInfo: sourceInfoChild } : {}
2739
2875
  });
2740
2876
  }
@@ -3074,7 +3210,37 @@ function isRecord(x) {
3074
3210
  * `source.kind` directly.
3075
3211
  */
3076
3212
  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);
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;
3078
3244
  }
3079
3245
  /** Narrow Zod v4's structural `$ZodType` (returned by `.unwrap()`) back to the
3080
3246
  * concrete classic `z.ZodType`. Every runtime schema is a `z.ZodType`, so this
@@ -3395,9 +3561,10 @@ var DeviceStateMirror = class DeviceStateMirror {
3395
3561
  const raw = this.stateMirror.get(deviceId)?.get(cap) ?? null;
3396
3562
  const links = this.linkHost.linkTargets.get(`${deviceId}:${cap}`);
3397
3563
  if (!links || links.length === 0) return raw ? { ...raw } : null;
3564
+ const now = Date.now();
3398
3565
  const resolved = links.map((rl) => ({
3399
3566
  link: rl.link,
3400
- sourceValue: resolveLinkValue(rl.link, (srcCap, fieldPath) => (0, _camstack_types.getByPath)(this.stateMirror.get(rl.sourceDeviceId)?.get(srcCap), fieldPath))
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))
3401
3568
  }));
3402
3569
  const capStatus = this.linkHost.capabilityRegistry?.getDefinition(cap)?.status;
3403
3570
  const schema = capStatus?.schema;
@@ -3588,6 +3755,39 @@ async function resolveLinkedStatusInner(pctx, input) {
3588
3755
  const parentMeta = allMeta[String(parentId)];
3589
3756
  if (parentMeta) containerStableId = parentMeta.stableId;
3590
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
+ };
3591
3791
  const resolved = [];
3592
3792
  for (const link of links) {
3593
3793
  const src = link.source;
@@ -3598,27 +3798,46 @@ async function resolveLinkedStatusInner(pctx, input) {
3598
3798
  });
3599
3799
  continue;
3600
3800
  }
3601
- const srcId = src.kind === "global" ? pctx.metaStore.resolveGlobalSourceDeviceId(src.sourceStableId, allMeta) : pctx.metaStore.resolveSourceDeviceId(containerStableId, src.sourceKey, allMeta);
3602
- if (srcId === null) continue;
3603
- try {
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);
3606
- resolved.push({
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({
3607
3817
  link,
3608
- sourceValue: (0, _camstack_types.applyTransform)(raw, link.transform)
3818
+ sourceValue: (0, _camstack_types.applyTransform)(result.value, link.transform)
3609
3819
  });
3610
- } catch (err) {
3611
- pctx.host.ctx.logger.warn("resolveLinkedStatus: source read failed", {
3820
+ else pctx.host.ctx.logger.warn("resolveLinkedStatus: expression skipped", {
3612
3821
  tags: {
3613
3822
  deviceId,
3614
3823
  capName: cap
3615
3824
  },
3616
3825
  meta: {
3617
- sourceKey: src.kind === "global" ? src.sourceStableId : src.sourceKey,
3618
- error: err instanceof Error ? err.message : String(err)
3826
+ linkId: link.id,
3827
+ error: result.error
3619
3828
  }
3620
3829
  });
3830
+ continue;
3621
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
+ });
3622
3841
  }
3623
3842
  if (resolved.length === 0) return null;
3624
3843
  const synthesizing = !isRecord$1(baseStatus);
@@ -3931,6 +4150,9 @@ var DeviceManagerAddon = class extends _camstack_types.BaseAddon {
3931
4150
  setChildLayout: (input) => setChildLayout(pctx, input),
3932
4151
  setDeviceLinks: (input) => setDeviceLinks(pctx, input),
3933
4152
  setRole: (input) => setRole(pctx, input),
4153
+ setDisplay: (input) => setDisplay(pctx, input),
4154
+ getRoleDisplayDefaults: (input) => getRoleDisplayDefaults(pctx, input),
4155
+ setRoleDisplayDefaults: (input) => setRoleDisplayDefaults(pctx, input),
3934
4156
  applyInitialMeta: (input) => applyInitialMeta(pctx, input),
3935
4157
  setMetadata: (input) => setMetadata(pctx, input),
3936
4158
  setDisabled: (input) => setDisabled(pctx, input),
@@ -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;
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ const require_builtins_local_auth_local_auth_addon = require("./builtins/local-a
20
20
  require("./builtins/local-auth/index.js");
21
21
  const require_builtins_device_manager_device_manager_addon = require("./builtins/device-manager/device-manager.addon.js");
22
22
  require("./builtins/device-manager/index.js");
23
- const require_manifest_python_deps = require("./manifest-python-deps-D7iR07uA.js");
23
+ const require_manifest_python_deps = require("./manifest-python-deps-XWJwKYDx.js");
24
24
  const require_custom_action_registry = require("./custom-action-registry-vLYEFTtv.js");
25
25
  let _camstack_types_node = require("@camstack/types/node");
26
26
  let node_http = require("node:http");
package/dist/index.mjs CHANGED
@@ -18,7 +18,7 @@ import { LocalAuthAddon, a as require_ms, c as __esmMin, d as __toCommonJS, f as
18
18
  import "./builtins/local-auth/index.mjs";
19
19
  import { DeviceManagerAddon } from "./builtins/device-manager/device-manager.addon.mjs";
20
20
  import "./builtins/device-manager/index.mjs";
21
- import { $ as buildNativeCapProxy, A as createHubCapForwardService, B as AGENT_CAP_FWD_ACTION, C as brokerTransportLink, D as localProviderLink, E as ipcParentLink, F as createUdsLogger, G as callWithServiceDiscovery, H as CapRouteResolver, I as createUdsLoggerWithControl, J as UdsLocalTransportServer, K as createLocalTransport, L as LocalChildClient, M as createUdsEventBridge, N as createUdsEventBus, O as HUB_CAP_FWD_ACTION, P as udsChildLogToWorkerEntry, Q as encodeFrame, R as LocalChildRegistry, S as brokerCallForCap, T as ipcChildLink, U as CapRouteError, V as AGENT_CAP_FWD_SERVICE, W as classifyCapRoute, X as localEndpointPath, Y as SocketChannel, Z as FrameDecoder, _ as resolveHwAccel, _t as CapabilityHandle, a as getWorkerDeviceRegistry, b as __resetCapUsageRegistryForTests, bt as resolveAddonClass, c as setHubConnected, ct as NATIVE_PROVIDER_SERVICE_INFIX, d as getMoleculerEventStats, dt as capBareAction, et as buildUdsNativeCapProxy, f as registerEventBusService, ft as capServiceName, g as createKernelHwAccel, gt as DeviceRegistry, h as AddonDepsManager, ht as serializeTypedArrays, i as createUdsAddonContext, it as mountNativeCapService, j as createParentUnownedCallHandler, k as HUB_CAP_FWD_SERVICE, l as EVENT_TOPIC_PREFIX, lt as capActionName, m as subscribePassthrough, mt as deserializeTypedArrays, n as adaptBrokerToCluster, o as getOrInitReadinessRegistry, ot as createAddonService, p as setNodeEventInterest, pt as parseCapAction, q as UdsLocalTransportClient, r as createAddonContext, s as getOrInitReadinessRegistryForClient, st as validateProviderRegistrations, t as installManifestPythonDeps, tt as createBrokerDeviceManagerApi, u as getBrokerEventBus, ut as capActionSuffix, v as createHwAccelService, vt as CapabilityUnavailableError, w as buildLinkChain, x as getCapUsageRegistry, y as CapUsageRegistry, yt as installManifestNativeDeps, z as UDS_NO_ROUTE_PREFIX } from "./manifest-python-deps-hDWMDe_b.mjs";
21
+ import { $ as buildNativeCapProxy, A as createHubCapForwardService, B as AGENT_CAP_FWD_ACTION, C as brokerTransportLink, D as localProviderLink, E as ipcParentLink, F as createUdsLogger, G as callWithServiceDiscovery, H as CapRouteResolver, I as createUdsLoggerWithControl, J as UdsLocalTransportServer, K as createLocalTransport, L as LocalChildClient, M as createUdsEventBridge, N as createUdsEventBus, O as HUB_CAP_FWD_ACTION, P as udsChildLogToWorkerEntry, Q as encodeFrame, R as LocalChildRegistry, S as brokerCallForCap, T as ipcChildLink, U as CapRouteError, V as AGENT_CAP_FWD_SERVICE, W as classifyCapRoute, X as localEndpointPath, Y as SocketChannel, Z as FrameDecoder, _ as resolveHwAccel, _t as CapabilityHandle, a as getWorkerDeviceRegistry, b as __resetCapUsageRegistryForTests, bt as resolveAddonClass, c as setHubConnected, ct as NATIVE_PROVIDER_SERVICE_INFIX, d as getMoleculerEventStats, dt as capBareAction, et as buildUdsNativeCapProxy, f as registerEventBusService, ft as capServiceName, g as createKernelHwAccel, gt as DeviceRegistry, h as AddonDepsManager, ht as serializeTypedArrays, i as createUdsAddonContext, it as mountNativeCapService, j as createParentUnownedCallHandler, k as HUB_CAP_FWD_SERVICE, l as EVENT_TOPIC_PREFIX, lt as capActionName, m as subscribePassthrough, mt as deserializeTypedArrays, n as adaptBrokerToCluster, o as getOrInitReadinessRegistry, ot as createAddonService, p as setNodeEventInterest, pt as parseCapAction, q as UdsLocalTransportClient, r as createAddonContext, s as getOrInitReadinessRegistryForClient, st as validateProviderRegistrations, t as installManifestPythonDeps, tt as createBrokerDeviceManagerApi, u as getBrokerEventBus, ut as capActionSuffix, v as createHwAccelService, vt as CapabilityUnavailableError, w as buildLinkChain, x as getCapUsageRegistry, y as CapUsageRegistry, yt as installManifestNativeDeps, z as UDS_NO_ROUTE_PREFIX } from "./manifest-python-deps-CPJXzrZt.mjs";
22
22
  import { t as CustomActionRegistry } from "./custom-action-registry-BEXwC-oo.mjs";
23
23
  import { PYTHON_VERSION, buildBinaryPath, downloadBinary, ensureBinary, ensureFfmpeg, ensurePython, findInPath, getFfmpegDownloadUrl, getPlatformInfo, getPythonDownloadUrl, installPythonPackages, installPythonRequirements } from "@camstack/types/node";
24
24
  import { request } from "node:http";
@@ -1423,6 +1423,18 @@ function createBrokerDeviceManagerApi(opts) {
1423
1423
  meta: { error: err instanceof Error ? err.message : String(err) }
1424
1424
  });
1425
1425
  });
1426
+ if (initialMeta.display !== void 0) await callDeviceManager(api, "setDisplay", {
1427
+ deviceId: id,
1428
+ display: initialMeta.display
1429
+ }).catch((err) => {
1430
+ opts.logger.warn("create: setDisplay pre-seed failed", {
1431
+ tags: {
1432
+ stableId,
1433
+ deviceId: id
1434
+ },
1435
+ meta: { error: err instanceof Error ? err.message : String(err) }
1436
+ });
1437
+ });
1426
1438
  }
1427
1439
  if (Object.keys(config).length > 0) await callDeviceManager(api, "persistConfig", {
1428
1440
  deviceId: id,
@@ -1425,6 +1425,18 @@ function createBrokerDeviceManagerApi(opts) {
1425
1425
  meta: { error: err instanceof Error ? err.message : String(err) }
1426
1426
  });
1427
1427
  });
1428
+ if (initialMeta.display !== void 0) await callDeviceManager(api, "setDisplay", {
1429
+ deviceId: id,
1430
+ display: initialMeta.display
1431
+ }).catch((err) => {
1432
+ opts.logger.warn("create: setDisplay pre-seed failed", {
1433
+ tags: {
1434
+ stableId,
1435
+ deviceId: id
1436
+ },
1437
+ meta: { error: err instanceof Error ? err.message : String(err) }
1438
+ });
1439
+ });
1428
1440
  }
1429
1441
  if (Object.keys(config).length > 0) await callDeviceManager(api, "persistConfig", {
1430
1442
  deviceId: id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.1.23",
3
+ "version": "1.1.24",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",