summaryrefslogtreecommitdiff
path: root/ui/control_panel.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_panel.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_panel.py')
-rw-r--r--ui/control_panel.py281
1 files changed, 193 insertions, 88 deletions
diff --git a/ui/control_panel.py b/ui/control_panel.py
index 98f0637..d0f8637 100644
--- a/ui/control_panel.py
+++ b/ui/control_panel.py
@@ -83,11 +83,25 @@ class ControlWidget(QFrame):
outer.addWidget(self._body)
def _write(self, value: float):
- """Write value to the linked device channel (if any)."""
+ """
+ Write value to the linked device channel.
+
+ Routing:
+ DigitalIODevice → write_channel() → ArduinoLayer.digital_write() → W:Dxx:val
+ AnalogInputDevice (arduino backend) → write_channel() → ArduinoLayer.digital_write()
+ Any device with no write support → logs a warning, emits signal only
+ """
+ written = False
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)
+ ok = dev.write_channel(self.channel_id, value)
+ if ok:
+ written = True
+ else:
+ print(f"[Control] write_channel({self.channel_id}, {value}) "
+ f"returned False on {self.device_id} — "
+ f"check device type and channel ID")
self.value_changed.emit(self.channel_id, value)
@@ -98,8 +112,8 @@ class ControlWidget(QFrame):
class OnOffSwitch(ControlWidget):
"""Large latching power switch with green/red indicator."""
- def __init__(self, title: str = "Power", **kw):
- super().__init__(title, icon="⏻", **kw)
+ def __init__(self, title: str = "Power", icon: str = "⏻", **kw):
+ super().__init__(title, icon=icon, **kw)
self._state = False
self._build_body()
@@ -151,8 +165,8 @@ class OnOffSwitch(ControlWidget):
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)
+ def __init__(self, title: str = "Motor", icon: str = "⟳", max_rpm: int = 3000, **kw):
+ super().__init__(title, icon=icon, **kw)
self.max_rpm = max_rpm
self._running = False
self._fwd = True
@@ -238,10 +252,10 @@ class MotorControl(ControlWidget):
class SetpointControl(ControlWidget):
"""Numeric setpoint with ± step buttons and live process-value readback."""
- def __init__(self, title: str = "Setpoint", unit: str = "",
+ def __init__(self, title: str = "Setpoint", icon: str = "◎", unit: str = "",
min_val: float = 0.0, max_val: float = 100.0,
step: float = 1.0, **kw):
- super().__init__(title, icon="◎", **kw)
+ super().__init__(title, icon=icon, **kw)
self.unit = unit
self.min_val = min_val
self.max_val = max_val
@@ -330,8 +344,8 @@ class SetpointControl(ControlWidget):
class PwmControl(ControlWidget):
"""PWM duty cycle slider + frequency setting."""
- def __init__(self, title: str = "PWM Output", **kw):
- super().__init__(title, icon="⊓", **kw)
+ def __init__(self, title: str = "PWM Output", icon: str = "⊓", **kw):
+ super().__init__(title, icon=icon, **kw)
self._build_body()
def _build_body(self):
@@ -399,9 +413,9 @@ class PwmControl(ControlWidget):
class AnalogOutputControl(ControlWidget):
"""Generic voltage/current analog output with spinbox + send button."""
- def __init__(self, title: str = "Analog Out", unit: str = "V",
+ def __init__(self, title: str = "Analog Out", icon: str = "↗", unit: str = "V",
min_val: float = 0.0, max_val: float = 10.0, **kw):
- super().__init__(title, icon="↗", **kw)
+ super().__init__(title, icon=icon, **kw)
self.unit = unit
self.min_val = min_val
self.max_val = max_val
@@ -451,24 +465,39 @@ class AnalogOutputControl(ControlWidget):
# ══════════════════════════════════════════════════════════════════════════════
class ControlPanel(QWidget):
- """Left panel — output control widgets only. No device management here."""
+ """
+ Left panel — output control widgets.
+ Header has Add button. Each widget has Edit and Remove buttons overlaid.
+ """
+
+ controls_changed = pyqtSignal() # emitted whenever widgets are added/edited/removed
def __init__(self, registry: DeviceRegistry):
super().__init__()
self.registry = registry
- self._widgets: list[ControlWidget] = []
+ self._widgets: list[ControlWidget] = []
+ self._specs: list = [] # parallel list of ControlSpec
self._build()
- self._add_demo_widgets()
+
+ # ── Layout ───────────────────────────────────────────────────────────────
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)
+ # Header with Add button
+ hdr_widget = QWidget(); hdr_widget.setObjectName("controlPanelHeader")
+ hdr_widget.setFixedHeight(30)
+ hdr_lay = QHBoxLayout(hdr_widget)
+ hdr_lay.setContentsMargins(8, 0, 6, 0)
+ hdr_lbl = QLabel("CONTROLS"); hdr_lbl.setObjectName("panelHeader")
+ hdr_lay.addWidget(hdr_lbl, 1)
+ add_btn = QPushButton("+"); add_btn.setObjectName("devicesSmallBtn")
+ add_btn.setFixedSize(24, 22); add_btn.setToolTip("Add control widget")
+ add_btn.clicked.connect(self._on_add)
+ hdr_lay.addWidget(add_btn)
+ layout.addWidget(hdr_widget)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
@@ -477,83 +506,159 @@ class ControlPanel(QWidget):
self._container = QWidget()
self._inner = QVBoxLayout(self._container)
- self._inner.setContentsMargins(8, 8, 8, 8)
- self._inner.setSpacing(10)
+ self._inner.setContentsMargins(6, 6, 6, 6)
+ self._inner.setSpacing(8)
self._inner.addStretch()
scroll.setWidget(self._container)
layout.addWidget(scroll)
- def add_widget(self, widget: ControlWidget):
- """Add a control widget to the panel."""
+ # ── Widget management ─────────────────────────────────────────────────────
+
+ def _make_wrapper(self, widget: ControlWidget, spec) -> QFrame:
+ """Wrap a ControlWidget with Edit / Remove buttons in the corner."""
+ wrapper = QFrame(); wrapper.setObjectName("controlWidgetWrapper")
+ wl = QVBoxLayout(wrapper); wl.setContentsMargins(0, 0, 0, 0); wl.setSpacing(0)
+ wl.addWidget(widget)
+
+ # Button row below each widget
+ btn_row = QHBoxLayout(); btn_row.setContentsMargins(2, 1, 2, 1)
+ btn_row.addStretch()
+
+ edit_btn = QPushButton("✎ Edit"); edit_btn.setObjectName("configButton")
+ edit_btn.setFixedHeight(20)
+ rm_btn = QPushButton("✕"); rm_btn.setObjectName("traceRemoveBtn")
+ rm_btn.setFixedSize(20, 20)
+
+ edit_btn.clicked.connect(lambda: self._on_edit(widget, spec, wrapper))
+ rm_btn.clicked.connect( lambda: self._on_remove(widget, spec, wrapper))
+
+ btn_row.addWidget(edit_btn)
+ btn_row.addWidget(rm_btn)
+ wl.addLayout(btn_row)
+ return wrapper
+
+ def _add_widget_from_spec(self, spec):
+ """Instantiate a ControlWidget from a ControlSpec and add to panel."""
+ from ui.control_editor import ControlSpec as CS
+ widget = _build_widget_from_spec(spec, self.registry)
+ if widget is None:
+ return
+ wrapper = self._make_wrapper(widget, spec)
self._widgets.append(widget)
- self._inner.insertWidget(self._inner.count() - 1, widget)
+ self._specs.append(spec)
+ self._inner.insertWidget(self._inner.count() - 1, wrapper)
+ self.controls_changed.emit()
+
+ def add_widget(self, widget: ControlWidget, spec=None):
+ """Legacy API — add a pre-built widget directly."""
+ from ui.control_editor import ControlSpec
+ if spec is None:
+ spec = ControlSpec(
+ title=widget.title, icon=widget.icon,
+ device_id=widget.device_id, channel_id=widget.channel_id,
+ )
+ wrapper = self._make_wrapper(widget, spec)
+ self._widgets.append(widget)
+ self._specs.append(spec)
+ self._inner.insertWidget(self._inner.count() - 1, wrapper)
def clear_widgets(self):
- """Remove all control widgets."""
- for w in self._widgets:
- self._inner.removeWidget(w)
- w.deleteLater()
+ while self._inner.count() > 1:
+ item = self._inner.takeAt(0)
+ if item and item.widget():
+ item.widget().deleteLater()
self._widgets.clear()
+ self._specs.clear()
+
+ def get_specs(self) -> list:
+ """Return list of ControlSpec for all current widgets (for profile save)."""
+ return list(self._specs)
+
+ def load_specs(self, specs: list):
+ """Load a list of ControlSpec objects (from profile restore)."""
+ self.clear_widgets()
+ for spec in specs:
+ self._add_widget_from_spec(spec)
+
+ # ── Slots ─────────────────────────────────────────────────────────────────
+
+ def _on_add(self):
+ from ui.control_editor import ControlEditorDialog
+ dlg = ControlEditorDialog(self.registry, parent=self)
+ if dlg.exec() and dlg.result_spec:
+ self._add_widget_from_spec(dlg.result_spec)
+
+ def _on_edit(self, widget: ControlWidget, spec, wrapper: QFrame):
+ from ui.control_editor import ControlEditorDialog
+ dlg = ControlEditorDialog(self.registry, spec=spec, parent=self)
+ if not (dlg.exec() and dlg.result_spec):
+ return
+ new_spec = dlg.result_spec
+ idx = self._specs.index(spec)
+
+ # Remove old wrapper
+ self._inner.removeWidget(wrapper); wrapper.deleteLater()
+ self._widgets.pop(idx); self._specs.pop(idx)
+
+ # Insert new one at same position
+ new_widget = _build_widget_from_spec(new_spec, self.registry)
+ if new_widget is None:
+ return
+ new_wrapper = self._make_wrapper(new_widget, new_spec)
+ self._widgets.insert(idx, new_widget)
+ self._specs.insert(idx, new_spec)
+ self._inner.insertWidget(idx, new_wrapper)
+ self.controls_changed.emit()
+
+ def _on_remove(self, widget: ControlWidget, spec, wrapper: QFrame):
+ idx = self._specs.index(spec)
+ self._inner.removeWidget(wrapper); wrapper.deleteLater()
+ self._widgets.pop(idx); self._specs.pop(idx)
+ self.controls_changed.emit()
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)
+ """Default demo configuration."""
+ from ui.control_editor import ControlSpec
+ demos = [
+ ControlSpec("On/Off Switch", "Pump Power", "⏻", "dio_0", "do0"),
+ ControlSpec("On/Off Switch", "Heater", "⏻", "ard_0", "D7"),
+ ControlSpec("Motor Control", "Drive Motor", "⟳", "ard_0", "A0", max_rpm=3000),
+ ControlSpec("Setpoint", "Temp Setpoint","◎", "", "", unit="°C", min_val=0, max_val=300, step=0.5),
+ ControlSpec("Setpoint", "Flow Rate", "◎", "", "", unit="mL/min",min_val=0, max_val=500, step=5.0),
+ ControlSpec("PWM Output", "PWM Ch 1", "⊓", "ard_0", "D9"),
+ ControlSpec("Analog Output", "Analog Out", "↗", "", "", unit="V", min_val=0, max_val=10),
+ ]
+ for spec in demos:
+ self._add_widget_from_spec(spec)
+
+
+# ── Factory — build a ControlWidget from a ControlSpec ─────────────────────────
+
+def _build_widget_from_spec(spec, registry: DeviceRegistry) -> Optional[ControlWidget]:
+ """Instantiate the right ControlWidget subclass from a ControlSpec."""
+ # Base kwargs — icon is NOT included here; it's passed explicitly below
+ # so subclasses never get a double-value collision.
+ base_kw = dict(
+ device_id=spec.device_id,
+ channel_id=spec.channel_id,
+ registry=registry,
+ )
+ t = spec.control_type
+ ttl = spec.title
+ ico = spec.icon
+ if t == "On/Off Switch":
+ return OnOffSwitch(title=ttl, icon=ico, **base_kw)
+ elif t == "Motor Control":
+ return MotorControl(title=ttl, icon=ico, max_rpm=spec.max_rpm, **base_kw)
+ elif t == "PWM Output":
+ return PwmControl(title=ttl, icon=ico, **base_kw)
+ elif t == "Setpoint":
+ return SetpointControl(title=ttl, icon=ico, unit=spec.unit,
+ min_val=spec.min_val, max_val=spec.max_val,
+ step=spec.step, **base_kw)
+ elif t == "Analog Output":
+ return AnalogOutputControl(title=ttl, icon=ico, unit=spec.unit,
+ min_val=spec.min_val, max_val=spec.max_val,
+ **base_kw)
+ return None