diff options
Diffstat (limited to 'ui/control_editor.py')
| -rw-r--r-- | ui/control_editor.py | 319 |
1 files changed, 319 insertions, 0 deletions
diff --git a/ui/control_editor.py b/ui/control_editor.py new file mode 100644 index 0000000..93b8110 --- /dev/null +++ b/ui/control_editor.py @@ -0,0 +1,319 @@ +""" +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 = "" + # 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._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("Save 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) + + # 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) + # 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, + ) + # Save type-specific params + panel = self._param_panels.get(ctype) + if panel: + panel.save(spec) + + self.result_spec = spec + self.accept() |
