@camstack/ui-library 1.2.325 → 1.2.326

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.
@@ -4,7 +4,5 @@ import type { ControlPanelProps } from "./types.js";
4
4
  export declare function toDateInput(value: string): string;
5
5
  /** Trim an HA time value ('HH:MM:SS') to the `<input type=time>` shape ('HH:MM'). */
6
6
  export declare function toTimeInput(value: string): string;
7
- /** Trim an HA datetime value to the `<input type=datetime-local>` shape
8
- * ('YYYY-MM-DDTHH:MM'). Accepts both 'T'- and space-separated forms. */
9
7
  export declare function toDateTimeInput(value: string): string;
10
8
  export declare function ControlPanel({ trpc, deviceId, features: _features, layout, optimistic }: ControlPanelProps): ReactNode;
@@ -20,7 +20,7 @@
20
20
  * at the call site; narrowing occurs after `useDeviceStateSlice`.
21
21
  */
22
22
  import type { ReactNode } from "react";
23
- import type { DeviceProxy, SliceHandle } from "@camstack/types";
23
+ import type { DeviceProxy, ReadingQuantity, SliceHandle } from "@camstack/types";
24
24
  import { type UseDeviceProxyTrpc } from "../../hooks/use-device-proxy";
25
25
  import { type ControlDateTimeFormat } from "../../lib/format-control-datetime";
26
26
  import type { DeviceItemDevice } from "../device-item/helpers";
@@ -28,12 +28,8 @@ interface NumericDescriptor {
28
28
  readonly kind: "numeric";
29
29
  /** Field in the slice that holds the numeric value. */
30
30
  readonly field: string;
31
- /** Canonical display unit for TYPED roles (temperature → '°C', humidity → '%',
32
- * ambient-light → 'lx', pressure → 'hPa'). When present the UI renders this
33
- * directly WITHOUT consulting the slice or sourceInfo.
34
- * For the generic `numeric-sensor` role this is `undefined` — the unit is
35
- * read live from the slice (`slice.unit`). */
36
- readonly canonicalUnit?: string;
31
+ /** Quantity of the stored number. `device` reads `sourceInfo.quantity`. */
32
+ readonly quantity?: ReadingQuantity;
37
33
  /** Accessor into DeviceProxy['state'] — returns a typed slice handle. */
38
34
  readonly getSliceHandle: (state: DeviceProxy["state"]) => SliceHandle<unknown>;
39
35
  }
@@ -99,13 +95,9 @@ export declare function hasBatteryNumericValue(slice: unknown, field: string): b
99
95
  * avoids dynamic string indexing — each one is a concrete property
100
96
  * access on the typed `DeviceProxy['state']` shape.
101
97
  *
102
- * Canonical unit rules:
103
- * - Typed roles (temperature/humidity/ambient-light/pressure/power/energy)
104
- * carry a `canonicalUnit` constant. The slice is normalised to one unit
105
- * so no per-device lookup is needed.
106
- * - Generic `numeric-sensor` has no `canonicalUnit`. The unit is read live
107
- * from the slice (`slice.unit`) as populated by the provider from the
108
- * upstream source on every state push.
98
+ * The unit comes from the descriptor's quantity through `resolveSensorDisplay`.
99
+ * A generic numeric sensor uses quantity `device` and the device's
100
+ * `sourceInfo.quantity`.
109
101
  */
110
102
  export declare const ROLE_DESCRIPTOR: Record<string, SensorDescriptor>;
111
103
  export interface StringSliceValue {
package/dist/index.cjs CHANGED
@@ -20973,21 +20973,16 @@ Skeleton.displayName = "Skeleton";
20973
20973
  /** `value` null = unknown (D393) — renders an em-dash, never a fake `0`. */
20974
20974
  function formatRemaining(t, r) {
20975
20975
  if (r.value === null) return "—";
20976
- const count = r.value;
20977
- switch (r.unit) {
20978
- case "days": return t("uiLibrary.consumablesPanel.days", {
20979
- one: "{{count}} day",
20980
- other: "{{count}} days"
20981
- }, { count });
20982
- case "hours": return t("uiLibrary.consumablesPanel.hours", {
20983
- one: "{{count}} hour",
20984
- other: "{{count}} hours"
20985
- }, { count });
20986
- case "cycles": return t("uiLibrary.consumablesPanel.cycles", {
20987
- one: "{{count}} cycle",
20988
- other: "{{count}} cycles"
20989
- }, { count });
20990
- }
20976
+ if (r.kind === "count") return t("uiLibrary.consumablesPanel.cycles", {
20977
+ one: "{{count}} cycle",
20978
+ other: "{{count}} cycles"
20979
+ }, { count: r.value });
20980
+ const rendered = (0, _camstack_types.formatReading)({
20981
+ value: r.value,
20982
+ quantity: "duration",
20983
+ precision: 0
20984
+ });
20985
+ return rendered.unit.length === 0 ? rendered.text : `${rendered.text} ${rendered.unit}`;
20991
20986
  }
20992
20987
  /** Format a ms-epoch as a compact local date for the "replaced" hint. */
20993
20988
  function formatResetAt(ms) {
@@ -46083,6 +46078,66 @@ function formatNumeric(value, precision) {
46083
46078
  return Number.isInteger(value) ? String(value) : value.toFixed(1);
46084
46079
  }
46085
46080
  //#endregion
46081
+ //#region src/lib/resolve-sensor-display.ts
46082
+ function canonUnit(raw) {
46083
+ if (raw === void 0) return void 0;
46084
+ return (0, _camstack_types.normalizeUnit)(raw) ?? raw;
46085
+ }
46086
+ function concreteQuantity(input) {
46087
+ if (input.quantity === void 0) return void 0;
46088
+ if (input.quantity === "device") {
46089
+ const chosen = input.deviceQuantity;
46090
+ if (chosen === void 0 || chosen === "device") return void 0;
46091
+ return chosen;
46092
+ }
46093
+ return input.quantity;
46094
+ }
46095
+ function resolveSensorDisplay(input) {
46096
+ const perCap = input.capName !== void 0 ? input.display?.perCap?.[input.capName] : void 0;
46097
+ const precision = perCap?.precision ?? input.display?.precision ?? input.roleDefault?.precision ?? input.declarationPrecision;
46098
+ const quantity = concreteQuantity(input);
46099
+ const preference = input.preference ?? _camstack_types.DEFAULT_DISPLAY_PREFERENCE;
46100
+ const base = quantity === void 0 ? void 0 : (0, _camstack_types.baseUnitOf)(quantity);
46101
+ const preferred = quantity === void 0 ? void 0 : (0, _camstack_types.displayUnitFor)(quantity, preference);
46102
+ const overrideUnit = canonUnit(perCap?.unit ?? input.display?.unit ?? input.roleDefault?.unit);
46103
+ const overrideOk = overrideUnit !== void 0 && base !== void 0 && overrideUnit !== base && (0, _camstack_types.canConvertUnit)(base, overrideUnit);
46104
+ const unit = overrideOk ? overrideUnit : preferred ?? "";
46105
+ const scales = quantity === "duration" || quantity === "information" || quantity === "duration-ms";
46106
+ const toDisplayValue = (raw) => {
46107
+ if (scales || base === void 0 || unit.length === 0 || unit === base) return raw;
46108
+ return (0, _camstack_types.tryConvertUnit)(raw, base, unit) ?? raw;
46109
+ };
46110
+ const fromDisplayValue = (shown) => {
46111
+ if (scales || base === void 0 || unit.length === 0 || unit === base) return shown;
46112
+ return (0, _camstack_types.tryConvertUnit)(shown, unit, base) ?? shown;
46113
+ };
46114
+ const format = (raw) => {
46115
+ if (quantity !== void 0 && !overrideOk) {
46116
+ const rendered = (0, _camstack_types.formatReading)({
46117
+ value: raw,
46118
+ quantity,
46119
+ preference,
46120
+ precision
46121
+ });
46122
+ return {
46123
+ text: rendered.text,
46124
+ unit: rendered.unit
46125
+ };
46126
+ }
46127
+ return {
46128
+ text: formatNumeric(toDisplayValue(raw), precision),
46129
+ unit
46130
+ };
46131
+ };
46132
+ return {
46133
+ unit,
46134
+ precision,
46135
+ toDisplayValue,
46136
+ fromDisplayValue,
46137
+ format
46138
+ };
46139
+ }
46140
+ //#endregion
46086
46141
  //#region src/lib/format-control-datetime.ts
46087
46142
  var DATE_RE = /^(\d{4})-(\d{2})-(\d{2})/;
46088
46143
  var TIME_RE = /^(\d{2}):(\d{2})(?::(\d{2}))?$/;
@@ -46148,8 +46203,20 @@ function toDateInput(value) {
46148
46203
  function toTimeInput(value) {
46149
46204
  return value.slice(0, 5);
46150
46205
  }
46151
- /** Trim an HA datetime value to the `<input type=datetime-local>` shape
46152
- * ('YYYY-MM-DDTHH:MM'). Accepts both 'T'- and space-separated forms. */
46206
+ function datetimeEditor(format, rawValue) {
46207
+ if (format === "date") return {
46208
+ type: "date",
46209
+ value: toDateInput(rawValue)
46210
+ };
46211
+ if (format === "time") return {
46212
+ type: "time",
46213
+ value: toTimeInput(rawValue)
46214
+ };
46215
+ return {
46216
+ type: "datetime-local",
46217
+ value: toDateTimeInput(rawValue)
46218
+ };
46219
+ }
46153
46220
  function toDateTimeInput(value) {
46154
46221
  return (value.includes("T") ? value : value.replace(" ", "T")).slice(0, 16);
46155
46222
  }
@@ -46242,24 +46309,35 @@ function TextControl({ value, busy, onCommit }) {
46242
46309
  className: cn("rounded border border-border bg-surface px-2 py-0.5 text-xs", busy && "opacity-40 cursor-not-allowed")
46243
46310
  });
46244
46311
  }
46245
- function ControlWidget({ slice, busy, setValueFn, layout }) {
46312
+ function ControlWidget({ slice, busy, setValueFn, layout, quantity, display, dateTimeShape }) {
46246
46313
  const t = useUiText();
46247
46314
  switch (slice.kind) {
46248
- case "numeric": return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NumericControl, {
46249
- value: typeof slice.value === "number" ? slice.value : Number(slice.value),
46250
- busy,
46251
- onCommit: (v) => {
46252
- setValueFn({ control: {
46253
- kind: "numeric",
46254
- value: v
46255
- } });
46256
- },
46257
- unit: slice.unit,
46258
- min: slice.min,
46259
- max: slice.max,
46260
- step: slice.step,
46261
- precision: slice.precision
46262
- });
46315
+ case "numeric": {
46316
+ const numericValue = typeof slice.value === "number" ? slice.value : Number(slice.value);
46317
+ const resolved = quantity === void 0 ? void 0 : resolveSensorDisplay({
46318
+ quantity,
46319
+ display,
46320
+ capName: "control"
46321
+ });
46322
+ const toShown = resolved?.toDisplayValue ?? ((raw) => raw);
46323
+ const toBase = resolved?.fromDisplayValue ?? ((shown) => shown);
46324
+ const unit = resolved !== void 0 && resolved.unit.length > 0 ? resolved.unit : void 0;
46325
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NumericControl, {
46326
+ value: toShown(numericValue),
46327
+ busy,
46328
+ onCommit: (v) => {
46329
+ setValueFn({ control: {
46330
+ kind: "numeric",
46331
+ value: toBase(v)
46332
+ } });
46333
+ },
46334
+ unit,
46335
+ min: slice.min === void 0 ? void 0 : toShown(slice.min),
46336
+ max: slice.max === void 0 ? void 0 : toShown(slice.max),
46337
+ step: slice.step === void 0 ? void 0 : toShown(slice.step),
46338
+ precision: resolved?.precision
46339
+ });
46340
+ }
46263
46341
  case "select": {
46264
46342
  const selectValue = String(slice.value);
46265
46343
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
@@ -46292,7 +46370,7 @@ function ControlWidget({ slice, busy, setValueFn, layout }) {
46292
46370
  });
46293
46371
  case "datetime": {
46294
46372
  const rawValue = String(slice.value);
46295
- const format = slice.format ?? "datetime";
46373
+ const format = dateTimeShape ?? "datetime";
46296
46374
  if (layout === "compact") {
46297
46375
  if (rawValue === "") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
46298
46376
  "aria-label": t("uiLibrary.controlPanel.value", "Control value"),
@@ -46305,8 +46383,9 @@ function ControlWidget({ slice, busy, setValueFn, layout }) {
46305
46383
  children: formatControlDateTime(rawValue, format)
46306
46384
  });
46307
46385
  }
46308
- const inputType = format === "date" ? "date" : format === "time" ? "time" : "datetime-local";
46309
- const inputValue = format === "date" ? toDateInput(rawValue) : format === "time" ? toTimeInput(rawValue) : toDateTimeInput(rawValue);
46386
+ const editor = datetimeEditor(format, rawValue);
46387
+ const inputType = editor.type;
46388
+ const inputValue = editor.value;
46310
46389
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
46311
46390
  type: inputType,
46312
46391
  "aria-label": t("uiLibrary.controlPanel.value", "Control value"),
@@ -46326,6 +46405,7 @@ function ControlWidget({ slice, busy, setValueFn, layout }) {
46326
46405
  }
46327
46406
  }
46328
46407
  function ControlPanel({ trpc, deviceId, features: _features, layout, optimistic }) {
46408
+ const info = useOptionalSystem()?.getDeviceInfo(deviceId) ?? null;
46329
46409
  const { cap, slice, available } = useDeviceCapability({
46330
46410
  trpc,
46331
46411
  deviceId,
@@ -46335,6 +46415,8 @@ function ControlPanel({ trpc, deviceId, features: _features, layout, optimistic
46335
46415
  const { display, applyOptimistic } = useOptimisticSlice(slice, { enabled: optimistic ?? true });
46336
46416
  if (display === void 0) return null;
46337
46417
  const busy = !available;
46418
+ const chosen = info?.sourceInfo?.quantity;
46419
+ const quantity = (0, _camstack_types.isReadingQuantity)(chosen) && chosen !== "device" && chosen !== "none" ? chosen : void 0;
46338
46420
  const setValueFn = (input) => {
46339
46421
  if (!busy && cap) {
46340
46422
  applyOptimistic({ value: input.control.value });
@@ -46348,7 +46430,10 @@ function ControlPanel({ trpc, deviceId, features: _features, layout, optimistic
46348
46430
  slice: display,
46349
46431
  busy,
46350
46432
  setValueFn,
46351
- layout
46433
+ layout,
46434
+ quantity,
46435
+ display: info?.display,
46436
+ dateTimeShape: info?.sourceInfo?.dateTimeShape
46352
46437
  })
46353
46438
  });
46354
46439
  }
@@ -46452,30 +46537,6 @@ function PopoverRowAction({ icon: Icon, title, children }) {
46452
46537
  }), document.body)] });
