diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-04-20 16:55:57 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-04-20 16:55:57 -0600 |
| commit | d5acb04b88373d33b038bb59945fb5ab8b4f543b (patch) | |
| tree | 21c37f2b54258ef20edc1b9b7226327d5f5b1a3a /ui/control_panel.py | |
| parent | 425ba78ee1f760978b23a09fe8acbbc9b8b5dae4 (diff) | |
V8
Diffstat (limited to 'ui/control_panel.py')
| -rw-r--r-- | ui/control_panel.py | 559 |
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) |
