""" 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)