46453
46538
  }
46454
46539
  //#endregion
46455
- //#region src/lib/resolve-sensor-display.ts
46456
- /** Table keys are canonical spellings; providers often send `C` / `F` / `celsius`. */
46457
- function canonUnit(raw) {
46458
- if (raw === void 0) return void 0;
46459
- return (0, _camstack_types.normalizeUnit)(raw) ?? raw;
46460
- }
46461
- var identity = (raw) => raw;
46462
- function resolveSensorDisplay(input) {
46463
- const perCap = input.capName !== void 0 ? input.display?.perCap?.[input.capName] : void 0;
46464
- const precision = perCap?.precision ?? input.display?.precision ?? input.roleDefault?.precision ?? input.slicePrecision;
46465
- const sourceUnit = canonUnit(input.sliceUnit ?? input.canonicalUnit);
46466
- const overrideUnit = canonUnit(perCap?.unit ?? input.display?.unit ?? input.roleDefault?.unit);
46467
- if (overrideUnit !== void 0 && sourceUnit !== void 0 && overrideUnit !== sourceUnit && (0, _camstack_types.canConvertUnit)(sourceUnit, overrideUnit)) return {
46468
- unit: overrideUnit,
46469
- precision,
46470
- toDisplayValue: (raw) => (0, _camstack_types.tryConvertUnit)(raw, sourceUnit, overrideUnit) ?? raw
46471
- };
46472
- return {
46473
- unit: sourceUnit ?? "",
46474
- precision,
46475
- toDisplayValue: identity
46476
- };
46477
- }
46478
- //#endregion
46479
46540
  //#region src/composites/device-controls/sensor-value-atom.tsx
46480
46541
  /**
46481
46542
  * Binary fallback for the `battery-sensor` role.
@@ -46516,102 +46577,105 @@ function hasBatteryNumericValue(slice, field) {
46516
46577
  * avoids dynamic string indexing — each one is a concrete property
46517
46578
  * access on the typed `DeviceProxy['state']` shape.
46518
46579
  *
46519
- * Canonical unit rules:
46520
- * - Typed roles (temperature/humidity/ambient-light/pressure/power/energy)
46521
- * carry a `canonicalUnit` constant. The slice is normalised to one unit
46522
- * so no per-device lookup is needed.
46523
- * - Generic `numeric-sensor` has no `canonicalUnit`. The unit is read live
46524
- * from the slice (`slice.unit`) as populated by the provider from the
46525
- * upstream source on every state push.
46580
+ * The unit comes from the descriptor's quantity through `resolveSensorDisplay`.
46581
+ * A generic numeric sensor uses quantity `device` and the device's
46582
+ * `sourceInfo.quantity`.
46526
46583
  */
