@procaaso/alphinity-ui-components 1.1.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,6 +72,17 @@ function HMIPanel() {
72
72
  - `npm test` - Run tests with Vitest
73
73
  - `npm run lint` - Lint code with ESLint
74
74
  - `npm run typecheck` - Type check with TypeScript
75
+ - `npm run dev` - Run the demo playground on http://localhost:3010
76
+
77
+ ### Demo views
78
+
79
+ The demo (`npm run dev`) mounts one of three views, selected by a `?demo=` query param:
80
+
81
+ - `http://localhost:3010/` *(default)* - **Runtime**: full P&ID canvas with live telemetry, simulation, and device control/config panels
82
+ - `http://localhost:3010/?demo=single-node` - **Single-node HMI mockup** over the full plant SVG layout
83
+ - `http://localhost:3010/?demo=gallery` - **Component gallery** of individual widgets
84
+
85
+ > The demo is served, not opened as a file — `demo-dist/index.html` references assets by absolute path and will be blank if opened via `file://`. Use `npm run dev`, or `npx vite preview --config vite.demo.config.ts` for the built version.
75
86
 
76
87
  ### Components
77
88
 
package/dist/index.cjs CHANGED
@@ -36,6 +36,7 @@ __export(index_exports, {
36
36
  CardValue: () => CardValue,
37
37
  CircularGauge: () => CircularGauge,
38
38
  ControlPanel: () => ControlPanel,
39
+ DEFAULT_STATUS_COLORS: () => DEFAULT_STATUS_COLORS,
39
40
  DEFAULT_THRESHOLDS: () => DEFAULT_THRESHOLDS,
40
41
  DashboardCard: () => DashboardCard,
41
42
  DeviceConfigPanel: () => DeviceConfigPanel,
@@ -85,6 +86,7 @@ __export(index_exports, {
85
86
  overlayStyles: () => overlayStyles,
86
87
  registerSymbol: () => registerSymbol,
87
88
  registerSymbols: () => registerSymbols,
89
+ resolveStatusColor: () => resolveStatusColor,
88
90
  useDeviceConfigs: () => useDeviceConfigs,
89
91
  useDeviceControls: () => useDeviceControls,
90
92
  useDrag: () => useDrag,
@@ -578,11 +580,12 @@ var ControlPanel = ({
578
580
  secondSetpointValue,
579
581
  onSecondSetpointChange,
580
582
  modeConfigs = {},
581
- defaultUnit = "Units",
583
+ defaultUnit,
582
584
  defaultLabel = "Setpoint",
583
- defaultMin = 0,
584
- defaultMax = 100,
585
- defaultPrecision = 2,
585
+ defaultMin,
586
+ defaultMax,
587
+ defaultPrecision,
588
+ scaling,
586
589
  status = "normal",
587
590
  collapsed = false,
588
591
  onCollapseChange,
@@ -604,11 +607,11 @@ var ControlPanel = ({
604
607
  onCollapseChange?.(!newExpanded);
605
608
  };
606
609
  const currentModeConfig = modeConfigs[selectedMode] || {};
607
- const currentUnit = currentModeConfig.unit || defaultUnit;
610
+ const currentUnit = currentModeConfig.unit || defaultUnit || scaling?.units || "Units";
608
611
  const currentLabel = currentModeConfig.label || defaultLabel;
609
- const currentMin = currentModeConfig.min ?? defaultMin;
610
- const currentMax = currentModeConfig.max ?? defaultMax;
611
- const currentPrecision = currentModeConfig.precision ?? defaultPrecision;
612
+ const currentMin = currentModeConfig.min ?? defaultMin ?? scaling?.euLow ?? 0;
613
+ const currentMax = currentModeConfig.max ?? defaultMax ?? scaling?.euHigh ?? 100;
614
+ const currentPrecision = currentModeConfig.precision ?? defaultPrecision ?? scaling?.decimals ?? 2;
612
615
  const hasSecondSetpoint = currentModeConfig.hasSecondSetpoint || false;
613
616
  const baseClass = "ui-control-panel";
614
617
  const statusClass = status ? `ui-control-panel--${status}` : "";
@@ -996,6 +999,31 @@ var NodeControlsPanel = ({
996
999
 
997
1000
  // src/components/DeviceControlPanel/DeviceControlPanel.tsx
998
1001
  var import_react5 = require("react");
1002
+
1003
+ // src/theme/statusColors.ts
1004
+ var DEFAULT_STATUS_COLORS = {
1005
+ normal: { fill: "#888888", stroke: "#6b6b6b", bg: "#888888", text: "#ffffff", border: "#6b6b6b" },
1006
+ warning: { fill: "#f59e0b", stroke: "#d97706", bg: "#FFD700", text: "#000000", border: "#d97706" },
1007
+ alarm: { fill: "#ef4444", stroke: "#dc2626", bg: "#FF0000", text: "#ffffff", border: "#dc2626" },
1008
+ fault: { fill: "#dc2626", stroke: "#b91c1c", bg: "#FF0000", text: "#ffffff", border: "#b91c1c" },
1009
+ off: { fill: "#edeeef", stroke: "#000000", bg: "#666666", text: "#ffffff", border: "#4b5563" },
1010
+ starting: { fill: "#90EE90", stroke: "#22c55e", bg: "#90EE90", text: "#000000", border: "#22c55e" },
1011
+ stopping: { fill: "#FFD700", stroke: "#d97706", bg: "#FFD700", text: "#000000", border: "#d97706" }
1012
+ };
1013
+ var STATUS_ALIASES = {
1014
+ running: "normal",
1015
+ stopped: "off"
1016
+ };
1017
+ function resolveStatusColor(status, customMap) {
1018
+ const raw = (status || "off").toLowerCase();
1019
+ const key = STATUS_ALIASES[raw] ?? raw;
1020
+ const defaults = DEFAULT_STATUS_COLORS[key] ?? DEFAULT_STATUS_COLORS.off;
1021
+ const overrides = customMap?.[key];
1022
+ if (!overrides) return defaults;
1023
+ return { ...defaults, ...overrides };
1024
+ }
1025
+
1026
+ // src/components/DeviceControlPanel/DeviceControlPanel.tsx
999
1027
  var import_jsx_runtime8 = require("react/jsx-runtime");
1000
1028
  function DeviceControlPanel({
1001
1029
  binding,
@@ -1012,6 +1040,8 @@ function DeviceControlPanel({
1012
1040
  onParameterChange,
1013
1041
  onStart,
1014
1042
  onStop,
1043
+ startPending = false,
1044
+ stopPending = false,
1015
1045
  onCustomAction,
1016
1046
  onOpenConfig,
1017
1047
  showConfigButton = true,
@@ -1070,23 +1100,8 @@ function DeviceControlPanel({
1070
1100
  const textColor = binding.theme?.textColor || "#000000";
1071
1101
  const mutedTextColor = "#666666";
1072
1102
  const selectedColor = "#b4d0fe";
1073
- const statusColors = {
1074
- normal: binding.theme?.statusColors?.normal || "#888888",
1075
- // Gray (no alarm)
1076
- warning: binding.theme?.statusColors?.warning || "#FFD700",
1077
- // Yellow
1078
- alarm: binding.theme?.statusColors?.alarm || "#FF0000",
1079
- // Red
1080
- fault: binding.theme?.statusColors?.fault || "#FF0000",
1081
- // Red
1082
- off: binding.theme?.statusColors?.off || "#666666",
1083
- // Dark gray
1084
- starting: binding.theme?.statusColors?.starting || "#90EE90",
1085
- // Light green
1086
- stopping: binding.theme?.statusColors?.stopping || "#FFD700"
1087
- // Yellow
1088
- };
1089
- const statusColor = statusColors[binding.state.status || "normal"];
1103
+ const statusEntry = resolveStatusColor(binding.state.status, binding.theme?.statusColors);
1104
+ const statusColor = statusEntry.bg;
1090
1105
  const handleModeChange = (mode) => {
1091
1106
  onModeChange?.(mode);
1092
1107
  };
@@ -1373,7 +1388,10 @@ function DeviceControlPanel({
1373
1388
  /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
1374
1389
  "div",
1375
1390
  {
1376
- onClick: () => setShowModeModal(true),
1391
+ onClick: () => {
1392
+ setPendingMode(currentMode);
1393
+ setShowModeModal(true);
1394
+ },
1377
1395
  style: {
1378
1396
  padding: "12px",
1379
1397
  backgroundColor: surfaceColor,
@@ -1770,11 +1788,10 @@ function DeviceControlPanel({
1770
1788
  "button",
1771
1789
  {
1772
1790
  onClick: onStart,
1773
- disabled: binding.state.isRunning,
1774
1791
  style: {
1775
1792
  flex: 1,
1776
1793
  height: touchSize,
1777
- backgroundColor: binding.state.isRunning ? "#a3a3a3" : "#10b981",
1794
+ backgroundColor: "#10b981",
1778
1795
  color: "#ffffff",
1779
1796
  border: "none",
1780
1797
  borderRadius: "6px",
@@ -1782,21 +1799,20 @@ function DeviceControlPanel({
1782
1799
  fontWeight: 700,
1783
1800
  fontFamily: "Arial, sans-serif",
1784
1801
  letterSpacing: "0.5px",
1785
- cursor: binding.state.isRunning ? "not-allowed" : "pointer",
1786
- opacity: binding.state.isRunning ? 0.5 : 1
1802
+ cursor: "pointer",
1803
+ opacity: 1
1787
1804
  },
1788
- children: "START"
1805
+ children: startPending ? "STARTING\u2026" : "START"
1789
1806
  }
1790
1807
  ),
1791
1808
  activeCapabilities?.canStop && /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
1792
1809
  "button",
1793
1810
  {
1794
1811
  onClick: onStop,
1795
- disabled: !binding.state.isRunning,
1796
1812
  style: {
1797
1813
  flex: 1,
1798
1814
  height: touchSize,
1799
- backgroundColor: !binding.state.isRunning ? "#a3a3a3" : "#ef4444",
1815
+ backgroundColor: "#ef4444",
1800
1816
  color: "#ffffff",
1801
1817
  border: "none",
1802
1818
  borderRadius: "6px",
@@ -1804,10 +1820,10 @@ function DeviceControlPanel({
1804
1820
  fontWeight: 700,
1805
1821
  fontFamily: "Arial, sans-serif",
1806
1822
  letterSpacing: "0.5px",
1807
- cursor: !binding.state.isRunning ? "not-allowed" : "pointer",
1808
- opacity: !binding.state.isRunning ? 0.5 : 1
1823
+ cursor: "pointer",
1824
+ opacity: 1
1809
1825
  },
1810
- children: "STOP"
1826
+ children: stopPending ? "STOPPING\u2026" : "STOP"
1811
1827
  }
1812
1828
  )
1813
1829
  ] }),
@@ -4113,7 +4129,8 @@ function NodeRenderer({
4113
4129
  onNodeDragStart,
4114
4130
  draggingNodeId,
4115
4131
  dragOffset = { x: 0, y: 0 },
4116
- enableDrag = false
4132
+ enableDrag = false,
4133
+ statusColorMap
4117
4134
  }) {
4118
4135
  return /* @__PURE__ */ (0, import_jsx_runtime15.jsx)("g", { id: "layer-nodes", children: nodes2.filter((node) => node.visible !== false).map((node) => {
4119
4136
  const symbol = getSymbolDefinition(node.symbolId);
@@ -4172,24 +4189,9 @@ function NodeRenderer({
4172
4189
  `translate(${centerX}, ${centerY}) scale(${scaleX}, ${scaleY}) translate(${-centerX}, ${-centerY})`
4173
4190
  );
4174
4191
  }
4175
- const getStatusColor = (status) => {
4176
- switch (status) {
4177
- case "running":
4178
- return "#22c55e";
4179
- // Green
4180
- case "alarm":
4181
- return "#ef4444";
4182
- // Red
4183
- case "warning":
4184
- return "#f59e0b";
4185
- // Orange
4186
- case "stopped":
4187
- default:
4188
- return "#edeeef";
4189
- }
4190
- };
4191
- const fillColor = node.customColors?.fill ?? getStatusColor(node.status);
4192
- const strokeColor = node.customColors?.stroke ?? "rgb(0, 0, 0)";
4192
+ const statusEntry = resolveStatusColor(node.status, statusColorMap);
4193
+ const fillColor = node.customColors?.fill ?? statusEntry.fill;
4194
+ const strokeColor = node.customColors?.stroke ?? statusEntry.stroke;
4193
4195
  return /* @__PURE__ */ (0, import_jsx_runtime15.jsxs)(
4194
4196
  "g",
4195
4197
  {
@@ -4453,14 +4455,16 @@ function DataOverlay({
4453
4455
  x,
4454
4456
  y,
4455
4457
  telemetry,
4456
- scale = 1
4458
+ scale = 1,
4459
+ statusColorMap
4457
4460
  }) {
4458
- const { value, display, history } = telemetry;
4461
+ const { value, display, history, scaling } = telemetry;
4459
4462
  const showValue = display?.showValue ?? true;
4460
4463
  const showStatus = display?.showStatus ?? true;
4461
4464
  const showSparkline = display?.showSparkline ?? false;
4462
4465
  const label = display?.label;
4463
- const precision = display?.precision;
4466
+ const precision = scaling?.decimals ?? display?.precision;
4467
+ const unit = scaling?.units ?? value.unit;
4464
4468
  const sparklineColor = display?.sparklineColor;
4465
4469
  const textColor = display?.textColor;
4466
4470
  const backgroundColor = display?.backgroundColor;
@@ -4478,25 +4482,13 @@ function DataOverlay({
4478
4482
  }
4479
4483
  return val.toString();
4480
4484
  };
4481
- const defaultStatusColors = {
4482
- normal: { bg: "#10b981", text: "#ffffff", border: "#059669" },
4483
- warning: { bg: "#f59e0b", text: "#ffffff", border: "#d97706" },
4484
- alarm: { bg: "#ef4444", text: "#ffffff", border: "#dc2626" },
4485
- fault: { bg: "#dc2626", text: "#ffffff", border: "#b91c1c" },
4486
- off: { bg: "#6b7280", text: "#ffffff", border: "#4b5563" }
4487
- };
4488
- const getStatusColors = () => {
4489
- const customColor = telemetry.statusColors?.[value.status];
4490
- if (customColor) {
4491
- return { bg: customColor, text: "#ffffff", border: customColor };
4492
- }
4493
- return defaultStatusColors[value.status] || defaultStatusColors.normal;
4494
- };
4495
- const statusColors = getStatusColors();
4485
+ const resolvedEntry = resolveStatusColor(value.status, statusColorMap);
4486
+ const customColor = telemetry.statusColors?.[value.status];
4487
+ const statusColors = customColor ? { bg: customColor, text: "#ffffff", border: customColor } : { bg: resolvedEntry.bg, text: resolvedEntry.text, border: resolvedEntry.border };
4496
4488
  const colors = backgroundColor && backgroundColor !== "status" ? { bg: backgroundColor, text: textColor || "#ffffff", border: backgroundColor } : statusColors;
4497
4489
  const finalTextColor = textColor || colors.text;
4498
4490
  const formattedValue = formatValue(value.value);
4499
- const displayText = value.unit ? `${formattedValue} ${value.unit}` : formattedValue;
4491
+ const displayText = unit ? `${formattedValue} ${unit}` : formattedValue;
4500
4492
  const sparklinePath = (() => {
4501
4493
  if (!showSparkline || !history || history.length < 2) return "";
4502
4494
  const width = 70 * scale;
@@ -4504,8 +4496,9 @@ function DataOverlay({
4504
4496
  const padding = 5 * scale;
4505
4497
  const effectiveWidth = width - padding * 2;
4506
4498
  const effectiveHeight = height - padding * 2;
4507
- const min = Math.min(...history);
4508
- const max = Math.max(...history);
4499
+ const hasEuRange = scaling?.euLow !== void 0 && scaling?.euHigh !== void 0 && scaling.euHigh !== scaling.euLow;
4500
+ const min = hasEuRange ? scaling.euLow : Math.min(...history);
4501
+ const max = hasEuRange ? scaling.euHigh : Math.max(...history);
4509
4502
  const range = max - min || 1;
4510
4503
  const points = history.map((val, i) => {
4511
4504
  const xPos = i / (history.length - 1) * effectiveWidth + padding;
@@ -5763,6 +5756,7 @@ function PIDCanvas({
5763
5756
  onPipeCreated,
5764
5757
  onMounted,
5765
5758
  telemetry,
5759
+ statusColorMap,
5766
5760
  ...props
5767
5761
  }) {
5768
5762
  const [localDiagram, setLocalDiagram] = (0, import_react16.useState)(diagram);
@@ -6416,7 +6410,8 @@ function PIDCanvas({
6416
6410
  enableDrag: dragEnabled && !isConnecting,
6417
6411
  onNodeDragStart: dragEnabled && !isConnecting ? handleNodeDragStart : void 0,
6418
6412
  draggingNodeId: dragState.draggedId,
6419
- dragOffset: dragState.offset
6413
+ dragOffset: dragState.offset,
6414
+ statusColorMap
6420
6415
  }
6421
6416
  ),
6422
6417
  (features.editPipeRoutes ?? false) && /* @__PURE__ */ (0, import_jsx_runtime19.jsx)(
@@ -6450,7 +6445,8 @@ function PIDCanvas({
6450
6445
  x: overlayX,
6451
6446
  y: overlayY,
6452
6447
  telemetry: binding,
6453
- scale: features.telemetryScale ?? 1
6448
+ scale: features.telemetryScale ?? 1,
6449
+ statusColorMap
6454
6450
  },
6455
6451
  `telemetry-${node.id}`
6456
6452
  );
@@ -7664,7 +7660,11 @@ function createControlBinding(nodeId, deviceConfig, actions) {
7664
7660
 
7665
7661
  // src/hooks/useDeviceControls.ts
7666
7662
  var import_react18 = require("react");
7667
- function useDeviceControls(initialBindings) {
7663
+ function commandKey(nodeId, kind, suffix) {
7664
+ return suffix ? `${nodeId}:${kind}:${suffix}` : `${nodeId}:${kind}`;
7665
+ }
7666
+ function useDeviceControls(initialBindings, options) {
7667
+ const { commandTimeoutMs } = options ?? {};
7668
7668
  const [controls, setControls] = (0, import_react18.useState)(() => {
7669
7669
  const map = /* @__PURE__ */ new Map();
7670
7670
  if (initialBindings) {
@@ -7674,6 +7674,11 @@ function useDeviceControls(initialBindings) {
7674
7674
  }
7675
7675
  return map;
7676
7676
  });
7677
+ const pendingRef = (0, import_react18.useRef)(/* @__PURE__ */ new Map());
7678
+ const [pendingCommands, setPendingCommands] = (0, import_react18.useState)(() => /* @__PURE__ */ new Set());
7679
+ const syncPending = (0, import_react18.useCallback)(() => {
7680
+ setPendingCommands(new Set(pendingRef.current.keys()));
7681
+ }, []);
7677
7682
  const updateControl = (0, import_react18.useCallback)((nodeId, updates) => {
7678
7683
  setControls((prev) => {
7679
7684
  const newMap = new Map(prev);
@@ -7763,121 +7768,112 @@ function useDeviceControls(initialBindings) {
7763
7768
  },
7764
7769
  [controls]
7765
7770
  );
7766
- const sendModeChange = (0, import_react18.useCallback)(
7767
- async (nodeId, mode) => {
7768
- const binding = controls.get(nodeId);
7769
- if (!binding) return;
7770
- updateDeviceState(nodeId, { currentMode: mode });
7771
- try {
7772
- await binding.actions.onModeChange?.(mode);
7773
- updateDeviceState(nodeId, {
7774
- lastCommandTime: Date.now(),
7775
- lastCommandResult: { success: true, message: `Mode changed to ${mode}` }
7776
- });
7777
- } catch (error) {
7778
- updateDeviceState(nodeId, {
7779
- currentMode: binding.state.currentMode,
7780
- lastCommandResult: {
7781
- success: false,
7782
- message: error instanceof Error ? error.message : "Mode change failed"
7783
- }
7784
- });
7771
+ const isCommandPending = (0, import_react18.useCallback)(
7772
+ (nodeId, kind) => {
7773
+ if (kind) {
7774
+ const prefix2 = `${nodeId}:${kind}`;
7775
+ for (const key of pendingCommands) {
7776
+ if (key === prefix2 || key.startsWith(`${prefix2}:`)) return true;
7777
+ }
7778
+ return false;
7785
7779
  }
7780
+ const prefix = `${nodeId}:`;
7781
+ for (const key of pendingCommands) {
7782
+ if (key.startsWith(prefix)) return true;
7783
+ }
7784
+ return false;
7786
7785
  },
7787
- [controls, updateDeviceState]
7786
+ [pendingCommands]
7788
7787
  );
7789
- const sendParameterChange = (0, import_react18.useCallback)(
7790
- async (nodeId, parameterId, value) => {
7791
- const binding = controls.get(nodeId);
7792
- if (!binding) return;
7793
- updateParameter(nodeId, parameterId, value);
7794
- try {
7795
- await binding.actions.onParameterChange?.(parameterId, value);
7796
- updateDeviceState(nodeId, {
7797
- lastCommandTime: Date.now(),
7798
- lastCommandResult: { success: true, message: `Parameter ${parameterId} updated` }
7799
- });
7800
- } catch (error) {
7801
- updateDeviceState(nodeId, {
7802
- lastCommandResult: {
7803
- success: false,
7804
- message: error instanceof Error ? error.message : "Parameter change failed"
7805
- }
7806
- });
7807
- }
7788
+ const markPending = (0, import_react18.useCallback)(
7789
+ (key, delta) => {
7790
+ const next = (pendingRef.current.get(key) ?? 0) + delta;
7791
+ if (next > 0) pendingRef.current.set(key, next);
7792
+ else pendingRef.current.delete(key);
7793
+ syncPending();
7808
7794
  },
7809
- [controls, updateParameter, updateDeviceState]
7795
+ [syncPending]
7810
7796
  );
7811
- const sendStartCommand = (0, import_react18.useCallback)(
7812
- async (nodeId) => {
7797
+ const runCommand = (0, import_react18.useCallback)(
7798
+ async (nodeId, key, invoke, messages) => {
7813
7799
  const binding = controls.get(nodeId);
7814
7800
  if (!binding) return;
7815
- updateDeviceState(nodeId, { isRunning: true, status: "starting" });
7801
+ markPending(key, 1);
7802
+ let timer;
7816
7803
  try {
7817
- await binding.actions.onStart?.();
7804
+ const action = Promise.resolve(invoke(binding));
7805
+ if (commandTimeoutMs && commandTimeoutMs > 0) {
7806
+ await Promise.race([
7807
+ action,
7808
+ new Promise((_, reject) => {
7809
+ timer = setTimeout(
7810
+ () => reject(new Error(`${messages.success.replace(/ sent$/, "")} timed out (unconfirmed)`)),
7811
+ commandTimeoutMs
7812
+ );
7813
+ })
7814
+ ]);
7815
+ } else {
7816
+ await action;
7817
+ }
7818
7818
  updateDeviceState(nodeId, {
7819
- status: "normal",
7820
7819
  lastCommandTime: Date.now(),
7821
- lastCommandResult: { success: true, message: "Device started" }
7820
+ lastCommandResult: { success: true, message: messages.success }
7822
7821
  });
7823
7822
  } catch (error) {
7824
7823
  updateDeviceState(nodeId, {
7825
- isRunning: false,
7826
- status: "fault",
7824
+ lastCommandTime: Date.now(),
7827
7825
  lastCommandResult: {
7828
7826
  success: false,
7829
- message: error instanceof Error ? error.message : "Start failed"
7827
+ message: error instanceof Error ? error.message : messages.failure
7830
7828
  }
7831
7829
  });
7830
+ } finally {
7831
+ if (timer) clearTimeout(timer);
7832
+ markPending(key, -1);
7832
7833
  }
7833
7834
  },
7834
- [controls, updateDeviceState]
7835
+ [controls, updateDeviceState, markPending, commandTimeoutMs]
7836
+ );
7837
+ const sendModeChange = (0, import_react18.useCallback)(
7838
+ (nodeId, mode) => runCommand(
7839
+ nodeId,
7840
+ commandKey(nodeId, "mode"),
7841
+ (binding) => binding.actions.onModeChange?.(mode),
7842
+ { success: `Mode ${mode} command sent`, failure: "Mode change failed" }
7843
+ ),
7844
+ [runCommand]
7845
+ );
7846
+ const sendParameterChange = (0, import_react18.useCallback)(
7847
+ (nodeId, parameterId, value) => runCommand(
7848
+ nodeId,
7849
+ commandKey(nodeId, "parameter", parameterId),
7850
+ (binding) => binding.actions.onParameterChange?.(parameterId, value),
7851
+ { success: `Parameter ${parameterId} command sent`, failure: "Parameter change failed" }
7852
+ ),
7853
+ [runCommand]
7854
+ );
7855
+ const sendStartCommand = (0, import_react18.useCallback)(
7856
+ (nodeId) => runCommand(nodeId, commandKey(nodeId, "start"), (binding) => binding.actions.onStart?.(), {
7857
+ success: "Start command sent",
7858
+ failure: "Start command failed"
7859
+ }),
7860
+ [runCommand]
7835
7861
  );
7836
7862
  const sendStopCommand = (0, import_react18.useCallback)(
7837
- async (nodeId) => {
7838
- const binding = controls.get(nodeId);
7839
- if (!binding) return;
7840
- updateDeviceState(nodeId, { isRunning: false, status: "stopping" });
7841
- try {
7842
- await binding.actions.onStop?.();
7843
- updateDeviceState(nodeId, {
7844
- status: "off",
7845
- lastCommandTime: Date.now(),
7846
- lastCommandResult: { success: true, message: "Device stopped" }
7847
- });
7848
- } catch (error) {
7849
- updateDeviceState(nodeId, {
7850
- isRunning: true,
7851
- status: "fault",
7852
- lastCommandResult: {
7853
- success: false,
7854
- message: error instanceof Error ? error.message : "Stop failed"
7855
- }
7856
- });
7857
- }
7858
- },
7859
- [controls, updateDeviceState]
7863
+ (nodeId) => runCommand(nodeId, commandKey(nodeId, "stop"), (binding) => binding.actions.onStop?.(), {
7864
+ success: "Stop command sent",
7865
+ failure: "Stop command failed"
7866
+ }),
7867
+ [runCommand]
7860
7868
  );
7861
7869
  const sendCustomAction = (0, import_react18.useCallback)(
7862
- async (nodeId, actionId) => {
7863
- const binding = controls.get(nodeId);
7864
- if (!binding) return;
7865
- try {
7866
- await binding.actions.onCustomAction?.(actionId);
7867
- updateDeviceState(nodeId, {
7868
- lastCommandTime: Date.now(),
7869
- lastCommandResult: { success: true, message: `Action ${actionId} executed` }
7870
- });
7871
- } catch (error) {
7872
- updateDeviceState(nodeId, {
7873
- lastCommandResult: {
7874
- success: false,
7875
- message: error instanceof Error ? error.message : "Action failed"
7876
- }
7877
- });
7878
- }
7879
- },
7880
- [controls, updateDeviceState]
7870
+ (nodeId, actionId) => runCommand(
7871
+ nodeId,
7872
+ commandKey(nodeId, "action", actionId),
7873
+ (binding) => binding.actions.onCustomAction?.(actionId),
7874
+ { success: `Action ${actionId} command sent`, failure: "Action failed" }
7875
+ ),
7876
+ [runCommand]
7881
7877
  );
7882
7878
  return {
7883
7879
  controls,
@@ -7888,6 +7884,10 @@ function useDeviceControls(initialBindings) {
7888
7884
  setControlBinding,
7889
7885
  removeControlBinding,
7890
7886
  getControlBinding,
7887
+ // In-flight command visibility (interaction state, not device state).
7888
+ // `isCommandPending` is the stable query surface; the raw key set is
7889
+ // intentionally not exported (its key format is an implementation detail).
7890
+ isCommandPending,
7891
7891
  // Command helpers
7892
7892
  sendModeChange,
7893
7893
  sendParameterChange,
@@ -8946,6 +8946,7 @@ function useSimulation(telemetry, updateTelemetryBatch, options = {}) {
8946
8946
  CardValue,
8947
8947
  CircularGauge,
8948
8948
  ControlPanel,
8949
+ DEFAULT_STATUS_COLORS,
8949
8950
  DEFAULT_THRESHOLDS,
8950
8951
  DashboardCard,
8951
8952
  DeviceConfigPanel,
@@ -8995,6 +8996,7 @@ function useSimulation(telemetry, updateTelemetryBatch, options = {}) {
8995
8996
  overlayStyles,
8996
8997
  registerSymbol,
8997
8998
  registerSymbols,
8999
+ resolveStatusColor,
8998
9000
  useDeviceConfigs,
8999
9001
  useDeviceControls,
9000
9002
  useDrag,