diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-04-21 13:43:52 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-04-21 13:57:46 -0600 |
| commit | 2a634dca0c7962b90004f75c4cdac6225201bb5e (patch) | |
| tree | 3e1f0cd4ff165e77d17ebec5278c9bed885bb1da /ui | |
| parent | b1a61fd29e2282110bc4f4bc4616c55ed9d88dbb (diff) | |
V13
This version contains the profile feature. Allowins users to save the
channels, signals and plots for specific labs.
Diffstat (limited to 'ui')
32 files changed, 777 insertions, 347 deletions
diff --git a/ui/__pycache__/__init__.cpython-312.pyc b/ui/__pycache__/__init__.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..6ed2349 --- /dev/null +++ b/ui/__pycache__/__init__.cpython-312.pyc diff --git a/ui/__pycache__/__init__.cpython-314.pyc b/ui/__pycache__/__init__.cpython-314.pyc Binary files differdeleted file mode 100644 index 3af9695..0000000 --- a/ui/__pycache__/__init__.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/add_device_dialog.cpython-314.pyc b/ui/__pycache__/add_device_dialog.cpython-314.pyc Binary files differdeleted file mode 100644 index e74884b..0000000 --- a/ui/__pycache__/add_device_dialog.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/alarm_panel.cpython-314.pyc b/ui/__pycache__/alarm_panel.cpython-314.pyc Binary files differdeleted file mode 100644 index a1811f7..0000000 --- a/ui/__pycache__/alarm_panel.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/config_dialog.cpython-314.pyc b/ui/__pycache__/config_dialog.cpython-314.pyc Binary files differdeleted file mode 100644 index 788f4a0..0000000 --- a/ui/__pycache__/config_dialog.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/control_editor.cpython-312.pyc b/ui/__pycache__/control_editor.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..f1716c6 --- /dev/null +++ b/ui/__pycache__/control_editor.cpython-312.pyc diff --git a/ui/__pycache__/control_panel.cpython-312.pyc b/ui/__pycache__/control_panel.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..c54cd25 --- /dev/null +++ b/ui/__pycache__/control_panel.cpython-312.pyc diff --git a/ui/__pycache__/control_panel.cpython-314.pyc b/ui/__pycache__/control_panel.cpython-314.pyc Binary files differdeleted file mode 100644 index a534649..0000000 --- a/ui/__pycache__/control_panel.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/device_panel.cpython-314.pyc b/ui/__pycache__/device_panel.cpython-314.pyc Binary files differdeleted file mode 100644 index 07eb750..0000000 --- a/ui/__pycache__/device_panel.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/main_window.cpython-312.pyc b/ui/__pycache__/main_window.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..b99d7c2 --- /dev/null +++ b/ui/__pycache__/main_window.cpython-312.pyc diff --git a/ui/__pycache__/main_window.cpython-314.pyc b/ui/__pycache__/main_window.cpython-314.pyc Binary files differdeleted file mode 100644 index d933966..0000000 --- a/ui/__pycache__/main_window.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/profile_manager_ui.cpython-312.pyc b/ui/__pycache__/profile_manager_ui.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..e66a754 --- /dev/null +++ b/ui/__pycache__/profile_manager_ui.cpython-312.pyc diff --git a/ui/__pycache__/readout_panel.cpython-314.pyc b/ui/__pycache__/readout_panel.cpython-314.pyc Binary files differdeleted file mode 100644 index 9258b10..0000000 --- a/ui/__pycache__/readout_panel.cpython-314.pyc +++ /dev/null diff --git a/ui/__pycache__/strip_chart.cpython-312.pyc b/ui/__pycache__/strip_chart.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..ba3ca5d --- /dev/null +++ b/ui/__pycache__/strip_chart.cpython-312.pyc diff --git a/ui/__pycache__/strip_chart.cpython-314.pyc b/ui/__pycache__/strip_chart.cpython-314.pyc Binary files differdeleted file mode 100644 index af41096..0000000 --- a/ui/__pycache__/strip_chart.cpython-314.pyc +++ /dev/null diff --git a/ui/alarm_panel.py b/ui/alarm_panel.py deleted file mode 100644 index 1270aff..0000000 --- a/ui/alarm_panel.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -ui/alarm_panel.py — Alarm event log. -""" - -from datetime import datetime -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QScrollArea, QFrame, QPushButton, -) -from PyQt6.QtCore import Qt - - -class AlarmRow(QFrame): - def __init__(self, dev_id: str, ch_id: str, value: float, kind: str): - super().__init__() - self.setObjectName("alarmEntry") - color = "#ef4444" if kind == "high" else "#f59e0b" - arrow = "▲" if kind == "high" else "▼" - self.setStyleSheet(f"QFrame#alarmEntry {{ border-left: 3px solid {color}; }}") - - ts_str = datetime.now().strftime("%H:%M:%S.%f")[:11] - layout = QHBoxLayout(self) - layout.setContentsMargins(8, 3, 8, 3) - - msg = QLabel(f"{arrow} {dev_id} / {ch_id} = {value:.4f} [{kind.upper()}]") - msg.setObjectName("alarmMsg") - ts = QLabel(ts_str) - ts.setObjectName("alarmTs") - - layout.addWidget(msg, 1) - layout.addWidget(ts) - - -class AlarmPanel(QWidget): - def __init__(self): - super().__init__() - self._count = 0 - self._build() - - def _build(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - hdr_w = QWidget() - hdr_w.setObjectName("alarmHeaderWidget") - hdr_lay = QHBoxLayout(hdr_w) - hdr_lay.setContentsMargins(0, 0, 4, 0) - hdr = QLabel(" ALARMS") - hdr.setObjectName("panelHeader") - hdr.setMinimumHeight(28) - hdr_lay.addWidget(hdr, 1) - clr = QPushButton("Clear") - clr.setObjectName("clearAlarmsBtn") - clr.clicked.connect(self.clear) - hdr_lay.addWidget(clr) - layout.addWidget(hdr_w) - - self._scroll = QScrollArea() - self._scroll.setWidgetResizable(True) - self._container = QWidget() - self._inner = QVBoxLayout(self._container) - self._inner.setContentsMargins(4, 4, 4, 4) - self._inner.setSpacing(2) - self._inner.addStretch() - self._scroll.setWidget(self._container) - layout.addWidget(self._scroll) - - def add_alarm(self, dev_id: str, ch_id: str, value: float, kind: str): - row = AlarmRow(dev_id, ch_id, value, kind) - self._inner.insertWidget(0, row) - self._count += 1 - # Cap at 300 entries - if self._count > 300: - item = self._inner.takeAt(self._inner.count() - 2) - if item and item.widget(): - item.widget().deleteLater() - self._count -= 1 - - def clear(self): - while self._inner.count() > 1: - item = self._inner.takeAt(0) - if item and item.widget(): - item.widget().deleteLater() - self._count = 0 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() 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 diff --git a/ui/main_window.py b/ui/main_window.py index e69ab1a..a82a389 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -1,21 +1,15 @@ """ ui/main_window.py -Toolbar sections (left→right): - [▶ RUN] [⬤ LOG] | [⚙ DEVICES] | [⚗ SIGNALS] | [📐 PLOT] | [⚙ SETTINGS] - spacer | timer - -Central: - Left: ControlPanel (output widgets only) - Center: StripChartWidget - -All secondary features live in floating windows. +Toolbar (left→right): + [▶ RUN] [⬤ LOG] | [⊞ Devices] | [⚗ Signals] | [📐 Plot] | [⚙ Settings] + spacer | timer | [📁 File] """ from PyQt6.QtWidgets import ( QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QSplitter, QStatusBar, QLabel, QPushButton, - QToolBar, QSizePolicy, QApplication, + QToolBar, QSizePolicy, QApplication, QFrame, ) from PyQt6.QtCore import Qt, QTimer, pyqtSlot import os @@ -26,13 +20,15 @@ from devices.serial_device import SerialDevice from devices.device_registry import DeviceRegistry from core.acquisition import AcquisitionEngine from core.signal_processor import SignalProcessor +from core.profile import Profile, ProfileManager -from ui.control_panel import ControlPanel -from ui.strip_chart import StripChartWidget -from ui.windows.devices_window import DevicesWindow -from ui.windows.signals_window import SignalsWindow -from ui.windows.plot_window import PlotWindow, build_default_layout -from ui.windows.settings_window import SettingsWindow +from ui.control_panel import ControlPanel +from ui.strip_chart import StripChartWidget +from ui.profile_manager_ui import ProfileButton +from ui.windows.devices_window import DevicesWindow +from ui.windows.signals_window import SignalsWindow +from ui.windows.plot_window import PlotWindow, build_default_layout +from ui.windows.settings_window import SettingsWindow _DARK_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_dark.qss") @@ -50,11 +46,11 @@ class MainWindow(QMainWindow): self.processor = SignalProcessor() self._settings = dict(SettingsWindow._defaults) - self._elapsed = 0 - self._win_devices = None - self._win_signals = None - self._win_plot = None - self._win_settings = None + self._elapsed = 0 + self._win_devices = None + self._win_signals = None + self._win_plot = None + self._win_settings = None self._init_demo_devices() self._build_ui() @@ -76,7 +72,6 @@ class MainWindow(QMainWindow): # ── UI ──────────────────────────────────────────────────────────────── def _build_ui(self): - # ── Toolbar ────────────────────────────────────────────────────── tb = QToolBar(); tb.setObjectName("mainToolbar"); tb.setMovable(False) self.addToolBar(tb) @@ -84,7 +79,6 @@ class MainWindow(QMainWindow): s = QFrame(); s.setFrameShape(QFrame.Shape.VLine) s.setObjectName("toolbarSep"); return s - # RUN / LOG self._run_btn = QPushButton("▶ RUN") self._run_btn.setObjectName("runButton"); self._run_btn.setCheckable(True) self._run_btn.clicked.connect(self._toggle_run); tb.addWidget(self._run_btn) @@ -96,46 +90,50 @@ class MainWindow(QMainWindow): tb.addWidget(_sep()) - # DEVICES dev_btn = QPushButton("⊞ Devices") - dev_btn.setObjectName("toolbarSectionBtn") - dev_btn.setCheckable(True) + dev_btn.setObjectName("toolbarSectionBtn"); dev_btn.setCheckable(True) dev_btn.clicked.connect(lambda c: self._toggle_win("devices", c, dev_btn)) tb.addWidget(dev_btn); self._btn_devices = dev_btn tb.addWidget(_sep()) - # SIGNALS sig_btn = QPushButton("⚗ Signals") - sig_btn.setObjectName("toolbarSectionBtn") - sig_btn.setCheckable(True) + sig_btn.setObjectName("toolbarSectionBtn"); sig_btn.setCheckable(True) sig_btn.clicked.connect(lambda c: self._toggle_win("signals", c, sig_btn)) tb.addWidget(sig_btn); self._btn_signals = sig_btn tb.addWidget(_sep()) - # PLOT plot_btn = QPushButton("📐 Plot") - plot_btn.setObjectName("toolbarSectionBtn") - plot_btn.setCheckable(True) + plot_btn.setObjectName("toolbarSectionBtn"); plot_btn.setCheckable(True) plot_btn.clicked.connect(lambda c: self._toggle_win("plot", c, plot_btn)) tb.addWidget(plot_btn); self._btn_plot = plot_btn tb.addWidget(_sep()) - # SETTINGS set_btn = QPushButton("⚙ Settings") - set_btn.setObjectName("toolbarSectionBtn") - set_btn.setCheckable(True) + set_btn.setObjectName("toolbarSectionBtn"); set_btn.setCheckable(True) set_btn.clicked.connect(lambda c: self._toggle_win("settings", c, set_btn)) tb.addWidget(set_btn); self._btn_settings = set_btn - spacer = QWidget(); spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + # Spacer + clock + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) tb.addWidget(spacer) self._time_lbl = QLabel("00:00:00"); self._time_lbl.setObjectName("timeLabel") tb.addWidget(self._time_lbl) + tb.addWidget(_sep()) + + # ── 📁 File button (profiles) ───────────────────────────────────── + self._file_btn = ProfileButton( + on_new=self._profile_new, + get_profile=self._profile_capture, + apply_profile=self._profile_apply, + ) + tb.addWidget(self._file_btn) + # ── Central ─────────────────────────────────────────────────────── central = QWidget(); self.setCentralWidget(central) root = QHBoxLayout(central); root.setContentsMargins(0,0,0,0); root.setSpacing(0) @@ -143,16 +141,16 @@ class MainWindow(QMainWindow): hsplit = QSplitter(Qt.Orientation.Horizontal); hsplit.setHandleWidth(3) self._ctrl = ControlPanel(self.registry) - self._ctrl.setMinimumWidth(200); self._ctrl.setMaximumWidth(320) + self._ctrl.setMinimumWidth(200); self._ctrl.setMaximumWidth(340) + self._ctrl._add_demo_widgets() hsplit.addWidget(self._ctrl) self._chart = StripChartWidget(self.engine, self.registry, self.processor) hsplit.addWidget(self._chart) - hsplit.setSizes([240, 1000]) + hsplit.setSizes([260, 1000]) root.addWidget(hsplit) - # ── Status bar ───────────────────────────────────────────────── sb = QStatusBar(); self.setStatusBar(sb) self._status = QLabel("Ready"); sb.addWidget(self._status) self._log_lbl = QLabel(""); sb.addPermanentWidget(self._log_lbl) @@ -229,11 +227,51 @@ class MainWindow(QMainWindow): if not win.isVisible(): if position == "right": win.move(geo.right() + 8, geo.top() + 40) - elif position == "below": + else: win.move(geo.left(), geo.bottom() + 8) win.show(); win.raise_(); win.activateWindow() - # ── Device/signal events ────────────────────────────────────────────── + # ── Profile callbacks ───────────────────────────────────────────────── + + def _profile_new(self): + """Reset to a blank slate.""" + self._ctrl.clear_widgets() + # Clear derived channels + for dc in self.processor.get_derived(): + self.processor.remove_derived(dc.channel_id) + self._chart.refresh() + self._status.setText("New profile — blank slate.") + + def _profile_capture(self, name: str = "Profile") -> Profile: + """Serialise current state into a Profile object.""" + return ProfileManager.capture( + registry=self.registry, + processor=self.processor, + plot_cfg=self._chart._cfg, + control_specs=self._ctrl.get_specs(), + settings=self._settings, + profile_name=name, + ) + + def _profile_apply(self, profile: Profile): + """Restore state from a Profile object.""" + plot_cfg = ProfileManager.apply( + profile=profile, + registry=self.registry, + processor=self.processor, + control_panel=self._ctrl, + settings_ref=self._settings, + ) + if plot_cfg: + self._chart.apply_layout(plot_cfg) + else: + self._chart.refresh() + # Refresh open windows + if self._win_plot: + self._win_plot.refresh_channels() + self._status.setText(f"Profile loaded: {profile.name}") + + # ── Device / signal events ──────────────────────────────────────────── def _on_device_added(self): self._chart.refresh() @@ -245,10 +283,8 @@ class MainWindow(QMainWindow): self._status.setText(f"Device '{dev_id}' removed.") def _on_device_reconfigured(self, dev_id: str): - """Called after Configure dialog closes — rebuild chart curves.""" self._chart.refresh() - if self._win_plot: - self._win_plot.refresh_channels() + if self._win_plot: self._win_plot.refresh_channels() self._status.setText(f"Device '{dev_id}' reconfigured.") def _on_derived_changed(self): @@ -271,22 +307,20 @@ class MainWindow(QMainWindow): def _toggle_log(self, c: bool): if c: - os.makedirs(self._settings.get("log_dir","logs"), exist_ok=True) p = self.engine.start_logging( - os.path.join(self._settings.get("log_dir","logs"), "")) + os.path.join(self._settings.get("log_dir", "logs"), "")) self._log_btn.setText("⏹ LOGGING") self._status.setText(f"Logging → {p}") else: self.engine.stop_logging(); self._log_btn.setText("⬤ LOG") - # ── Settings ────────────────────────────────────────────────────────── + # ── Theme / settings ────────────────────────────────────────────────── def _apply_theme(self, theme: str): qss_file = _DARK_QSS if theme == "dark" else _LIGHT_QSS if os.path.exists(qss_file): with open(qss_file) as f: QApplication.instance().setStyleSheet(f.read()) - # Update pyqtgraph plot colours independently of QSS self._chart.set_theme(theme) def _on_settings(self, cfg: dict): @@ -299,9 +333,7 @@ class MainWindow(QMainWindow): self._time_lbl.setText(f"{h:02d}:{m:02d}:{s:02d}") def closeEvent(self, event): - for w in (self._win_devices,self._win_signals,self._win_plot,self._win_settings): + for w in (self._win_devices, self._win_signals, + self._win_plot, self._win_settings): if w: w.close() self.engine.stop(); event.accept() - - -from PyQt6.QtWidgets import QFrame diff --git a/ui/profile_manager_ui.py b/ui/profile_manager_ui.py new file mode 100644 index 0000000..8966f8f --- /dev/null +++ b/ui/profile_manager_ui.py @@ -0,0 +1,152 @@ +""" +ui/profile_manager_ui.py + +Profile menu button (📁) in the top-right toolbar. +Handles New / Load / Save / Save As via file dialogs. +Thin UI layer — all serialisation is in core/profile.py. +""" + +import os +from PyQt6.QtWidgets import ( + QMenu, QPushButton, QFileDialog, QInputDialog, + QMessageBox, QApplication, +) +from PyQt6.QtCore import QPoint +from PyQt6.QtGui import QAction + +from core.profile import Profile, ProfileManager + + +PROFILE_EXT = ".labdaq" +PROFILE_FILTER = f"LabDAQ Profile (*{PROFILE_EXT});;All files (*)" + + +class ProfileButton(QPushButton): + """ + A '📁 File' button that shows New / Load / Save / Save As. + Placed in the toolbar top-right. + + Caller provides callbacks: + on_new() — reset to blank state + get_profile() — return a Profile representing current state + apply_profile(p) — restore state from a Profile + """ + + def __init__(self, on_new, get_profile, apply_profile, parent=None): + super().__init__("📁 File", parent) + self.setObjectName("toolbarSectionBtn") + self._on_new = on_new + self._get_profile = get_profile + self._apply_profile = apply_profile + self._current_path: str = "" + self.clicked.connect(self._show_menu) + + # ── Menu ────────────────────────────────────────────────────────────────── + + def _show_menu(self): + menu = QMenu(self) + menu.setObjectName("profileMenu") + + a_new = menu.addAction("📄 New Profile") + menu.addSeparator() + a_open = menu.addAction("📂 Load Profile…") + menu.addSeparator() + a_save = menu.addAction("💾 Save") + a_save_as = menu.addAction("💾 Save As…") + + a_save.setEnabled(bool(self._current_path)) + + a_new.triggered.connect(self._do_new) + a_open.triggered.connect(self._do_load) + a_save.triggered.connect(self._do_save) + a_save_as.triggered.connect(self._do_save_as) + + # Show below the button + pos = self.mapToGlobal(QPoint(0, self.height())) + menu.exec(pos) + + # ── Actions ─────────────────────────────────────────────────────────────── + + def _do_new(self): + reply = QMessageBox.question( + self, "New Profile", + "Discard current configuration and start a blank profile?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + self._current_path = "" + self._on_new() + self._update_title() + + def _do_load(self): + path, _ = QFileDialog.getOpenFileName( + self, "Load Profile", self._default_dir(), + PROFILE_FILTER, + ) + if not path: + return + try: + profile = Profile.load(path) + self._apply_profile(profile) + self._current_path = path + self._update_title(profile.name) + except Exception as e: + QMessageBox.critical(self, "Load Failed", str(e)) + + def _do_save(self): + if not self._current_path: + self._do_save_as() + return + self._save_to(self._current_path) + + def _do_save_as(self): + # Ask for profile name + name, ok = QInputDialog.getText( + self, "Profile Name", "Profile name:", + text=os.path.splitext(os.path.basename(self._current_path))[0] + if self._current_path else "My Profile", + ) + if not ok or not name.strip(): + return + name = name.strip() + + path, _ = QFileDialog.getSaveFileName( + self, "Save Profile As", + os.path.join(self._default_dir(), name + PROFILE_EXT), + PROFILE_FILTER, + ) + if not path: + return + if not path.endswith(PROFILE_EXT): + path += PROFILE_EXT + self._save_to(path, name) + + def _save_to(self, path: str, name: str = ""): + try: + profile = self._get_profile(name or os.path.splitext( + os.path.basename(path))[0]) + profile.save(path) + self._current_path = path + self._update_title(profile.name) + except Exception as e: + QMessageBox.critical(self, "Save Failed", str(e)) + + # ── Helpers ─────────────────────────────────────────────────────────────── + + def _default_dir(self) -> str: + d = os.path.join(os.path.expanduser("~"), "labdaq_profiles") + os.makedirs(d, exist_ok=True) + return d + + def _update_title(self, name: str = ""): + app = QApplication.instance() + if app and hasattr(app, "topLevelWidgets"): + for w in app.topLevelWidgets(): + if hasattr(w, "setWindowTitle") and "LabDAQ" in (w.windowTitle() or ""): + title = "LabDAQ" + if name: + title += f" — {name}" + if self._current_path: + title += f" [{os.path.basename(self._current_path)}]" + w.setWindowTitle(title) + break diff --git a/ui/readout_panel.py b/ui/readout_panel.py deleted file mode 100644 index ef04e18..0000000 --- a/ui/readout_panel.py +++ /dev/null @@ -1,122 +0,0 @@ -""" -ui/readout_panel.py — Right-side numeric readout panel. -""" - -from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, - QScrollArea, QFrame, QProgressBar, -) -from PyQt6.QtCore import Qt, pyqtSlot -from devices.device_registry import DeviceRegistry - - -class ChannelReadout(QFrame): - def __init__(self, ch_config): - super().__init__() - self.ch = ch_config - self.setObjectName("channelReadout") - self._alarm = False - self._build() - - def _build(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(8, 6, 8, 6) - layout.setSpacing(2) - - top = QHBoxLayout() - self._name = QLabel(self.ch.name) - self._name.setObjectName("readoutName") - top.addWidget(self._name, 1) - - self._val = QLabel("— — —") - self._val.setObjectName("readoutValue") - self._val.setStyleSheet(f"color:{self.ch.color};") - top.addWidget(self._val) - - unit = QLabel(f" {self.ch.unit}") - unit.setObjectName("readoutUnit") - top.addWidget(unit) - layout.addLayout(top) - - self._bar = QProgressBar() - self._bar.setObjectName("readoutBar") - self._bar.setRange(0, 1000) - self._bar.setValue(500) - self._bar.setTextVisible(False) - self._bar.setMaximumHeight(3) - self._bar.setStyleSheet( - f"QProgressBar::chunk {{ background:{self.ch.color}; border-radius:1px; }}" - ) - layout.addWidget(self._bar) - - def update_value(self, v: float): - self._val.setText(f"{v:>10.4f}".strip()) - rng = self.ch.max_value - self.ch.min_value - norm = int(((v - self.ch.min_value) / rng) * 1000) if rng else 500 - self._bar.setValue(max(0, min(1000, norm))) - - alarm = ( - (self.ch.alarm_low is not None and v < self.ch.alarm_low) or - (self.ch.alarm_high is not None and v > self.ch.alarm_high) - ) - if alarm != self._alarm: - self._alarm = alarm - self.setProperty("alarm", alarm) - self.style().unpolish(self) - self.style().polish(self) - - -class ReadoutPanel(QWidget): - def __init__(self, registry: DeviceRegistry): - super().__init__() - self.registry = registry - self._readouts = {} # (dev_id, ch_id) -> ChannelReadout - self._build() - self.refresh() - - def _build(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - hdr = QLabel(" CHANNELS") - hdr.setObjectName("panelHeader") - hdr.setMinimumHeight(28) - layout.addWidget(hdr) - - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - - self._container = QWidget() - self._inner = QVBoxLayout(self._container) - self._inner.setContentsMargins(6, 6, 6, 6) - self._inner.setSpacing(4) - self._inner.addStretch() - - scroll.setWidget(self._container) - layout.addWidget(scroll) - - def refresh(self): - for w in self._readouts.values(): - w.deleteLater() - self._readouts.clear() - # Clear layout - while self._inner.count() > 1: - item = self._inner.takeAt(0) - if item and item.widget(): - item.widget().deleteLater() - - for dev in self.registry.all_instances(): - for ch in dev.info.channels: - if not ch.enabled: - continue - ro = ChannelReadout(ch) - self._inner.insertWidget(self._inner.count() - 1, ro) - self._readouts[(dev.info.device_id, ch.channel_id)] = ro - - @pyqtSlot(str, str, float, float) - def on_new_data(self, dev_id: str, ch_id: str, _ts: float, value: float): - ro = self._readouts.get((dev_id, ch_id)) - if ro: - ro.update_value(value) diff --git a/ui/style_dark.qss b/ui/style_dark.qss index 4043ff6..df48330 100644 --- a/ui/style_dark.qss +++ b/ui/style_dark.qss @@ -464,3 +464,32 @@ QLabel#traceSource { font-family: "IBM Plex Mono", monospace; font-size: 12px; } + +/* ── Profile / File menu ─────────────────────────────────────────── */ +QMenu#profileMenu { + background-color: #111620; + border: 1px solid #2a3558; + border-radius: 4px; + padding: 4px 0; + color: #e2e8f0; + font-size: 13px; +} +QMenu#profileMenu::item { + padding: 7px 20px; + color: #c7d2fe; +} +QMenu#profileMenu::item:selected { + background-color: #1e3a5f; + color: #e0f2fe; +} +QMenu#profileMenu::separator { + height: 1px; + background: #2a3558; + margin: 3px 8px; +} + +/* ── Control widget wrapper (edit/remove row) ────────────────────── */ +QFrame#controlWidgetWrapper { + background: transparent; + border: none; +} diff --git a/ui/windows/__pycache__/__init__.cpython-312.pyc b/ui/windows/__pycache__/__init__.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..244f840 --- /dev/null +++ b/ui/windows/__pycache__/__init__.cpython-312.pyc diff --git a/ui/windows/__pycache__/__init__.cpython-314.pyc b/ui/windows/__pycache__/__init__.cpython-314.pyc Binary files differdeleted file mode 100644 index 0f65045..0000000 --- a/ui/windows/__pycache__/__init__.cpython-314.pyc +++ /dev/null diff --git a/ui/windows/__pycache__/devices_window.cpython-312.pyc b/ui/windows/__pycache__/devices_window.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..f2ce20a --- /dev/null +++ b/ui/windows/__pycache__/devices_window.cpython-312.pyc diff --git a/ui/windows/__pycache__/devices_window.cpython-314.pyc b/ui/windows/__pycache__/devices_window.cpython-314.pyc Binary files differdeleted file mode 100644 index f13d7e0..0000000 --- a/ui/windows/__pycache__/devices_window.cpython-314.pyc +++ /dev/null diff --git a/ui/windows/__pycache__/plot_window.cpython-312.pyc b/ui/windows/__pycache__/plot_window.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..5c957ed --- /dev/null +++ b/ui/windows/__pycache__/plot_window.cpython-312.pyc diff --git a/ui/windows/__pycache__/plot_window.cpython-314.pyc b/ui/windows/__pycache__/plot_window.cpython-314.pyc Binary files differdeleted file mode 100644 index a76c9b5..0000000 --- a/ui/windows/__pycache__/plot_window.cpython-314.pyc +++ /dev/null diff --git a/ui/windows/__pycache__/settings_window.cpython-312.pyc b/ui/windows/__pycache__/settings_window.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..7da4e1a --- /dev/null +++ b/ui/windows/__pycache__/settings_window.cpython-312.pyc diff --git a/ui/windows/__pycache__/settings_window.cpython-314.pyc b/ui/windows/__pycache__/settings_window.cpython-314.pyc Binary files differdeleted file mode 100644 index 38f2969..0000000 --- a/ui/windows/__pycache__/settings_window.cpython-314.pyc +++ /dev/null diff --git a/ui/windows/__pycache__/signals_window.cpython-312.pyc b/ui/windows/__pycache__/signals_window.cpython-312.pyc Binary files differnew file mode 100644 index 0000000..6b423d8 --- /dev/null +++ b/ui/windows/__pycache__/signals_window.cpython-312.pyc diff --git a/ui/windows/__pycache__/signals_window.cpython-314.pyc b/ui/windows/__pycache__/signals_window.cpython-314.pyc Binary files differdeleted file mode 100644 index 1ca83d8..0000000 --- a/ui/windows/__pycache__/signals_window.cpython-314.pyc +++ /dev/null |
