summaryrefslogtreecommitdiff
path: root/ui/control_panel.py
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-04-20 16:55:57 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-04-20 16:55:57 -0600
commitd5acb04b88373d33b038bb59945fb5ab8b4f543b (patch)
tree21c37f2b54258ef20edc1b9b7226327d5f5b1a3a /ui/control_panel.py
parent425ba78ee1f760978b23a09fe8acbbc9b8b5dae4 (diff)
V8
Diffstat (limited to 'ui/control_panel.py')
-rw-r--r--ui/control_panel.py559
1 files changed, 559 insertions, 0 deletions
diff --git a/ui/control_panel.py b/ui/control_panel.py
new file mode 100644
index 0000000..98f0637
--- /dev/null
+++ b/ui/control_panel.py
@@ -0,0 +1,559 @@
+"""
+ui/control_panel.py
+
+Left-panel Control Panel.
+
+Contains modular output control widgets:
+ • OnOffSwitch — latching power switch with indicator
+ • MotorControl — speed dial (0–100 %), direction toggle, start/stop
+ • SetpointControl — numeric target with ± step buttons and live readback
+ • PwmControl — PWM duty cycle slider with frequency setting
+ • GenericOutput — generic voltage/current analog output entry
+
+Each widget is self-contained and writes to a device channel via the
+DeviceRegistry when a device+channel is configured.
+
+New widget types can be added by subclassing ControlWidget and dropping
+an instance into ControlPanel.add_widget().
+"""
+
+from __future__ import annotations
+from typing import Optional
+
+from PyQt6.QtWidgets import (
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel,
+ QPushButton, QSlider, QDoubleSpinBox, QComboBox,
+ QScrollArea, QFrame, QSizePolicy, QGridLayout,
+ QSpinBox, QGroupBox,
+)
+from PyQt6.QtCore import Qt, pyqtSignal, QTimer
+from PyQt6.QtGui import QFont
+
+from devices.device_registry import DeviceRegistry
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Base widget
+# ══════════════════════════════════════════════════════════════════════════════
+
+class ControlWidget(QFrame):
+ """Base class for all control panel widgets."""
+
+ value_changed = pyqtSignal(str, float) # channel_id, value
+
+ def __init__(self, title: str, icon: str = "⚙",
+ device_id: str = "", channel_id: str = "",
+ registry: Optional[DeviceRegistry] = None):
+ super().__init__()
+ self.title = title
+ self.icon = icon
+ self.device_id = device_id
+ self.channel_id = channel_id
+ self.registry = registry
+ self.setObjectName("controlWidget")
+ self._build_frame()
+
+ def _build_frame(self):
+ """Build the common outer frame; subclasses fill self._body."""
+ outer = QVBoxLayout(self)
+ outer.setContentsMargins(0, 0, 0, 0)
+ outer.setSpacing(0)
+
+ # Header bar
+ hdr = QWidget()
+ hdr.setObjectName("controlWidgetHeader")
+ hdr.setFixedHeight(26)
+ hdr_lay = QHBoxLayout(hdr)
+ hdr_lay.setContentsMargins(8, 0, 8, 0)
+ icon_lbl = QLabel(self.icon)
+ icon_lbl.setObjectName("controlWidgetIcon")
+ title_lbl = QLabel(self.title.upper())
+ title_lbl.setObjectName("controlWidgetTitle")
+ hdr_lay.addWidget(icon_lbl)
+ hdr_lay.addWidget(title_lbl, 1)
+ outer.addWidget(hdr)
+
+ # Body — subclasses populate this
+ self._body = QWidget()
+ self._body.setObjectName("controlWidgetBody")
+ body_lay = QVBoxLayout(self._body)
+ body_lay.setContentsMargins(10, 8, 10, 10)
+ body_lay.setSpacing(6)
+ self._body_layout = body_lay
+ outer.addWidget(self._body)
+
+ def _write(self, value: float):
+ """Write value to the linked device channel (if any)."""
+ if self.registry and self.device_id and self.channel_id:
+ dev = self.registry.get_instance(self.device_id)
+ if dev:
+ dev.write_channel(self.channel_id, value)
+ self.value_changed.emit(self.channel_id, value)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# On/Off Switch
+# ══════════════════════════════════════════════════════════════════════════════
+
+class OnOffSwitch(ControlWidget):
+ """Large latching power switch with green/red indicator."""
+
+ def __init__(self, title: str = "Power", **kw):
+ super().__init__(title, icon="⏻", **kw)
+ self._state = False
+ self._build_body()
+
+ def _build_body(self):
+ lay = self._body_layout
+
+ # Big toggle button
+ self._btn = QPushButton("OFF")
+ self._btn.setObjectName("switchBtn")
+ self._btn.setCheckable(True)
+ self._btn.setMinimumHeight(52)
+ self._btn.toggled.connect(self._on_toggle)
+ lay.addWidget(self._btn)
+
+ # Status indicator row
+ ind_row = QHBoxLayout()
+ self._indicator = QLabel("●")
+ self._indicator.setObjectName("switchIndicatorOff")
+ self._state_lbl = QLabel("INACTIVE")
+ self._state_lbl.setObjectName("switchStateLbl")
+ ind_row.addStretch()
+ ind_row.addWidget(self._indicator)
+ ind_row.addWidget(self._state_lbl)
+ ind_row.addStretch()
+ lay.addLayout(ind_row)
+
+ def _on_toggle(self, checked: bool):
+ self._state = checked
+ if checked:
+ self._btn.setText("ON")
+ self._btn.setObjectName("switchBtnOn")
+ self._indicator.setObjectName("switchIndicatorOn")
+ self._state_lbl.setText("ACTIVE")
+ else:
+ self._btn.setText("OFF")
+ self._btn.setObjectName("switchBtnOff")
+ self._indicator.setObjectName("switchIndicatorOff")
+ self._state_lbl.setText("INACTIVE")
+ # Re-polish so QSS picks up new objectName
+ for w in (self._btn, self._indicator, self._state_lbl):
+ w.style().unpolish(w); w.style().polish(w)
+ self._write(1.0 if checked else 0.0)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Motor Control
+# ══════════════════════════════════════════════════════════════════════════════
+
+class MotorControl(ControlWidget):
+ """Motor speed (0–100 %), direction toggle, start/stop."""
+
+ def __init__(self, title: str = "Motor", max_rpm: int = 3000, **kw):
+ super().__init__(title, icon="⟳", **kw)
+ self.max_rpm = max_rpm
+ self._running = False
+ self._fwd = True
+ self._build_body()
+
+ def _build_body(self):
+ lay = self._body_layout
+
+ # Speed readout
+ readout_row = QHBoxLayout()
+ self._speed_lbl = QLabel("0")
+ self._speed_lbl.setObjectName("motorSpeedLbl")
+ unit_lbl = QLabel("RPM")
+ unit_lbl.setObjectName("motorUnitLbl")
+ readout_row.addStretch()
+ readout_row.addWidget(self._speed_lbl)
+ readout_row.addWidget(unit_lbl)
+ readout_row.addStretch()
+ lay.addLayout(readout_row)
+
+ # Speed slider
+ self._slider = QSlider(Qt.Orientation.Horizontal)
+ self._slider.setRange(0, 100)
+ self._slider.setValue(0)
+ self._slider.setObjectName("motorSlider")
+ self._slider.valueChanged.connect(self._on_speed)
+ lay.addWidget(self._slider)
+
+ # Pct label
+ self._pct_lbl = QLabel("0 %")
+ self._pct_lbl.setObjectName("motorPctLbl")
+ self._pct_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ lay.addWidget(self._pct_lbl)
+
+ # Direction + start/stop row
+ btn_row = QHBoxLayout()
+
+ self._dir_btn = QPushButton("▶ FWD")
+ self._dir_btn.setObjectName("motorDirBtn")
+ self._dir_btn.setCheckable(True)
+ self._dir_btn.toggled.connect(self._on_dir)
+ btn_row.addWidget(self._dir_btn)
+
+ self._run_btn = QPushButton("START")
+ self._run_btn.setObjectName("motorRunBtnOff")
+ self._run_btn.setCheckable(True)
+ self._run_btn.toggled.connect(self._on_run)
+ btn_row.addWidget(self._run_btn)
+
+ lay.addLayout(btn_row)
+
+ def _on_speed(self, pct: int):
+ rpm = int(pct / 100.0 * self.max_rpm)
+ self._speed_lbl.setText(str(rpm))
+ self._pct_lbl.setText(f"{pct} %")
+ if self._running:
+ self._write(float(rpm) * (1 if self._fwd else -1))
+
+ def _on_dir(self, rev: bool):
+ self._fwd = not rev
+ self._dir_btn.setText("◀ REV" if rev else "▶ FWD")
+ self._dir_btn.setObjectName("motorDirBtnRev" if rev else "motorDirBtn")
+ self._dir_btn.style().unpolish(self._dir_btn)
+ self._dir_btn.style().polish(self._dir_btn)
+ self._on_speed(self._slider.value())
+
+ def _on_run(self, running: bool):
+ self._running = running
+ self._run_btn.setText("STOP" if running else "START")
+ self._run_btn.setObjectName("motorRunBtnOn" if running else "motorRunBtnOff")
+ self._run_btn.style().unpolish(self._run_btn)
+ self._run_btn.style().polish(self._run_btn)
+ if not running:
+ self._write(0.0)
+ else:
+ self._on_speed(self._slider.value())
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Setpoint Control
+# ══════════════════════════════════════════════════════════════════════════════
+
+class SetpointControl(ControlWidget):
+ """Numeric setpoint with ± step buttons and live process-value readback."""
+
+ def __init__(self, title: str = "Setpoint", unit: str = "",
+ min_val: float = 0.0, max_val: float = 100.0,
+ step: float = 1.0, **kw):
+ super().__init__(title, icon="◎", **kw)
+ self.unit = unit
+ self.min_val = min_val
+ self.max_val = max_val
+ self.step = step
+ self._pv = 0.0
+ self._build_body()
+
+ def _build_body(self):
+ lay = self._body_layout
+
+ # SP row
+ sp_row = QHBoxLayout()
+ sp_lbl = QLabel("SP")
+ sp_lbl.setObjectName("spLabel")
+ sp_lbl.setFixedWidth(24)
+
+ self._minus_btn = QPushButton("−")
+ self._minus_btn.setObjectName("spStepBtn")
+ self._minus_btn.setFixedWidth(32)
+ self._minus_btn.clicked.connect(self._decrement)
+
+ self._sp_spin = QDoubleSpinBox()
+ self._sp_spin.setRange(self.min_val, self.max_val)
+ self._sp_spin.setSingleStep(self.step)
+ self._sp_spin.setSuffix(f" {self.unit}")
+ self._sp_spin.setObjectName("spSpinbox")
+ self._sp_spin.valueChanged.connect(self._on_sp_changed)
+
+ self._plus_btn = QPushButton("+")
+ self._plus_btn.setObjectName("spStepBtn")
+ self._plus_btn.setFixedWidth(32)
+ self._plus_btn.clicked.connect(self._increment)
+
+ sp_row.addWidget(sp_lbl)
+ sp_row.addWidget(self._minus_btn)
+ sp_row.addWidget(self._sp_spin, 1)
+ sp_row.addWidget(self._plus_btn)
+ lay.addLayout(sp_row)
+
+ # PV readback row
+ pv_row = QHBoxLayout()
+ pv_lbl = QLabel("PV")
+ pv_lbl.setObjectName("pvLabel")
+ pv_lbl.setFixedWidth(24)
+ self._pv_lbl = QLabel(f"— {self.unit}")
+ self._pv_lbl.setObjectName("pvValue")
+ pv_row.addWidget(pv_lbl)
+ pv_row.addWidget(self._pv_lbl, 1)
+ lay.addLayout(pv_row)
+
+ # Error bar
+ err_row = QHBoxLayout()
+ err_lbl = QLabel("ERR")
+ err_lbl.setObjectName("errLabel")
+ err_lbl.setFixedWidth(24)
+ self._err_lbl = QLabel("—")
+ self._err_lbl.setObjectName("errValue")
+ err_row.addWidget(err_lbl)
+ err_row.addWidget(self._err_lbl, 1)
+ lay.addLayout(err_row)
+
+ def update_pv(self, value: float):
+ """Call this with live process-variable readings."""
+ self._pv = value
+ self._pv_lbl.setText(f"{value:.3f} {self.unit}")
+ err = self._sp_spin.value() - value
+ self._err_lbl.setText(f"{err:+.3f} {self.unit}")
+ self._err_lbl.setStyleSheet(
+ "color:#ef4444;" if abs(err) > 1.0 else "color:#22c55e;"
+ )
+
+ def _on_sp_changed(self, v: float):
+ self._write(v)
+
+ def _increment(self):
+ self._sp_spin.setValue(self._sp_spin.value() + self.step)
+
+ def _decrement(self):
+ self._sp_spin.setValue(self._sp_spin.value() - self.step)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# PWM Control
+# ══════════════════════════════════════════════════════════════════════════════
+
+class PwmControl(ControlWidget):
+ """PWM duty cycle slider + frequency setting."""
+
+ def __init__(self, title: str = "PWM Output", **kw):
+ super().__init__(title, icon="⊓", **kw)
+ self._build_body()
+
+ def _build_body(self):
+ lay = self._body_layout
+
+ # Duty cycle
+ dc_row = QHBoxLayout()
+ dc_lbl = QLabel("Duty:")
+ dc_lbl.setObjectName("pwmLabel")
+ self._dc_lbl = QLabel("0 %")
+ self._dc_lbl.setObjectName("pwmValue")
+ dc_row.addWidget(dc_lbl)
+ dc_row.addStretch()
+ dc_row.addWidget(self._dc_lbl)
+ lay.addLayout(dc_row)
+
+ self._dc_slider = QSlider(Qt.Orientation.Horizontal)
+ self._dc_slider.setRange(0, 100)
+ self._dc_slider.setValue(0)
+ self._dc_slider.setObjectName("pwmSlider")
+ self._dc_slider.valueChanged.connect(self._on_dc)
+ lay.addWidget(self._dc_slider)
+
+ # Frequency
+ freq_row = QHBoxLayout()
+ freq_lbl = QLabel("Freq:")
+ freq_lbl.setObjectName("pwmLabel")
+ self._freq_spin = QSpinBox()
+ self._freq_spin.setRange(1, 100_000)
+ self._freq_spin.setValue(1000)
+ self._freq_spin.setSuffix(" Hz")
+ self._freq_spin.setObjectName("pwmFreqSpin")
+ self._freq_spin.valueChanged.connect(self._on_freq)
+ freq_row.addWidget(freq_lbl)
+ freq_row.addWidget(self._freq_spin)
+ lay.addLayout(freq_row)
+
+ # Enable
+ self._en_btn = QPushButton("ENABLE")
+ self._en_btn.setObjectName("motorRunBtnOff")
+ self._en_btn.setCheckable(True)
+ self._en_btn.toggled.connect(self._on_enable)
+ lay.addWidget(self._en_btn)
+
+ def _on_dc(self, v: int):
+ self._dc_lbl.setText(f"{v} %")
+ if self._en_btn.isChecked():
+ self._write(float(v))
+
+ def _on_freq(self, hz: int):
+ pass # TODO: send freq command to hardware
+
+ def _on_enable(self, en: bool):
+ self._en_btn.setText("DISABLE" if en else "ENABLE")
+ self._en_btn.setObjectName("motorRunBtnOn" if en else "motorRunBtnOff")
+ self._en_btn.style().unpolish(self._en_btn)
+ self._en_btn.style().polish(self._en_btn)
+ self._write(float(self._dc_slider.value()) if en else 0.0)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Generic Analog Output
+# ══════════════════════════════════════════════════════════════════════════════
+
+class AnalogOutputControl(ControlWidget):
+ """Generic voltage/current analog output with spinbox + send button."""
+
+ def __init__(self, title: str = "Analog Out", unit: str = "V",
+ min_val: float = 0.0, max_val: float = 10.0, **kw):
+ super().__init__(title, icon="↗", **kw)
+ self.unit = unit
+ self.min_val = min_val
+ self.max_val = max_val
+ self._build_body()
+
+ def _build_body(self):
+ lay = self._body_layout
+
+ val_row = QHBoxLayout()
+ self._spin = QDoubleSpinBox()
+ self._spin.setRange(self.min_val, self.max_val)
+ self._spin.setSingleStep(0.01)
+ self._spin.setSuffix(f" {self.unit}")
+ self._spin.setObjectName("spSpinbox")
+ val_row.addWidget(self._spin, 1)
+
+ send_btn = QPushButton("SET")
+ send_btn.setObjectName("applyButton")
+ send_btn.clicked.connect(lambda: self._write(self._spin.value()))
+ val_row.addWidget(send_btn)
+ lay.addLayout(val_row)
+
+ # Slider mirror
+ self._slider = QSlider(Qt.Orientation.Horizontal)
+ self._slider.setRange(0, 1000)
+ self._slider.setObjectName("motorSlider")
+ self._slider.valueChanged.connect(self._on_slider)
+ self._spin.valueChanged.connect(self._sync_slider)
+ lay.addWidget(self._slider)
+
+ def _on_slider(self, v: int):
+ mapped = self.min_val + (v / 1000.0) * (self.max_val - self.min_val)
+ self._spin.blockSignals(True)
+ self._spin.setValue(mapped)
+ self._spin.blockSignals(False)
+
+ def _sync_slider(self, v: float):
+ rng = self.max_val - self.min_val
+ norm = int(((v - self.min_val) / rng) * 1000) if rng else 0
+ self._slider.blockSignals(True)
+ self._slider.setValue(max(0, min(1000, norm)))
+ self._slider.blockSignals(False)
+
+
+# ══════════════════════════════════════════════════════════════════════════════
+# Control Panel container
+# ══════════════════════════════════════════════════════════════════════════════
+
+class ControlPanel(QWidget):
+ """Left panel — output control widgets only. No device management here."""
+
+ def __init__(self, registry: DeviceRegistry):
+ super().__init__()
+ self.registry = registry
+ self._widgets: list[ControlWidget] = []
+ self._build()
+ self._add_demo_widgets()
+
+ def _build(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ hdr = QLabel(" CONTROLS")
+ hdr.setObjectName("panelHeader")
+ hdr.setMinimumHeight(28)
+ layout.addWidget(hdr)
+
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ scroll.setObjectName("deviceScroll")
+
+ self._container = QWidget()
+ self._inner = QVBoxLayout(self._container)
+ self._inner.setContentsMargins(8, 8, 8, 8)
+ self._inner.setSpacing(10)
+ self._inner.addStretch()
+
+ scroll.setWidget(self._container)
+ layout.addWidget(scroll)
+
+ def add_widget(self, widget: ControlWidget):
+ """Add a control widget to the panel."""
+ self._widgets.append(widget)
+ self._inner.insertWidget(self._inner.count() - 1, widget)
+
+ def clear_widgets(self):
+ """Remove all control widgets."""
+ for w in self._widgets:
+ self._inner.removeWidget(w)
+ w.deleteLater()
+ self._widgets.clear()
+
+ def _add_demo_widgets(self):
+ """Default demo configuration — replace with your lab setup."""
+
+ # Pump power switch
+ pump = OnOffSwitch(
+ title="Pump Power",
+ registry=self.registry,
+ device_id="dio_0", channel_id="do0",
+ )
+ self.add_widget(pump)
+
+ # Heater switch
+ heater = OnOffSwitch(
+ title="Heater",
+ registry=self.registry,
+ device_id="dio_0", channel_id="do1",
+ )
+ self.add_widget(heater)
+
+ # Motor controller
+ motor = MotorControl(
+ title="Drive Motor",
+ max_rpm=3000,
+ registry=self.registry,
+ device_id="ard_0", channel_id="A0",
+ )
+ self.add_widget(motor)
+
+ # Temperature setpoint
+ temp_sp = SetpointControl(
+ title="Temp Setpoint",
+ unit="°C",
+ min_val=0.0, max_val=300.0,
+ step=0.5,
+ )
+ self.add_widget(temp_sp)
+
+ # Flow setpoint
+ flow_sp = SetpointControl(
+ title="Flow Rate",
+ unit="mL/min",
+ min_val=0.0, max_val=500.0,
+ step=5.0,
+ )
+ self.add_widget(flow_sp)
+
+ # PWM output
+ pwm = PwmControl(
+ title="PWM Ch 1",
+ registry=self.registry,
+ device_id="dio_0", channel_id="do2",
+ )
+ self.add_widget(pwm)
+
+ # Analog output
+ ao = AnalogOutputControl(
+ title="Analog Out",
+ unit="V",
+ min_val=0.0, max_val=10.0,
+ )
+ self.add_widget(ao)