""" 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, QSizePolicy, QTextEdit, QScrollArea, ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont, QFontMetrics class _DynStack(QStackedWidget): """QStackedWidget that sizes to current page only.""" def sizeHint(self): w = self.currentWidget() return w.sizeHint() if w else super().sizeHint() def minimumSizeHint(self): w = self.currentWidget() return w.minimumSizeHint() if w else super().minimumSizeHint() 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 # Optional Python snippet executed on every value change on_action_script: str = "" 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, 520) self.resize(460, 580) 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") self._params_grp.setSizePolicy( QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) params_lay = QVBoxLayout(self._params_grp) params_lay.setContentsMargins(10, 16, 10, 10) self._stack = _DynStack() self._stack.setSizePolicy( QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) 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) # ── On-action script ─────────────────────────────────────────── script_grp = QGroupBox("On Action Script (optional)") script_lay = QVBoxLayout(script_grp); script_lay.setContentsMargins(10, 12, 10, 10) _help = QLabel( "Runs every time this control changes value.\n" "Available: value, channel_id, device_id, vars (shared dict), " "channels (latest values by channel_id), math" ) _help.setObjectName("traceSource"); _help.setWordWrap(True) script_lay.addWidget(_help) _mono = QFont("IBM Plex Mono, Consolas, Monospace") _mono.setStyleHint(QFont.StyleHint.Monospace) self._script_edit = QTextEdit() self._script_edit.setFont(_mono) self._script_edit.setTabStopDistance(QFontMetrics(_mono).horizontalAdvance(" ") * 4) self._script_edit.setMinimumHeight(80); self._script_edit.setMaximumHeight(160) self._script_edit.setPlaceholderText( "# Example: zero a position offset when button is pressed\n" "# vars['zero_pos'] = channels.get('encoder_0', 0)" ) script_lay.addWidget(self._script_edit) self._script_status = QLabel("") self._script_status.setObjectName("traceSource") check_btn = QPushButton("Check Syntax") check_btn.clicked.connect(self._check_script) btn_row = QHBoxLayout() btn_row.addWidget(self._script_status, 1) btn_row.addWidget(check_btn) script_lay.addLayout(btn_row) root.addWidget(script_grp) root.addStretch() # ── 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 _check_script(self): src = self._script_edit.toPlainText().strip() if not src: self._script_status.setText(""); return try: compile(src, "