""" ui/control_editor.py Dialog for adding or editing a control widget. Lets the operator choose: • Control type (On/Off, Motor, PWM, Setpoint, Analog Out) • Title & icon • Target device + channel • Type-specific parameters (max RPM, unit, min/max, step...) """ from __future__ import annotations from dataclasses import dataclass, field, asdict from typing import Any, Dict, Optional from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QLineEdit, QComboBox, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QWidget, QStackedWidget, QFrame, QCheckBox, ) from PyQt6.QtCore import Qt from devices.device_registry import DeviceRegistry # ── Data model ──────────────────────────────────────────────────────────────── CONTROL_TYPES = [ "On/Off Switch", "Motor Control", "PWM Output", "Setpoint", "Analog Output", ] CONTROL_ICONS = { "On/Off Switch": "⏻", "Motor Control": "⟳", "PWM Output": "⊓", "Setpoint": "◎", "Analog Output": "↗", } @dataclass class ControlSpec: """Serialisable description of one control widget.""" control_type: str = "On/Off Switch" title: str = "Control" icon: str = "⏻" device_id: str = "" channel_id: str = "" active_low: bool = False # Type-specific params unit: str = "" min_val: float = 0.0 max_val: float = 100.0 step: float = 1.0 max_rpm: int = 3000 def to_dict(self) -> dict: return asdict(self) @staticmethod def from_dict(d: dict) -> "ControlSpec": valid = {k: v for k, v in d.items() if k in ControlSpec.__dataclass_fields__} return ControlSpec(**valid) # ── Per-type parameter panels ───────────────────────────────────────────────── class OnOffParams(QWidget): def __init__(self): super().__init__() # no extra params needed def load(self, spec: ControlSpec): pass def save(self, spec: ControlSpec): pass class MotorParams(QWidget): def __init__(self): super().__init__() lay = QFormLayout(self); lay.setContentsMargins(0, 4, 0, 4) self._rpm = QSpinBox(); self._rpm.setRange(1, 100000); self._rpm.setValue(3000) self._rpm.setSuffix(" RPM") lay.addRow("Max RPM:", self._rpm) def load(self, spec: ControlSpec): self._rpm.setValue(spec.max_rpm) def save(self, spec: ControlSpec): spec.max_rpm = self._rpm.value() class PwmParams(QWidget): def __init__(self): super().__init__() # duty handled by slider, no extra params def load(self, spec: ControlSpec): pass def save(self, spec: ControlSpec): pass class SetpointParams(QWidget): def __init__(self): super().__init__() lay = QFormLayout(self); lay.setContentsMargins(0, 4, 0, 4) self._unit = QLineEdit(); self._unit.setPlaceholderText("e.g. °C, bar, rpm") self._min = QDoubleSpinBox(); self._min.setRange(-1e9, 1e9); self._min.setValue(0.0) self._max = QDoubleSpinBox(); self._max.setRange(-1e9, 1e9); self._max.setValue(100.0) self._step = QDoubleSpinBox(); self._step.setRange(0.001, 1e6); self._step.setValue(1.0) lay.addRow("Unit:", self._unit) lay.addRow("Min:", self._min) lay.addRow("Max:", self._max) lay.addRow("Step:", self._step) def load(self, spec: ControlSpec): self._unit.setText(spec.unit) self._min.setValue(spec.min_val) self._max.setValue(spec.max_val) self._step.setValue(spec.step) def save(self, spec: ControlSpec): spec.unit = self._unit.text().strip() spec.min_val = self._min.value() spec.max_val = self._max.value() spec.step = self._step.value() class AnalogOutParams(QWidget): def __init__(self): super().__init__() lay = QFormLayout(self); lay.setContentsMargins(0, 4, 0, 4) self._unit = QLineEdit("V"); self._unit.setPlaceholderText("V, mA, …") self._min = QDoubleSpinBox(); self._min.setRange(-1e9, 1e9); self._min.setValue(0.0) self._max = QDoubleSpinBox(); self._max.setRange(-1e9, 1e9); self._max.setValue(10.0) lay.addRow("Unit:", self._unit) lay.addRow("Min:", self._min) lay.addRow("Max:", self._max) def load(self, spec: ControlSpec): self._unit.setText(spec.unit) self._min.setValue(spec.min_val) self._max.setValue(spec.max_val) def save(self, spec: ControlSpec): spec.unit = self._unit.text().strip() spec.min_val = self._min.value() spec.max_val = self._max.value() _PARAM_PANELS = { "On/Off Switch": OnOffParams, "Motor Control": MotorParams, "PWM Output": PwmParams, "Setpoint": SetpointParams, "Analog Output": AnalogOutParams, } # ── Editor dialog ───────────────────────────────────────────────────────────── class ControlEditorDialog(QDialog): """Add or edit a control widget.""" def __init__(self, registry: DeviceRegistry, spec: Optional[ControlSpec] = None, parent=None): super().__init__(parent) self.registry = registry self.spec = spec or ControlSpec() self.result_spec: Optional[ControlSpec] = None self.setWindowTitle("Edit Control" if spec else "Add Control") self.setMinimumSize(440, 460) self.resize(460, 500) self._build() self._load_spec() def _build(self): root = QVBoxLayout(self); root.setSpacing(10) # ── Identity ──────────────────────────────────────────────────── id_grp = QGroupBox("Identity") id_form = QFormLayout(id_grp); id_form.setContentsMargins(10, 16, 10, 10) self._type_cb = QComboBox() self._type_cb.addItems(CONTROL_TYPES) self._type_cb.currentTextChanged.connect(self._on_type_changed) id_form.addRow("Control Type:", self._type_cb) self._title_edit = QLineEdit() self._title_edit.setPlaceholderText("e.g. Heater, Pump, Mixer") id_form.addRow("Label:", self._title_edit) root.addWidget(id_grp) # ── Device & channel ──────────────────────────────────────────── hw_grp = QGroupBox("Hardware Assignment") hw_form = QFormLayout(hw_grp); hw_form.setContentsMargins(10, 16, 10, 10) self._dev_cb = QComboBox() self._dev_cb.addItem("— none —", userData="") for dev in self.registry.all_instances(): label = f"{dev.info.device_id} ({dev.info.name})" self._dev_cb.addItem(label, userData=dev.info.device_id) self._dev_cb.currentIndexChanged.connect(self._on_dev_changed) hw_form.addRow("Device:", self._dev_cb) self._ch_cb = QComboBox() self._ch_cb.setEditable(True) # allow typing custom pin like "D7" self._ch_cb.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) hw_form.addRow("Channel / Pin:", self._ch_cb) self._active_low_chk = QCheckBox("Active-low output (ON sends LOW)") hw_form.addRow("Polarity:", self._active_low_chk) self._ch_hint = QLabel("") self._ch_hint.setObjectName("traceSource") self._ch_hint.setWordWrap(True) hw_form.addRow(self._ch_hint) root.addWidget(hw_grp) # ── Type-specific params ──────────────────────────────────────── self._params_grp = QGroupBox("Parameters") params_lay = QVBoxLayout(self._params_grp) params_lay.setContentsMargins(10, 16, 10, 10) self._stack = QStackedWidget() self._param_panels: Dict[str, QWidget] = {} for name, cls in _PARAM_PANELS.items(): panel = cls() self._param_panels[name] = panel self._stack.addWidget(panel) params_lay.addWidget(self._stack) root.addWidget(self._params_grp) # ── Buttons ──────────────────────────────────────────────────── div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) div.setObjectName("devWindowDivider"); root.addWidget(div) btn_row = QHBoxLayout(); btn_row.addStretch() cancel = QPushButton("Cancel"); cancel.clicked.connect(self.reject) ok = QPushButton("Create Control") ok.setObjectName("applyButton"); ok.setDefault(True) ok.clicked.connect(self._on_ok) btn_row.addWidget(cancel); btn_row.addWidget(ok) root.addLayout(btn_row) def _load_spec(self): """Populate fields from self.spec.""" # Type idx = CONTROL_TYPES.index(self.spec.control_type) \ if self.spec.control_type in CONTROL_TYPES else 0 self._type_cb.setCurrentIndex(idx) self._title_edit.setText(self.spec.title) # Device for i in range(self._dev_cb.count()): if self._dev_cb.itemData(i) == self.spec.device_id: self._dev_cb.setCurrentIndex(i); break # Channel (populated after device selection) self._on_dev_changed() self._ch_cb.setCurrentText(self.spec.channel_id) self._active_low_chk.setChecked(bool(self.spec.active_low)) # Params for name, panel in self._param_panels.items(): panel.load(self.spec) def _on_type_changed(self, type_name: str): idx = list(_PARAM_PANELS.keys()).index(type_name) self._stack.setCurrentIndex(idx) self._active_low_chk.setEnabled(type_name == "On/Off Switch") # Auto-set title if still default if not self._title_edit.text() or \ self._title_edit.text() in CONTROL_TYPES: self._title_edit.setText(type_name) def _on_dev_changed(self): self._ch_cb.clear() dev_id = self._dev_cb.currentData() if not dev_id: self._ch_hint.setText("No device selected — type a channel manually") return dev = self.registry.get_instance(dev_id) if not dev: return # Add actual channels for ch in dev.info.channels: self._ch_cb.addItem(f"{ch.channel_id} ({ch.name})", userData=ch.channel_id) # For Arduino backends also suggest digital pins for output if hasattr(dev, "backend") and dev.backend == "arduino": self._ch_cb.insertSeparator(self._ch_cb.count()) for pin in ["D5", "D6", "D7", "D8", "D9", "D10", "D11", "D13"]: self._ch_cb.addItem(f"{pin} (digital out)", userData=pin) self._ch_hint.setText( "Analog pins (A0…) for reading.\n" "Digital pins (D5…) for on/off output.\n" "D9, D10, D11 support PWM." ) else: self._ch_hint.setText("") def _on_ok(self): ctype = self._type_cb.currentText() title = self._title_edit.text().strip() or ctype # Resolve channel_id — prefer userData if it's a real combo item ch_text = self._ch_cb.currentText().strip() ch_data = self._ch_cb.currentData() channel_id = ch_data if ch_data else ch_text.split()[0] # strip "(name)" part spec = ControlSpec( control_type=ctype, title=title, icon=CONTROL_ICONS.get(ctype, "⚙"), device_id=self._dev_cb.currentData() or "", channel_id=channel_id, active_low=self._active_low_chk.isChecked(), ) # Save type-specific params panel = self._param_panels.get(ctype) if panel: panel.save(spec) self.result_spec = spec self.accept()