diff options
Diffstat (limited to 'ui/control_panel.py')
| -rw-r--r-- | ui/control_panel.py | 281 |
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 |