46527
46584
  var ROLE_DESCRIPTOR = {
46528
46585
  "temperature-sensor": {
46529
46586
  kind: "numeric",
46530
46587
  field: "celsius",
46531
- canonicalUnit: "°C",
46588
+ quantity: "temperature",
46532
46589
  getSliceHandle: (s) => s.temperatureSensor
46533
46590
  },
46534
46591
  "humidity-sensor": {
46535
46592
  kind: "numeric",
46536
46593
  field: "percent",
46537
- canonicalUnit: "%",
46594
+ quantity: "humidity",
46538
46595
  getSliceHandle: (s) => s.humiditySensor
46539
46596
  },
46540
46597
  "ambient-light-sensor": {
46541
46598
  kind: "numeric",
46542
46599
  field: "lux",
46543
- canonicalUnit: "lx",
46600
+ quantity: "illuminance",
46544
46601
  getSliceHandle: (s) => s.ambientLightSensor
46545
46602
  },
46546
46603
  "pressure-sensor": {
46547
46604
  kind: "numeric",
46548
46605
  field: "hpa",
46549
- canonicalUnit: "hPa",
46606
+ quantity: "pressure",
46550
46607
  getSliceHandle: (s) => s.pressureSensor
46551
46608
  },
46552
46609
  "numeric-sensor": {
46553
46610
  kind: "numeric",
46554
46611
  field: "value",
46612
+ quantity: "device",
46555
46613
  getSliceHandle: (s) => s.numericSensor
46556
46614
  },
46557
46615
  "wind-sensor": {
46558
46616
  kind: "numeric",
46559
46617
  field: "value",
46618
+ quantity: "device",
46560
46619
  getSliceHandle: (s) => s.numericSensor
46561
46620
  },
46562
46621
  "rain-sensor": {
46563
46622
  kind: "numeric",
46564
46623
  field: "value",
46624
+ quantity: "device",
46565
46625
  getSliceHandle: (s) => s.numericSensor
46566
46626
  },
46567
46627
  "uv-sensor": {
46568
46628
  kind: "numeric",
46569
46629
  field: "value",
46630
+ quantity: "device",
46570
46631
  getSliceHandle: (s) => s.numericSensor
46571
46632
  },
46572
46633
  "solar-radiation-sensor": {
46573
46634
  kind: "numeric",
46574
46635
  field: "value",
46636
+ quantity: "device",
46575
46637
  getSliceHandle: (s) => s.numericSensor
46576
46638
  },
46577
46639
  "soil-moisture-sensor": {
46578
46640
  kind: "numeric",
46579
46641
  field: "value",
46642
+ quantity: "device",
46580
46643
  getSliceHandle: (s) => s.numericSensor
46581
46644
  },
46582
46645
  "power-sensor": {
46583
46646
  kind: "numeric",
46584
46647
  field: "watts",
46585
- canonicalUnit: "W",
46648
+ quantity: "power",
46586
46649
  getSliceHandle: (s) => s.powerMeter
46587
46650
  },
46588
46651
  "energy-sensor": {
46589
46652
  kind: "numeric",
46590
46653
  field: "kwhTotal",
46591
- canonicalUnit: "kWh",
46654
+ quantity: "energy",
46592
46655
  getSliceHandle: (s) => s.powerMeter
46593
46656
  },
46594
46657
  "voltage-sensor": {
46595
46658
  kind: "numeric",
46596
46659
  field: "volts",
46597
- canonicalUnit: "V",
46660
+ quantity: "voltage",
46598
46661
  getSliceHandle: (s) => s.powerMeter
46599
46662
  },
46600
46663
  "current-sensor": {
46601
46664
  kind: "numeric",
46602
46665
  field: "amps",
46603
- canonicalUnit: "A",
46666
+ quantity: "current",
46604
46667
  getSliceHandle: (s) => s.powerMeter
46605
46668
  },
46606
46669
  "battery-sensor": {
46607
46670
  kind: "numeric",
46608
46671
  field: "value",
46609
- canonicalUnit: "%",
46672
+ quantity: "percentage",
46610
46673
  getSliceHandle: (s) => s.numericSensor
46611
46674
  },
46612
46675
  "generic-sensor": {
46613
46676
  kind: "numeric",
46614
46677
  field: "value",
46678
+ quantity: "device",
46615
46679
  getSliceHandle: (s) => s.numericSensor
46616
46680
  },
46617
46681
  "motion-sensor": {
@@ -46729,19 +46793,16 @@ function SensorValueAtom({ trpc, device }) {
46729
46793
  children: enumSensorSlice.value
46730
46794
  });
46731
46795
  if (numericIsColStart) return null;
46732
- const sliceRecord = slice;
46733
- const liveUnit = typeof sliceRecord["unit"] === "string" ? sliceRecord["unit"] : void 0;
46734
- const slicePrecision = typeof sliceRecord["precision"] === "number" ? sliceRecord["precision"] : void 0;
46735
- const resolved = resolveSensorDisplay({
46796
+ const deviceQuantity = device.sourceInfo?.quantity;
46797
+ const rendered = resolveSensorDisplay({
46736
46798
  display: device.display,
46737
46799
  capName: device.role === "numeric-sensor" ? "numeric-sensor" : void 0,
46738
46800
  roleDefault: device.role != null ? roleDefaultsData?.defaults[device.role] : void 0,
46739
- sliceUnit: liveUnit,
46740
- slicePrecision,
46741
- canonicalUnit: descriptor.canonicalUnit
46742
- });
46743
- const unit = resolved.unit;
46744
- const formatted = formatNumeric(resolved.toDisplayValue(rawValue), resolved.precision);
46801
+ quantity: descriptor.quantity ?? "device",
46802
+ deviceQuantity: deviceQuantity === "device" ? void 0 : deviceQuantity
46803
+ }).format(rawValue);
46804
+ const unit = rendered.unit;
46805
+ const formatted = rendered.text;
46745
46806
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
46746
46807
  className: cn("inline-flex items-baseline gap-0.5 tabular-nums text-xs text-foreground"),
46747
46808
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: formatted }), unit && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -46771,7 +46832,7 @@ function SensorValueAtom({ trpc, device }) {
46771
46832
  if (descriptor.kind === "datetime") {
46772
46833
  if (!isStringSliceValue(slice)) return null;
46773
46834
  if (slice.value === "") return null;
46774
- const format = readSliceDateTimeFormat(slice) ?? descriptor.format;
46835
+ const format = device.sourceInfo?.dateTimeShape ?? readSliceDateTimeFormat(slice) ?? descriptor.format;
46775
46836
  const formatted = formatControlDateTime(slice.value, format);
46776
46837
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
46777
46838
  className: cn("inline-flex items-baseline text-xs text-foreground"),
@@ -47326,17 +47387,14 @@ function SensorInlineControl({ trpc, deviceId }) {
47326
47387
  *
47327
47388
  * Renders:
47328
47389
  * - Typed numeric sensors: value in `text-3xl font-semibold tabular-nums` +
47329
- * canonical unit from ROLE_DESCRIPTOR (e.g. temperature-sensor → '°C').
47330
- * - Generic numeric-sensor: value + unit read live from the slice
47331
- * (`slice.unit`, populated by the HA provider from state attributes).
47390
+ * the unit `resolveSensorDisplay` derives from the quantity.
47391
+ * - Generic numeric-sensor: the same formatter, with the device's quantity.
47332
47392
  * - Binary sensors: coloured state badge ("Detected"/"Clear" etc.) — same
47333
47393
  * labels and colours as SensorValueAtom's binary branch.
47334
47394
  * - Presence: Home/Away/zone-name badge — same labels as SensorValueAtom.
47335
47395
  * - All: sensor name label below the value.
47336
47396
  *
47337
- * Unit resolution uses the same logic as SensorValueAtom (live slice.unit
47338
- * wins, ROLE_DESCRIPTOR canonicalUnit as fallback) — the two surfaces stay
47339
- * in sync.
47397
+ * Unit resolution uses the same formatter as SensorValueAtom.
47340
47398
  */
47341
47399
  function isNumericSliceValue(v) {
47342
47400
  return typeof v === "object" && v !== null;
@@ -47403,19 +47461,16 @@ function SensorHeroInner({ trpc, device }) {
47403
47461
  if (!isNumericSliceValue(slice)) return null;
47404
47462
  const rawValue = slice[descriptor.field];
47405
47463
  if (typeof rawValue !== "number") return null;
47406
- const sliceRecord = slice;
47407
- const liveUnit = typeof sliceRecord["unit"] === "string" ? sliceRecord["unit"] : void 0;
47408
- const slicePrecision = typeof sliceRecord["precision"] === "number" ? sliceRecord["precision"] : void 0;
47409
- const resolved = resolveSensorDisplay({
47464
+ const deviceQuantity = device.sourceInfo?.quantity;
47465
+ const rendered = resolveSensorDisplay({
47410
47466
  display: device.display,
47411
47467
  capName: device.role === "numeric-sensor" ? "numeric-sensor" : void 0,
47412
47468
  roleDefault: device.role != null ? roleDefaultsData?.defaults[device.role] : roleDefaultsData?.defaults["temperature-sensor"],
47413
- sliceUnit: liveUnit,
47414
- slicePrecision,
47415
- canonicalUnit: descriptor.canonicalUnit
47416
- });
47417
- const unit = resolved.unit;
47418
- const formatted = formatNumeric(resolved.toDisplayValue(rawValue), resolved.precision);
47469
+ quantity: descriptor.quantity ?? "device",
47470
+ deviceQuantity: deviceQuantity === "device" ? void 0 : deviceQuantity
47471
+ }).format(rawValue);
47472
+ const unit = rendered.unit;
47473
+ const formatted = rendered.text;
47419
47474
  const occupancyOn = occupancySlice !== void 0 && isBinarySliceValue(occupancySlice) && typeof occupancySlice["on"] === "boolean" ? Boolean(occupancySlice["on"]) : null;
47420
47475
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
47421
47476
  className: "flex flex-col items-center justify-center gap-2 rounded-lg border border-border bg-surface p-4",
@@ -47471,7 +47526,7 @@ function SensorHeroInner({ trpc, device }) {
47471
47526
  children: t("uiLibrary.sensorHeroCard.awaiting", "Awaiting state…")
47472
47527
  })]
47473
47528
  });
47474
- const format = readSliceDateTimeFormat(slice) ?? descriptor.format;
47529
+ const format = device.sourceInfo?.dateTimeShape ?? readSliceDateTimeFormat(slice) ?? descriptor.format;
47475
47530
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
47476
47531
  className: "flex flex-col items-center justify-center gap-2 rounded-lg border border-border bg-surface p-4",
47477
47532
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -49927,10 +49982,15 @@ function useWeather(trpc, deviceId) {
49927
49982
  slice: (d) => d.state.weather
49928
49983
  });
49929
49984
  }
