summaryrefslogtreecommitdiff
path: root/ui/control_editor.py
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-04-21 13:43:52 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-04-21 13:57:46 -0600
commit2a634dca0c7962b90004f75c4cdac6225201bb5e (patch)
tree3e1f0cd4ff165e77d17ebec5278c9bed885bb1da /ui/control_editor.py
parentb1a61fd29e2282110bc4f4bc4616c55ed9d88dbb (diff)
V13
This version contains the profile feature. Allowins users to save the channels, signals and plots for specific labs.
Diffstat (limited to 'ui/control_editor.py')
-rw-r--r--ui/control_editor.py319
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()