summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
Diffstat (limited to 'ui')
-rw-r--r--ui/__pycache__/__init__.cpython-312.pycbin0 -> 151 bytes
-rw-r--r--ui/__pycache__/__init__.cpython-314.pycbin156 -> 0 bytes
-rw-r--r--ui/__pycache__/add_device_dialog.cpython-314.pycbin23299 -> 0 bytes
-rw-r--r--ui/__pycache__/alarm_panel.cpython-314.pycbin6113 -> 0 bytes
-rw-r--r--ui/__pycache__/config_dialog.cpython-314.pycbin7036 -> 0 bytes
-rw-r--r--ui/__pycache__/control_editor.cpython-312.pycbin0 -> 20107 bytes
-rw-r--r--ui/__pycache__/control_panel.cpython-312.pycbin0 -> 39988 bytes
-rw-r--r--ui/__pycache__/control_panel.cpython-314.pycbin34961 -> 0 bytes
-rw-r--r--ui/__pycache__/device_panel.cpython-314.pycbin10002 -> 0 bytes
-rw-r--r--ui/__pycache__/main_window.cpython-312.pycbin0 -> 25316 bytes
-rw-r--r--ui/__pycache__/main_window.cpython-314.pycbin24564 -> 0 bytes
-rw-r--r--ui/__pycache__/profile_manager_ui.cpython-312.pycbin0 -> 8368 bytes
-rw-r--r--ui/__pycache__/readout_panel.cpython-314.pycbin9028 -> 0 bytes
-rw-r--r--ui/__pycache__/strip_chart.cpython-312.pycbin0 -> 14890 bytes
-rw-r--r--ui/__pycache__/strip_chart.cpython-314.pycbin16639 -> 0 bytes
-rw-r--r--ui/alarm_panel.py85
-rw-r--r--ui/control_editor.py319
-rw-r--r--ui/control_panel.py281
-rw-r--r--ui/main_window.py136
-rw-r--r--ui/profile_manager_ui.py152
-rw-r--r--ui/readout_panel.py122
-rw-r--r--ui/style_dark.qss29
-rw-r--r--ui/windows/__pycache__/__init__.cpython-312.pycbin0 -> 159 bytes
-rw-r--r--ui/windows/__pycache__/__init__.cpython-314.pycbin164 -> 0 bytes
-rw-r--r--ui/windows/__pycache__/devices_window.cpython-312.pycbin0 -> 22019 bytes
-rw-r--r--ui/windows/__pycache__/devices_window.cpython-314.pycbin23245 -> 0 bytes
-rw-r--r--ui/windows/__pycache__/plot_window.cpython-312.pycbin0 -> 44562 bytes
-rw-r--r--ui/windows/__pycache__/plot_window.cpython-314.pycbin46125 -> 0 bytes
-rw-r--r--ui/windows/__pycache__/settings_window.cpython-312.pycbin0 -> 15917 bytes
-rw-r--r--ui/windows/__pycache__/settings_window.cpython-314.pycbin16406 -> 0 bytes
-rw-r--r--ui/windows/__pycache__/signals_window.cpython-312.pycbin0 -> 51395 bytes
-rw-r--r--ui/windows/__pycache__/signals_window.cpython-314.pycbin53434 -> 0 bytes
32 files changed, 777 insertions, 347 deletions
diff --git a/ui/__pycache__/__init__.cpython-312.pyc b/ui/__pycache__/__init__.cpython-312.pyc
new file mode 100644
index 0000000..6ed2349
--- /dev/null
+++ b/ui/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/__init__.cpython-314.pyc b/ui/__pycache__/__init__.cpython-314.pyc
deleted file mode 100644
index 3af9695..0000000
--- a/ui/__pycache__/__init__.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/add_device_dialog.cpython-314.pyc b/ui/__pycache__/add_device_dialog.cpython-314.pyc
deleted file mode 100644
index e74884b..0000000
--- a/ui/__pycache__/add_device_dialog.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/alarm_panel.cpython-314.pyc b/ui/__pycache__/alarm_panel.cpython-314.pyc
deleted file mode 100644
index a1811f7..0000000
--- a/ui/__pycache__/alarm_panel.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/config_dialog.cpython-314.pyc b/ui/__pycache__/config_dialog.cpython-314.pyc
deleted file mode 100644
index 788f4a0..0000000
--- a/ui/__pycache__/config_dialog.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/control_editor.cpython-312.pyc b/ui/__pycache__/control_editor.cpython-312.pyc
new file mode 100644
index 0000000..f1716c6
--- /dev/null
+++ b/ui/__pycache__/control_editor.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/control_panel.cpython-312.pyc b/ui/__pycache__/control_panel.cpython-312.pyc
new file mode 100644
index 0000000..c54cd25
--- /dev/null
+++ b/ui/__pycache__/control_panel.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/control_panel.cpython-314.pyc b/ui/__pycache__/control_panel.cpython-314.pyc
deleted file mode 100644
index a534649..0000000
--- a/ui/__pycache__/control_panel.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/device_panel.cpython-314.pyc b/ui/__pycache__/device_panel.cpython-314.pyc
deleted file mode 100644
index 07eb750..0000000
--- a/ui/__pycache__/device_panel.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/main_window.cpython-312.pyc b/ui/__pycache__/main_window.cpython-312.pyc
new file mode 100644
index 0000000..b99d7c2
--- /dev/null
+++ b/ui/__pycache__/main_window.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/main_window.cpython-314.pyc b/ui/__pycache__/main_window.cpython-314.pyc
deleted file mode 100644
index d933966..0000000
--- a/ui/__pycache__/main_window.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/profile_manager_ui.cpython-312.pyc b/ui/__pycache__/profile_manager_ui.cpython-312.pyc
new file mode 100644
index 0000000..e66a754
--- /dev/null
+++ b/ui/__pycache__/profile_manager_ui.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/readout_panel.cpython-314.pyc b/ui/__pycache__/readout_panel.cpython-314.pyc
deleted file mode 100644
index 9258b10..0000000
--- a/ui/__pycache__/readout_panel.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/__pycache__/strip_chart.cpython-312.pyc b/ui/__pycache__/strip_chart.cpython-312.pyc
new file mode 100644
index 0000000..ba3ca5d
--- /dev/null
+++ b/ui/__pycache__/strip_chart.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/strip_chart.cpython-314.pyc b/ui/__pycache__/strip_chart.cpython-314.pyc
deleted file mode 100644
index af41096..0000000
--- a/ui/__pycache__/strip_chart.cpython-314.pyc
+++ /dev/null
Binary files differ
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
new file mode 100644
index 0000000..244f840
--- /dev/null
+++ b/ui/windows/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/__init__.cpython-314.pyc b/ui/windows/__pycache__/__init__.cpython-314.pyc
deleted file mode 100644
index 0f65045..0000000
--- a/ui/windows/__pycache__/__init__.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/windows/__pycache__/devices_window.cpython-312.pyc b/ui/windows/__pycache__/devices_window.cpython-312.pyc
new file mode 100644
index 0000000..f2ce20a
--- /dev/null
+++ b/ui/windows/__pycache__/devices_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/devices_window.cpython-314.pyc b/ui/windows/__pycache__/devices_window.cpython-314.pyc
deleted file mode 100644
index f13d7e0..0000000
--- a/ui/windows/__pycache__/devices_window.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/windows/__pycache__/plot_window.cpython-312.pyc b/ui/windows/__pycache__/plot_window.cpython-312.pyc
new file mode 100644
index 0000000..5c957ed
--- /dev/null
+++ b/ui/windows/__pycache__/plot_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/plot_window.cpython-314.pyc b/ui/windows/__pycache__/plot_window.cpython-314.pyc
deleted file mode 100644
index a76c9b5..0000000
--- a/ui/windows/__pycache__/plot_window.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/windows/__pycache__/settings_window.cpython-312.pyc b/ui/windows/__pycache__/settings_window.cpython-312.pyc
new file mode 100644
index 0000000..7da4e1a
--- /dev/null
+++ b/ui/windows/__pycache__/settings_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/settings_window.cpython-314.pyc b/ui/windows/__pycache__/settings_window.cpython-314.pyc
deleted file mode 100644
index 38f2969..0000000
--- a/ui/windows/__pycache__/settings_window.cpython-314.pyc
+++ /dev/null
Binary files differ
diff --git a/ui/windows/__pycache__/signals_window.cpython-312.pyc b/ui/windows/__pycache__/signals_window.cpython-312.pyc
new file mode 100644
index 0000000..6b423d8
--- /dev/null
+++ b/ui/windows/__pycache__/signals_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/signals_window.cpython-314.pyc b/ui/windows/__pycache__/signals_window.cpython-314.pyc
deleted file mode 100644
index 1ca83d8..0000000
--- a/ui/windows/__pycache__/signals_window.cpython-314.pyc
+++ /dev/null
Binary files differ