49930
- /** Format a temperature + unit readout (e.g. `21°C`). */
49931
- function formatTemp(temp, unit) {
49932
- if (temp === null) return "—";
49933
- return `${Math.round(temp)}${unit ?? "°"}`;
49985
+ /** Format a base-unit weather reading (metric). */
49986
+ function formatWeather(value, quantity) {
49987
+ if (value === null) return "—";
49988
+ const rendered = (0, _camstack_types.formatReading)({
49989
+ value,
49990
+ quantity,
49991
+ precision: 0
49992
+ });
49993
+ return rendered.unit.length === 0 ? rendered.text : `${rendered.text}${rendered.unit}`;
49934
49994
  }
49935
49995
  /**
49936
49996
  * WeatherInlineControl — compact condition icon + temperature readout
@@ -49950,7 +50010,7 @@ function WeatherInlineControl({ trpc, deviceId }) {
49950
50010
  children: inlineGlyph(meta.glyph)
49951
50011
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
49952
50012
  className: "tabular-nums",
49953
- children: formatTemp(slice.temperature, slice.temperatureUnit)
50013
+ children: formatWeather(slice.temperature, "temperature")
49954
50014
  })]
49955
50015
  });
49956
50016
  }
@@ -50260,7 +50320,11 @@ function WeatherHeroCard({ trpc, deviceId }) {
50260
50320
  if (slice === void 0) return null;
50261
50321
  const meta = weatherConditionMeta(slice.condition, t);
50262
50322
  const tint = weatherTint(slice.condition);
50263
- const unit = slice.temperatureUnit ?? "°";
50323
+ const temperature = slice.temperature === null ? void 0 : (0, _camstack_types.formatReading)({
50324
+ value: slice.temperature,
50325
+ quantity: "temperature",
50326
+ precision: 0
50327
+ });
50264
50328
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
50265
50329
  className: "flex flex-col items-stretch gap-4 rounded-lg border border-border bg-surface p-4",
50266
50330
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -50275,11 +50339,14 @@ function WeatherHeroCard({ trpc, deviceId }) {
50275
50339
  children: [
50276
50340
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
50277
50341
  className: "text-3xl font-semibold tabular-nums text-foreground",
50278
- children: slice.temperature !== null ? Math.round(slice.temperature) : "—"
50342
+ children: temperature !== void 0 ? temperature.text : "—"
50279
50343
  }),
50280
50344
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
50281
50345
  className: "text-base text-foreground-subtle",
50282
- children: unit
50346
+ children: temperature !== void 0 ? temperature.unit : (0, _camstack_types.formatReading)({
50347
+ value: 0,
50348
+ quantity: "temperature"
50349
+ }).unit
50283
50350
  }),
50284
50351
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
50285
50352
  className: "text-xs text-foreground-subtle ml-2 capitalize",
@@ -50300,12 +50367,12 @@ function WeatherHeroCard({ trpc, deviceId }) {
50300
50367
  style: { transform: `rotate(${slice.windBearing}deg)` }
50301
50368
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Wind, { className: "h-3.5 w-3.5" }),
50302
50369
  label: t("uiLibrary.weatherControl.wind", "Wind"),
50303
- value: `${Math.round(slice.windSpeed)} ${slice.windSpeedUnit ?? ""}`.trim()
50370
+ value: formatWeather(slice.windSpeed, "speed")
50304
50371
  }),
50305
50372
  slice.pressure !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Readout$1, {
50306
50373
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Gauge, { className: "h-3.5 w-3.5" }),
50307
50374
  label: t("uiLibrary.weatherControl.pressure", "Pressure"),
50308
- value: `${Math.round(slice.pressure)} ${slice.pressureUnit ?? ""}`.trim()
50375
+ value: formatWeather(slice.pressure, "pressure")
50309
50376
  })
50310
50377
  ]
50311
50378
  })]
@@ -50544,6 +50611,16 @@ var VOLUME_MAX = 9;
50544
50611
  /** Amber kibble accent for the bowl fill; destructive red when low. */
50545
50612
  var KIBBLE = "#f59e0b";
50546
50613
  var KIBBLE_LOW = "#ef4444";
50614
+ function desiccantLabel(t, seconds) {
50615
+ const rendered = (0, _camstack_types.formatReading)({
50616
+ value: seconds,
50617
+ quantity: "duration"
50618
+ });
50619
+ return t("uiLibrary.petFeeder.desiccant", "{{text}}{{unit}} desiccant", {
50620
+ text: rendered.text,
50621
+ unit: rendered.unit
50622
+ });
50623
+ }
50547
50624
  function clampPortion(grams) {
50548
50625
  return Math.min(_camstack_types.PET_FEEDER_MANUAL_FEED_MAX, Math.max(_camstack_types.PET_FEEDER_MANUAL_FEED_MIN, grams));
50549
50626
  }
@@ -50666,7 +50743,7 @@ function PetFeederPanel({ trpc, deviceId, layout, optimistic }) {
50666
50743
  }),
50667
50744
  display.desiccantLeftDays !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Readout, {
50668
50745
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Wind, { className: "h-3.5 w-3.5" }),
50669
- label: t("uiLibrary.petFeeder.desiccant", "{{days}}d desiccant", { days: display.desiccantLeftDays })
50746
+ label: desiccantLabel(t, display.desiccantLeftDays)
50670
50747
  }),
50671
50748
  onBatteries && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
50672
50749
  className: "inline-flex items-center gap-1 font-medium text-amber-500",
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import * as __mfHostReact from "react";
5
5
  import React, { Fragment, Suspense, cloneElement, createContext, createElement, forwardRef, lazy, useCallback, useContext, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react";
6
6
  import * as __mfHostReactJsxRuntime from "react/jsx-runtime";
7
7
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
- import { ALL_CAPABILITY_DEFINITIONS, CAM_PROFILE_ORDER, CLIP_STILL_DISPOSITION_HEADER, CLIP_STILL_REASON_CODE_HEADER, CLIP_STILL_REASON_HEADER, CONTAINER_CHILD_PRIORITY, ConsumableLevelSchema, DEFAULT_EVENTS_BAND_BUFFER_SEC, DEVICE_HISTORY_ENERGY_KEY, DEVICE_HISTORY_QUERY_MANY_MAX_ITEMS, DEVICE_TYPE_CONTROL_KIND, DeviceExportStatusSchema, DeviceFeature, DeviceRole, DeviceType, EXTENSION_SLOTS, EXTENSION_SLOT_IDS, EventCategory, ExposedDeviceSchema, FIRST_LEVEL_MACRO_CLASSES, HISTORY_CATALOG, ICON_CATEGORIES, KNOWN_CAP_NAMES, MAX_SENSOR_TRIGGER_DEVICES, MODEL_PROVIDER_IDS, NAVIGATION_LIGHT_LEVEL_MAX, NAVIGATION_LIGHT_LEVEL_MIN, NODE_COLOR_IDS, NodeColorIdSchema, PALETTE_ENTRY_IDS, PET_FEEDER_MANUAL_FEED_MAX, PET_FEEDER_MANUAL_FEED_MIN, RECORDING_AUDIO_TRIGGER_OFFERED_DBFS, RECORDING_SOURCE_CAMSTACK_ADDON, REDACTED_SECRET, RecordingConfigSchema, RecordingStatusSchema, SECRET_CLEAR, SOURCE_CAP_ACTIVE_FIELD, STATIONARY_OVERLAY_STYLE, SUBJECT_PATH_DWELL_EPS, SUBJECT_PATH_FULL_FRAME_COVERAGE, SUBJECT_PATH_MAX_POINTS, TIMEZONES, USE_ROOT_MODEL_SETTING_KEY, assignNodeColorId, assignNodeColorId as assignNodeColorId$1, buildModelVariantGroups, buildSubjectPath, bundledCamstack, canConvertUnit, canonicalIconKey, clearModelBoundOverrides, clearModelBoundSetting, clusterStepSettingFieldsFor, collectModelBoundKeys, consumableFormOf, consumableKindOf, createDeviceProxy, createLazyTrpcSource, createSliceHandle, dehydrateSchema, deriveRecordingMode, describeModelVariant, effectiveStepSettings, findTimezone, formatParkedFor, hasModelBoundOverrides, iconCategoryLabel, iconsInCategory, isDrawableSubjectPath, isKeychainStoredField, isModelScopedStepSettingKey, isRootSuppliableStepId, isSecretClear, isSecretFieldView, isSecretMarker, modelBindingKey, normalizeUnit, paintHex, parseOccupancyScope, projectSubjectPath, publicEventCategories, resolveContainerPrimaryChild, resolveDeviceControlKind, resolveIconKey, resolveModelBoundSetting, resolveRecordingProfiles, resolveRootSupply, resolveVariantModelId, resolveViewableDeviceIds, searchIcons, selectAssignedProfileSlots, stepSettingSource, subjectPathSampleFromBox, subjectPathSvgPoints, tryConvertUnit, unitsForDimension, withFieldFrom, withoutField, writeModelBoundSetting } from "@camstack/types";
8
+ import { ALL_CAPABILITY_DEFINITIONS, CAM_PROFILE_ORDER, CLIP_STILL_DISPOSITION_HEADER, CLIP_STILL_REASON_CODE_HEADER, CLIP_STILL_REASON_HEADER, CONTAINER_CHILD_PRIORITY, ConsumableLevelSchema, DEFAULT_DISPLAY_PREFERENCE, DEFAULT_EVENTS_BAND_BUFFER_SEC, DEVICE_HISTORY_ENERGY_KEY, DEVICE_HISTORY_QUERY_MANY_MAX_ITEMS, DEVICE_TYPE_CONTROL_KIND, DeviceExportStatusSchema, DeviceFeature, DeviceRole, DeviceType, EXTENSION_SLOTS, EXTENSION_SLOT_IDS, EventCategory, ExposedDeviceSchema, FIRST_LEVEL_MACRO_CLASSES, HISTORY_CATALOG, ICON_CATEGORIES, KNOWN_CAP_NAMES, MAX_SENSOR_TRIGGER_DEVICES, MODEL_PROVIDER_IDS, NAVIGATION_LIGHT_LEVEL_MAX, NAVIGATION_LIGHT_LEVEL_MIN, NODE_COLOR_IDS, NodeColorIdSchema, PALETTE_ENTRY_IDS, PET_FEEDER_MANUAL_FEED_MAX, PET_FEEDER_MANUAL_FEED_MIN, RECORDING_AUDIO_TRIGGER_OFFERED_DBFS, RECORDING_SOURCE_CAMSTACK_ADDON, REDACTED_SECRET, RecordingConfigSchema, RecordingStatusSchema, SECRET_CLEAR, SOURCE_CAP_ACTIVE_FIELD, STATIONARY_OVERLAY_STYLE, SUBJECT_PATH_DWELL_EPS, SUBJECT_PATH_FULL_FRAME_COVERAGE, SUBJECT_PATH_MAX_POINTS, TIMEZONES, USE_ROOT_MODEL_SETTING_KEY, assignNodeColorId, assignNodeColorId as assignNodeColorId$1, baseUnitOf, buildModelVariantGroups, buildSubjectPath, bundledCamstack, canConvertUnit, canonicalIconKey, clearModelBoundOverrides, clearModelBoundSetting, clusterStepSettingFieldsFor, collectModelBoundKeys, consumableFormOf, consumableKindOf, createDeviceProxy, createLazyTrpcSource, createSliceHandle, dehydrateSchema, deriveRecordingMode, describeModelVariant, displayUnitFor, effectiveStepSettings, findTimezone, formatParkedFor, formatReading, hasModelBoundOverrides, iconCategoryLabel, iconsInCategory, isDrawableSubjectPath, isKeychainStoredField, isModelScopedStepSettingKey, isReadingQuantity, isRootSuppliableStepId, isSecretClear, isSecretFieldView, isSecretMarker, modelBindingKey, normalizeUnit, paintHex, parseOccupancyScope, projectSubjectPath, publicEventCategories, resolveContainerPrimaryChild, resolveDeviceControlKind, resolveIconKey, resolveModelBoundSetting, resolveRecordingProfiles, resolveRootSupply, resolveVariantModelId, resolveViewableDeviceIds, searchIcons, selectAssignedProfileSlots, stepSettingSource, subjectPathSampleFromBox, subjectPathSvgPoints, tryConvertUnit, unitsForDimension, withFieldFrom, withoutField, writeModelBoundSetting } from "@camstack/types";
9
9
  import * as __mfHostTrpcReactQuery from "@trpc/react-query";
10
10
  import { createTRPCReact } from "@trpc/react-query";
11
11
  import * as __mfHostReactDOM from "react-dom";
@@ -20949,21 +20949,16 @@ Skeleton.displayName = "Skeleton";
20949
20949
  /** `value` null = unknown (D393) — renders an em-dash, never a fake `0`. */
20950
20950
  function formatRemaining(t, r) {
20951
20951
  if (r.value === null) return "—";
20952
- const count = r.value;
20953
- switch (r.unit) {
20954
- case "days": return t("uiLibrary.consumablesPanel.days", {
20955
- one: "{{count}} day",
20956
- other: "{{count}} days"
20957
- }, { count });
20958
- case "hours": return t("uiLibrary.consumablesPanel.hours", {
20959
- one: "{{count}} hour",
20960
- other: "{{count}} hours"
20961
- }, { count });
20962
- case "cycles": return t("uiLibrary.consumablesPanel.cycles", {
20963
- one: "{{count}} cycle",
20964
- other: "{{count}} cycles"
20965
- }, { count });
20966
- }
20952
+ if (r.kind === "count") return t("uiLibrary.consumablesPanel.cycles", {
20953
+ one: "{{count}} cycle",
20954
+ other: "{{count}} cycles"
20955
+ }, { count: r.value });
20956
+ const rendered = formatReading({
20957
+ value: r.value,
20958
+ quantity: "duration",
20959
+ precision: 0
20960
+ });
20961
+ return rendered.unit.length === 0 ? rendered.text : `${rendered.text} ${rendered.unit}`;
20967
20962
  }
20968
20963
  /** Format a ms-epoch as a compact local date for the "replaced" hint. */
20969
20964
  function formatResetAt(ms) {
@@ -46059,6 +46054,66 @@ function formatNumeric(value, precision) {
46059
46054
  return Number.isInteger(value) ? String(value) : value.toFixed(1);
46060
46055
  }
46061
46056
  //#endregion
46057
+ //#region src/lib/resolve-sensor-display.ts
46058
+ function canonUnit(raw) {
46059
+ if (raw === void 0) return void 0;
46060
+ return normalizeUnit(raw) ?? raw;
46061
+ }
46062
+ function concreteQuantity(input) {
46063
+ if (input.quantity === void 0) return void 0;
46064
+ if (input.quantity === "device") {
46065
+ const chosen = input.deviceQuantity;
46066
+ if (chosen === void 0 || chosen === "device") return void 0;
46067
+ return chosen;
46068
+ }
46069
+ return input.quantity;
46070
+ }
46071
+ function resolveSensorDisplay(input) {
46072
+ const perCap = input.capName !== void 0 ? input.display?.perCap?.[input.capName] : void 0;
46073
+ const precision = perCap?.precision ?? input.display?.precision ?? input.roleDefault?.precision ?? input.declarationPrecision;
46074
+ const quantity = concreteQuantity(input);
46075
+ const preference = input.preference ?? DEFAULT_DISPLAY_PREFERENCE;
46076
+ const base = quantity === void 0 ? void 0 : baseUnitOf(quantity);
46077
+ const preferred = quantity === void 0 ? void 0 : displayUnitFor(quantity, preference);
46078
+ const overrideUnit = canonUnit(perCap?.unit ?? input.display?.unit ?? input.roleDefault?.unit);
46079
+ const overrideOk = overrideUnit !== void 0 && base !== void 0 && overrideUnit !== base && canConvertUnit(base, overrideUnit);
46080
+ const unit = overrideOk ? overrideUnit : preferred ?? "";
46081
+ const scales = quantity === "duration" || quantity === "information" || quantity === "duration-ms";
46082
+ const toDisplayValue = (raw) => {
46083
+ if (scales || base === void 0 || unit.length === 0 || unit === base) return raw;
46084
+ return tryConvertUnit(raw, base, unit) ?? raw;
46085
+ };
46086
+ const fromDisplayValue = (shown) => {
46087
+ if (scales || base === void 0 || unit.length === 0 || unit === base) return shown;
46088
+ return tryConvertUnit(shown, unit, base) ?? shown;
46089
+ };
46090
+ const format = (raw) => {
46091
+ if (quantity !== void 0 && !overrideOk) {
46092
+ const rendered = formatReading({
46093
+ value: raw,
46094
+ quantity,
46095
+ preference,
46096
+ precision
46097
+ });
46098
+ return {
46099
+ text: rendered.text,
46100
+ unit: rendered.unit
46101
+ };
46102
+ }
46103
+ return {
46104
+ text: formatNumeric(toDisplayValue(raw), precision),
46105
+ unit
46106
+ };
46107
+ };
46108
+ return {
46109
+ unit,
46110
+ precision,
46111
+ toDisplayValue,
46112
+ fromDisplayValue,
46113
+ format
46114
+ };
46115
+ }
46116
+ //#endregion
46062
46117
  //#region src/lib/format-control-datetime.ts
46063
46118
  var DATE_RE = /^(\d{4})-(\d{2})-(\d{2})/;
46064
46119
  var TIME_RE = /^(\d{2}):(\d{2})(?::(\d{2}))?$/;
@@ -46124,8 +46179,20 @@ function toDateInput(value) {
46124
46179
  function toTimeInput(value) {
46125
46180
  return value.slice(0, 5);
46126
46181
  }
46127
- /** Trim an HA datetime value to the `<input type=datetime-local>` shape
46128
- * ('YYYY-MM-DDTHH:MM'). Accepts both 'T'- and space-separated forms. */
46182
+ function datetimeEditor(format, rawValue) {
46183
+ if (format === "date") return {
46184
+ type: "date",
46185
+ value: toDateInput(rawValue)
46186
+ };
46187
+ if (format === "time") return {
46188
+ type: "time",
46189
+ value: toTimeInput(rawValue)
46190
+ };
46191
+ return {
46192
+ type: "datetime-local",
46193
+ value: toDateTimeInput(rawValue)
46194
+ };
46195
+ }
46129
46196
  function toDateTimeInput(value) {
46130
46197
  return (value.includes("T") ? value : value.replace(" ", "T")).slice(0, 16);
46131
46198
  }
@@ -46218,24 +46285,35 @@ function TextControl({ value, busy, onCommit }) {
46218
46285
  className: cn("rounded border border-border bg-surface px-2 py-0.5 text-xs", busy && "opacity-40 cursor-not-allowed")
46219
46286
  });
46220
46287
  }
46221
- function ControlWidget({ slice, busy, setValueFn, layout }) {
46288
+ function ControlWidget({ slice, busy, setValueFn, layout, quantity, display, dateTimeShape }) {
46222
46289
  const t = useUiText();
46223
46290
  switch (slice.kind) {
46224
- case "numeric": return /* @__PURE__ */ jsx(NumericControl, {
46225
- value: typeof slice.value === "number" ? slice.value : Number(slice.value),
46226
- busy,
46227
- onCommit: (v) => {
46228
- setValueFn({ control: {
46229
- kind: "numeric",
46230
- value: v
46231
- } });
46232
- },
46233
- unit: slice.unit,
46234
- min: slice.min,
46235
- max: slice.max,
46236
- step: slice.step,
46237
- precision: slice.precision
46238
- });
46291
+ case "numeric": {
46292
+ const numericValue = typeof slice.value === "number" ? slice.value : Number(slice.value);
46293
+ const resolved = quantity === void 0 ? void 0 : resolveSensorDisplay({
46294
+ quantity,
46295
+ display,
46296
+ capName: "control"
46297
+ });
46298
+ const toShown = resolved?.toDisplayValue ?? ((raw) => raw);
46299
+ const toBase = resolved?.fromDisplayValue ?? ((shown) => shown);
46300
+ const unit = resolved !== void 0 && resolved.unit.length > 0 ? resolved.unit : void 0;
46301
+ return /* @__PURE__ */ jsx(NumericControl, {
46302
+ value: toShown(numericValue),
46303
+ busy,
46304
+ onCommit: (v) => {
46305
+ setValueFn({ control: {
46306
+ kind: "numeric",
46307
+ value: toBase(v)
46308
+ } });
46309
+ },
46310
+ unit,
46311
+ min: slice.min === void 0 ? void 0 : toShown(slice.min),
46312
+ max: slice.max === void 0 ? void 0 : toShown(slice.max),
46313
+ step: slice.step === void 0 ? void 0 : toShown(slice.step),
46314
+ precision: resolved?.precision
46315
+ });
46316
+ }
46239
46317
  case "select": {
46240
46318
  const selectValue = String(slice.value);
46241
46319
  return /* @__PURE__ */ jsx("select", {
@@ -46268,7 +46346,7 @@ function ControlWidget({ slice, busy, setValueFn, layout }) {
46268
46346
  });
46269
46347
  case "datetime": {
46270
46348
  const rawValue = String(slice.value);
46271
- const format = slice.format ?? "datetime";
46349
+ const format = dateTimeShape ?? "datetime";
46272
46350
  if (layout === "compact") {
46273
46351
  if (rawValue === "") return /* @__PURE__ */ jsx("span", {
46274
46352
  "aria-label": t("uiLibrary.controlPanel.value", "Control value"),
@@ -46281,8 +46359,9 @@ function ControlWidget({ slice, busy, setValueFn, layout }) {
46281
46359
  children: formatControlDateTime(rawValue, format)
46282
46360
  });
46283
46361
  }
46284
- const inputType = format === "date" ? "date" : format === "time" ? "time" : "datetime-local";
46285
- const inputValue = format === "date" ? toDateInput(rawValue) : format === "time" ? toTimeInput(rawValue) : toDateTimeInput(rawValue);
46362
+ const editor = datetimeEditor(format, rawValue);
46363
+ const inputType = editor.type;
46364
+ const inputValue = editor.value;
46286
46365
  return /* @__PURE__ */ jsx("input", {
46287
46366
  type: inputType,
46288
46367
  "aria-label": t("uiLibrary.controlPanel.value", "Control value"),
@@ -46302,6 +46381,7 @@ function ControlWidget({ slice, busy, setValueFn, layout }) {
46302
46381
  }
46303
46382
  }
46304
46383
  function ControlPanel({ trpc, deviceId, features: _features, layout, optimistic }) {
46384
+ const info = useOptionalSystem()?.getDeviceInfo(deviceId) ?? null;
46305
46385
  const { cap, slice, available } = useDeviceCapability({
46306
46386
  trpc,
46307
46387
  deviceId,
@@ -46311,6 +46391,8 @@ function ControlPanel({ trpc, deviceId, features: _features, layout, optimistic
46311
46391
  const { display, applyOptimistic } = useOptimisticSlice(slice, { enabled: optimistic ?? true });
46312
46392
  if (display === void 0) return null;
46313
46393
  const busy = !available;
46394
+ const chosen = info?.sourceInfo?.quantity;
46395
+ const quantity = isReadingQuantity(chosen) && chosen !== "device" && chosen !== "none" ? chosen : void 0;
46314
46396
  const setValueFn = (input) => {
46315
46397
  if (!busy && cap) {
46316
46398
  applyOptimistic({ value: input.control.value });
@@ -46324,7 +46406,10 @@ function ControlPanel({ trpc, deviceId, features: _features, layout, optimistic
46324
46406
  slice: display,
46325
46407
  busy,
46326
46408
  setValueFn,
46327
- layout
46409
+ layout,
46410
+ quantity,
46411
+ display: info?.display,
46412
+ dateTimeShape: info?.sourceInfo?.dateTimeShape
46328
46413
  })
46329
46414
  });
46330
46415
  }
@@ -46428,30 +46513,6 @@ function PopoverRowAction({ icon: Icon, title, children }) {
46428
46513
  }), document.body)] });
46429
46514
  }
46430
46515
  //#endregion
46431
- //#region src/lib/resolve-sensor-display.ts
46432
- /** Table keys are canonical spellings; providers often send `C` / `F` / `celsius`. */
46433
- function canonUnit(raw) {
46434
- if (raw === void 0) return void 0;
46435
- return normalizeUnit(raw) ?? raw;
46436
- }
46437
- var identity = (raw) => raw;
46438
- function resolveSensorDisplay(input) {
46439
- const perCap = input.capName !== void 0 ? input.display?.perCap?.[input.capName] : void 0;
46440
- const precision = perCap?.precision ?? input.display?.precision ?? input.roleDefault?.precision ?? input.slicePrecision;
46441
- const sourceUnit = canonUnit(input.sliceUnit ?? input.canonicalUnit);
46442
- const overrideUnit = canonUnit(perCap?.unit ?? input.display?.unit ?? input.roleDefault?.unit);
46443
- if (overrideUnit !== void 0 && sourceUnit !== void 0 && overrideUnit !== sourceUnit && canConvertUnit(sourceUnit, overrideUnit)) return {
46444
- unit: overrideUnit,
46445
- precision,
46446
- toDisplayValue: (raw) => tryConvertUnit(raw, sourceUnit, overrideUnit) ?? raw
46447
- };
46448
- return {
46449
- unit: sourceUnit ?? "",
46450
- precision,
46451
- toDisplayValue: identity
46452
- };
46453
- }
46454
- //#endregion
46455
46516
  //#region src/composites/device-controls/sensor-value-atom.tsx
46456
46517
  /**
46457
46518
  * Binary fallback for the `battery-sensor` role.
@@ -46492,102 +46553,105 @@ function hasBatteryNumericValue(slice, field) {
46492
46553
  * avoids dynamic string indexing — each one is a concrete property
46493
46554
  * access on the typed `DeviceProxy['state']` shape.
46494
46555
  *
46495
- * Canonical unit rules:
46496
- * - Typed roles (temperature/humidity/ambient-light/pressure/power/energy)
46497
- * carry a `canonicalUnit` constant. The slice is normalised to one unit
46498
- * so no per-device lookup is needed.
46499
- * - Generic `numeric-sensor` has no `canonicalUnit`. The unit is read live
46500
- * from the slice (`slice.unit`) as populated by the provider from the
46501
- * upstream source on every state push.
46556
+ * The unit comes from the descriptor's quantity through `resolveSensorDisplay`.
46557
+ * A generic numeric sensor uses quantity `device` and the device's
46558
+ * `sourceInfo.quantity`.
46502
46559
  */
46503
46560
  var ROLE_DESCRIPTOR = {
46504
46561
  "temperature-sensor": {
46505
46562
  kind: "numeric",
46506
46563
  field: "celsius",
46507
- canonicalUnit: "°C",
46564
+ quantity: "temperature",
46508
46565
  getSliceHandle: (s) => s.temperatureSensor
46509
46566
  },
46510
46567
  "humidity-sensor": {
46511
46568
  kind: "numeric",
46512
46569
  field: "percent",
46513
- canonicalUnit: "%",
46570
+ quantity: "humidity",
46514
46571
  getSliceHandle: (s) => s.humiditySensor
46515
46572
  },
46516
46573
  "ambient-light-sensor": {
46517
46574
  kind: "numeric",
46518
46575
  field: "lux",
46519
- canonicalUnit: "lx",
46576
+ quantity: "illuminance",
46520
46577
  getSliceHandle: (s) => s.ambientLightSensor
46521
46578
  },
46522
46579
  "pressure-sensor": {
46523
46580
  kind: "numeric",
46524
46581
  field: "hpa",
46525
- canonicalUnit: "hPa",
46582
+ quantity: "pressure",
46526
46583
  getSliceHandle: (s) => s.pressureSensor
46527
46584
  },
46528
46585
  "numeric-sensor": {
46529
46586
  kind: "numeric",
46530
46587
  field: "value",
46588
+ quantity: "device",
46531
46589
  getSliceHandle: (s) => s.numericSensor
46532
46590
  },
46533
46591
  "wind-sensor": {
46534
46592
  kind: "numeric",
46535
46593
  field: "value",
46594
+ quantity: "device",
46536
46595
  getSliceHandle: (s) => s.numericSensor
46537
46596
  },
46538
46597
  "rain-sensor": {
46539
46598
  kind: "numeric",
46540
46599
  field: "value",
46600
+ quantity: "device",
46541
46601
  getSliceHandle: (s) => s.numericSensor
46542
46602
  },
46543
46603
  "uv-sensor": {
46544
46604
  kind: "numeric",
46545
46605
  field: "value",
46606
+ quantity: "device",
46546
46607
  getSliceHandle: (s) => s.numericSensor
46547
46608
  },
46548
46609
  "solar-radiation-sensor": {
46549
46610
  kind: "numeric",
46550
46611
  field: "value",
46612
+ quantity: "device",
46551
46613
  getSliceHandle: (s) => s.numericSensor
46552
46614
  },
46553
46615
  "soil-moisture-sensor": {
46554
46616
  kind: "numeric",
46555
46617
  field: "value",
46618
+ quantity: "device",
46556
46619
  getSliceHandle: (s) => s.numericSensor
46557
46620
  },
46558
46621
  "power-sensor": {
46559
46622
  kind: "numeric",
46560
46623
  field: "watts",
46561
- canonicalUnit: "W",
46624
+ quantity: "power",
46562
46625
  getSliceHandle: (s) => s.powerMeter
46563
46626
  },
46564
46627
  "energy-sensor": {
46565
46628
  kind: "numeric",
46566
46629
  field: "kwhTotal",
46567
- canonicalUnit: "kWh",
46630
+ quantity: "energy",
46568
46631
  getSliceHandle: (s) => s.powerMeter
46569
46632
  },
46570
46633
  "voltage-sensor": {
46571
46634
  kind: "numeric",
46572
46635
  field: "volts",
46573
- canonicalUnit: "V",
46636
+ quantity: "voltage",
46574
46637
  getSliceHandle: (s) => s.powerMeter
46575
46638
  },
46576
46639
  "current-sensor": {
46577
46640
  kind: "numeric",
46578
46641
  field: "amps",
46579
- canonicalUnit: "A",
46642
+ quantity: "current",
46580
46643
  getSliceHandle: (s) => s.powerMeter
46581
46644
  },
46582
46645
  "battery-sensor": {
46583
46646
  kind: "numeric",
46584
46647
  field: "value",
46585
- canonicalUnit: "%",
46648
+ quantity: "percentage",
46586
46649
  getSliceHandle: (s) => s.numericSensor
46587
46650
  },
46588
46651
  "generic-sensor": {
46589
46652
  kind: "numeric",
46590
46653
  field: "value",
46654
+ quantity: "device",
46591
46655
  getSliceHandle: (s) => s.numericSensor
46592
46656
  },
46593
46657
  "motion-sensor": {
@@ -46705,19 +46769,16 @@ function SensorValueAtom({ trpc, device }) {
46705
46769
  children: enumSensorSlice.value
46706
46770
  });
46707
46771
  if (numericIsColStart) return null;
46708
- const sliceRecord = slice;
46709
- const liveUnit = typeof sliceRecord["unit"] === "string" ? sliceRecord["unit"] : void 0;
46710
- const slicePrecision = typeof sliceRecord["precision"] === "number" ? sliceRecord["precision"] : void 0;
46711
- const resolved = resolveSensorDisplay({
46772
+ const deviceQuantity = device.sourceInfo?.quantity;
46773
+ const rendered = resolveSensorDisplay({
46712
46774
  display: device.display,
46713
46775
  capName: device.role === "numeric-sensor" ? "numeric-sensor" : void 0,
46714
46776
  roleDefault: device.role != null ? roleDefaultsData?.defaults[device.role] : void 0,
46715
- sliceUnit: liveUnit,
46716
- slicePrecision,
46717
- canonicalUnit: descriptor.canonicalUnit
46718
- });
46719
- const unit = resolved.unit;
46720
- const formatted = formatNumeric(resolved.toDisplayValue(rawValue), resolved.precision);
46777
+ quantity: descriptor.quantity ?? "device",
46778
+ deviceQuantity: deviceQuantity === "device" ? void 0 : deviceQuantity
46779
+ }).format(rawValue);
46780
+ const unit = rendered.unit;
46781
+ const formatted = rendered.text;
46721
46782
  return /* @__PURE__ */ jsxs("span", {
46722
46783
  className: cn("inline-flex items-baseline gap-0.5 tabular-nums text-xs text-foreground"),
46723
46784
  children: [/* @__PURE__ */ jsx("span", { children: formatted }), unit && /* @__PURE__ */ jsx("span", {
@@ -46747,7 +46808,7 @@ function SensorValueAtom({ trpc, device }) {
46747
46808
  if (descriptor.kind === "datetime") {
46748
46809
  if (!isStringSliceValue(slice)) return null;
46749
46810
  if (slice.value === "") return null;
46750
- const format = readSliceDateTimeFormat(slice) ?? descriptor.format;
46811
+ const format = device.sourceInfo?.dateTimeShape ?? readSliceDateTimeFormat(slice) ?? descriptor.format;
46751
46812
  const formatted = formatControlDateTime(slice.value, format);
46752
46813
  return /* @__PURE__ */ jsx("span", {
46753
46814
  className: cn("inline-flex items-baseline text-xs text-foreground"),
@@ -47302,17 +47363,14 @@ function SensorInlineControl({ trpc, deviceId }) {
47302
47363
  *
47303
47364
  * Renders:
47304
47365
  * - Typed numeric sensors: value in `text-3xl font-semibold tabular-nums` +
47305
- * canonical unit from ROLE_DESCRIPTOR (e.g. temperature-sensor → '°C').
47306
- * - Generic numeric-sensor: value + unit read live from the slice
47307
- * (`slice.unit`, populated by the HA provider from state attributes).
47366
+ * the unit `resolveSensorDisplay` derives from the quantity.
47367
+ * - Generic numeric-sensor: the same formatter, with the device's quantity.
47308
47368
  * - Binary sensors: coloured state badge ("Detected"/"Clear" etc.) — same
47309
47369
  * labels and colours as SensorValueAtom's binary branch.
47310
47370
  * - Presence: Home/Away/zone-name badge — same labels as SensorValueAtom.
47311
47371
  * - All: sensor name label below the value.
47312
47372
  *
47313
- * Unit resolution uses the same logic as SensorValueAtom (live slice.unit
47314
- * wins, ROLE_DESCRIPTOR canonicalUnit as fallback) — the two surfaces stay
47315
- * in sync.
47373
+ * Unit resolution uses the same formatter as SensorValueAtom.
47316
47374
  */
47317
47375
  function isNumericSliceValue(v) {
47318
47376
  return typeof v === "object" && v !== null;
@@ -47379,19 +47437,16 @@ function SensorHeroInner({ trpc, device }) {
47379
47437
  if (!isNumericSliceValue(slice)) return null;
47380
47438
  const rawValue = slice[descriptor.field];
47381
47439
  if (typeof rawValue !== "number") return null;
47382
- const sliceRecord = slice;
47383
- const liveUnit = typeof sliceRecord["unit"] === "string" ? sliceRecord["unit"] : void 0;
47384
- const slicePrecision = typeof sliceRecord["precision"] === "number" ? sliceRecord["precision"] : void 0;
47385
- const resolved = resolveSensorDisplay({
47440
+ const deviceQuantity = device.sourceInfo?.quantity;
47441
+ const rendered = resolveSensorDisplay({
47386
47442
  display: device.display,
47387
47443
  capName: device.role === "numeric-sensor" ? "numeric-sensor" : void 0,
47388
47444
  roleDefault: device.role != null ? roleDefaultsData?.defaults[device.role] : roleDefaultsData?.defaults["temperature-sensor"],
47389
- sliceUnit: liveUnit,
47390
- slicePrecision,
47391
- canonicalUnit: descriptor.canonicalUnit
47392
- });
47393
- const unit = resolved.unit;
47394
- const formatted = formatNumeric(resolved.toDisplayValue(rawValue), resolved.precision);
47445
+ quantity: descriptor.quantity ?? "device",
47446
+ deviceQuantity: deviceQuantity === "device" ? void 0 : deviceQuantity
47447
+ }).format(rawValue);
47448
+ const unit = rendered.unit;
47449
+ const formatted = rendered.text;
47395
47450
  const occupancyOn = occupancySlice !== void 0 && isBinarySliceValue(occupancySlice) && typeof occupancySlice["on"] === "boolean" ? Boolean(occupancySlice["on"]) : null;
47396
47451
  return /* @__PURE__ */ jsxs("div", {
47397
47452
  className: "flex flex-col items-center justify-center gap-2 rounded-lg border border-border bg-surface p-4",
@@ -47447,7 +47502,7 @@ function SensorHeroInner({ trpc, device }) {
47447
47502
  children: t("uiLibrary.sensorHeroCard.awaiting", "Awaiting state…")
47448
47503
  })]
47449
47504
  });
47450
- const format = readSliceDateTimeFormat(slice) ?? descriptor.format;
47505
+ const format = device.sourceInfo?.dateTimeShape ?? readSliceDateTimeFormat(slice) ?? descriptor.format;
47451
47506
  return /* @__PURE__ */ jsxs("div", {
47452
47507
  className: "flex flex-col items-center justify-center gap-2 rounded-lg border border-border bg-surface p-4",
47453
47508
  children: [/* @__PURE__ */ jsx("span", {
@@ -49903,10 +49958,15 @@ function useWeather(trpc, deviceId) {
49903
49958
  slice: (d) => d.state.weather
49904
49959
  });
49905
49960
  }
49906
- /** Format a temperature + unit readout (e.g. `21°C`). */
49907
- function formatTemp(temp, unit) {
49908
- if (temp === null) return "—";
49909
- return `${Math.round(temp)}${unit ?? "°"}`;
49961
+ /** Format a base-unit weather reading (metric). */
49962
+ function formatWeather(value, quantity) {
49963
+ if (value === null) return "—";
49964
+ const rendered = formatReading({
49965
+ value,
49966
+ quantity,
49967
+ precision: 0
49968
+ });
49969
+ return rendered.unit.length === 0 ? rendered.text : `${rendered.text}${rendered.unit}`;
49910
49970
  }
49911
49971
  /**
49912
49972
  * WeatherInlineControl — compact condition icon + temperature readout
@@ -49926,7 +49986,7 @@ function WeatherInlineControl({ trpc, deviceId }) {
49926
49986
  children: inlineGlyph(meta.glyph)
49927
49987
  }), /* @__PURE__ */ jsx("span", {
49928
49988
  className: "tabular-nums",
49929
- children: formatTemp(slice.temperature, slice.temperatureUnit)
49989
+ children: formatWeather(slice.temperature, "temperature")
49930
49990
  })]
49931
49991
  });
49932
49992
  }
@@ -50236,7 +50296,11 @@ function WeatherHeroCard({ trpc, deviceId }) {
50236
50296
  if (slice === void 0) return null;
50237
50297
  const meta = weatherConditionMeta(slice.condition, t);
50238
50298
  const tint = weatherTint(slice.condition);
50239
- const unit = slice.temperatureUnit ?? "°";
50299
+ const temperature = slice.temperature === null ? void 0 : formatReading({
50300
+ value: slice.temperature,
50301
+ quantity: "temperature",
50302
+ precision: 0
50303
+ });
50240
50304
  return /* @__PURE__ */ jsx("div", {
50241
50305
  className: "flex flex-col items-stretch gap-4 rounded-lg border border-border bg-surface p-4",
50242
50306
  children: /* @__PURE__ */ jsxs("div", {
@@ -50251,11 +50315,14 @@ function WeatherHeroCard({ trpc, deviceId }) {
50251
50315
  children: [
50252
50316
  /* @__PURE__ */ jsx("span", {
50253
50317
  className: "text-3xl font-semibold tabular-nums text-foreground",
50254
- children: slice.temperature !== null ? Math.round(slice.temperature) : "—"
50318
+ children: temperature !== void 0 ? temperature.text : "—"
50255
50319
  }),
50256
50320
  /* @__PURE__ */ jsx("span", {
50257
50321
  className: "text-base text-foreground-subtle",
50258
- children: unit
50322
+ children: temperature !== void 0 ? temperature.unit : formatReading({
50323
+ value: 0,
50324
+ quantity: "temperature"
50325
+ }).unit
50259
50326
  }),
50260
50327
  /* @__PURE__ */ jsx("span", {
50261
50328
  className: "text-xs text-foreground-subtle ml-2 capitalize",
@@ -50276,12 +50343,12 @@ function WeatherHeroCard({ trpc, deviceId }) {
50276
50343
  style: { transform: `rotate(${slice.windBearing}deg)` }
50277
50344
  }) : /* @__PURE__ */ jsx(Wind, { className: "h-3.5 w-3.5" }),
50278
50345
  label: t("uiLibrary.weatherControl.wind", "Wind"),
50279
- value: `${Math.round(slice.windSpeed)} ${slice.windSpeedUnit ?? ""}`.trim()
50346
+ value: formatWeather(slice.windSpeed, "speed")
50280
50347
  }),
50281
50348
  slice.pressure !== null && /* @__PURE__ */ jsx(Readout$1, {
50282
50349
  icon: /* @__PURE__ */ jsx(Gauge, { className: "h-3.5 w-3.5" }),
50283
50350
  label: t("uiLibrary.weatherControl.pressure", "Pressure"),
50284
- value: `${Math.round(slice.pressure)} ${slice.pressureUnit ?? ""}`.trim()
50351
+ value: formatWeather(slice.pressure, "pressure")
50285
50352
  })
50286
50353
  ]
50287
50354
  })]
@@ -50520,6 +50587,16 @@ var VOLUME_MAX = 9;
50520
50587
  /** Amber kibble accent for the bowl fill; destructive red when low. */
50521
50588
  var KIBBLE = "#f59e0b";
50522
50589
  var KIBBLE_LOW = "#ef4444";
50590
+ function desiccantLabel(t, seconds) {
50591
+ const rendered = formatReading({
50592
+ value: seconds,
50593
+ quantity: "duration"
50594
+ });
50595
+ return t("uiLibrary.petFeeder.desiccant", "{{text}}{{unit}} desiccant", {
50596
+ text: rendered.text,
50597
+ unit: rendered.unit
50598
+ });
50599
+ }
50523
50600
  function clampPortion(grams) {
50524
50601
  return Math.min(PET_FEEDER_MANUAL_FEED_MAX, Math.max(PET_FEEDER_MANUAL_FEED_MIN, grams));
50525
50602
  }
@@ -50642,7 +50719,7 @@ function PetFeederPanel({ trpc, deviceId, layout, optimistic }) {
50642
50719
  }),
50643
50720
  display.desiccantLeftDays !== null && /* @__PURE__ */ jsx(Readout, {
50644
50721
  icon: /* @__PURE__ */ jsx(Wind, { className: "h-3.5 w-3.5" }),
50645
- label: t("uiLibrary.petFeeder.desiccant", "{{days}}d desiccant", { days: display.desiccantLeftDays })
50722
+ label: desiccantLabel(t, display.desiccantLeftDays)
50646
50723
  }),
50647
50724
  onBatteries && /* @__PURE__ */ jsxs("span", {
50648
50725
  className: "inline-flex items-center gap-1 font-medium text-amber-500",
@@ -1,20 +1,10 @@
1
1
  /**
2
- * Shared, pure resolution of a sensor's DISPLAY unit + precision + value
3
- * transform. One function so the two renderers (`SensorValueAtom`,
4
- * `SensorHeroCard`) can never diverge — they duplicated this precedence before.
5
- *
6
- * Resolution order (device override → role default → live slice →
7
- * ROLE_DESCRIPTOR canonicalUnit). `??` everywhere, never `||`: `precision: 0`
8
- * is a legal override and must win over a slice `precision: 2`.
9
- *
10
- * Conversion happens at RENDER only — storage stays in source units. An
11
- * override unit converts the raw slice value ONLY when it shares a dimension
12
- * with the source unit (`canConvertUnit`); on refusal the display falls back to
13
- * identity + the SOURCE unit so a unit label and its value math can never
14
- * disagree. Precision applies AFTER conversion (by construction — the caller
15
- * runs `formatNumeric(toDisplayValue(raw), precision)`).
2
+ * Shared resolution of a sensor's display string. The stored number is always
3
+ * the quantity's base unit (D944 §4). A display preference (default metric)
4
+ * or an operator override picks the label; the value is converted to match.
16
5
  */
17
- import type { DeviceDisplayOverride, RoleDisplayDefault } from "@camstack/types";
6
+ import type { DeviceDisplayOverride, ReadingQuantity, RoleDisplayDefault } from "@camstack/types";
7
+ import { type DisplayPreference } from "@camstack/types";
18
8
  export interface SensorDisplayInput {
19
9
  /** Per-device override from the row (`DeviceInfo.display`). */
20
10
  readonly display?: DeviceDisplayOverride;
@@ -22,20 +12,28 @@ export interface SensorDisplayInput {
22
12
  readonly capName?: string;
23
13
  /** Role default (`roleDisplayDefaults[device.role]`). */
24
14
  readonly roleDefault?: RoleDisplayDefault;
25
- /** Live slice unit (the unit the raw VALUE is actually in, for generic sensors). */
26
- readonly sliceUnit?: string;
27
- /** Live slice precision. */
28
- readonly slicePrecision?: number;
29
- /** ROLE_DESCRIPTOR canonical unit fallback (typed roles). */
30
- readonly canonicalUnit?: string;
15
+ /** Quantity of the stored number. `device` reads `deviceQuantity`. */
16
+ readonly quantity?: ReadingQuantity;
17
+ /** The device's chosen quantity when `quantity` is `device`. */
18
+ readonly deviceQuantity?: ReadingQuantity;
19
+ /** Precision declared on the reading, when the operator has not overridden it. */
20
+ readonly declarationPrecision?: number;
21
+ /** Read-time preference. Defaults to metric. */
22
+ readonly preference?: DisplayPreference;
31
23
  }
32
24
  export interface ResolvedSensorDisplay {
33
- /** Display unit — `''` when none resolves anywhere. */
25
+ /** Display unit — `''` when the quantity has none. */
34
26
  readonly unit: string;
35
27
  /** Display precision (int 0-10) or undefined for auto-format. */
36
28
  readonly precision: number | undefined;
37
- /** Convert a raw slice value into the display unit. Identity unless an
38
- * override unit differs from the source unit AND both share a dimension. */
29
+ /** Convert a base-unit value into the display unit. */
39
30
  readonly toDisplayValue: (raw: number) => number;
31
+ /** Convert a display-unit value back into the stored base. */
32
+ readonly fromDisplayValue: (shown: number) => number;
33
+ /** Text and unit for one base-unit sample. */
34
+ readonly format: (raw: number) => {
35
+ readonly text: string;
36
+ readonly unit: string;
37
+ };
40
38
  }
41
39
  export declare function resolveSensorDisplay(input: SensorDisplayInput): ResolvedSensorDisplay;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/ui-library",
3
- "version": "1.2.325",
3
+ "version": "1.2.326",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/camstack/server.git",