cpdctrl-gui/cpdctrl_gui/ui/main_window.py
2025-03-10 11:47:29 +01:00

380 lines
16 KiB
Python

''' cpdctrl_gui/ui/main_window.py '''
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtWidgets import QMainWindow, QWidget, QHBoxLayout, QLabel, QStatusBar, QFileDialog, \
QVBoxLayout
from PyQt6.QtWidgets import QToolBox, QTabWidget
from PyQt6.QtGui import QIcon, QPixmap, QAction, QKeySequence
from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QMessageBox
from ..resources import get_resource_path
from .widgets.menubar import MenuBar
from .widgets.toolbar import ToolBar
from .widgets.metadata_input import MetadataInput
from .widgets.measurement_settings import MeasurementSettings, ScriptSelection
from .widgets.plot import Plot
from .widgets.device_select import ListChoice
from .widgets.about import MarkdownView
from .widgets.led_script import LedScriptViewer
# from .widgets.treeview import TreeView
import logging
log = logging.getLogger(__name__)
import multiprocessing as mp
import threading as mt
from ..utility.config import AppConfig
import cpdctrl.voltage_measurement_device as vmd
import cpdctrl.led_control_device as ledd
from cpdctrl.led_script import LedScript
from cpdctrl.utility.data import DataCollector
from cpdctrl.measurement import measure
class MainWindow(QMainWindow):
"""
The main window of the app.
Contains most logic as well as measurement functionality with live updates.
"""
def __init__(self) -> None:
"""
Initialize the Main-Window.
"""
super().__init__()
# Window-Settings
self.setWindowTitle(AppConfig.APP_NAME)
self.setGeometry(100, 100, 800, 600)
central_widget = QWidget(self)
self.setCentralWidget(central_widget)
self.create_toolbars()
self.setMenuBar(MenuBar(self)) # must come after toolbars
self.setStatusBar(QStatusBar(self))
layout = QHBoxLayout(central_widget)
central_widget.setLayout(layout)
# Left: Toolbox
self.w_leftbox = QToolBox(self)
self.w_leftbox.setMinimumWidth(300)
layout.addWidget(self.w_leftbox)
metadata_init_dict = AppConfig.MEAS_CFG.get_or("metadata", {})
# Measurement settings
self.w_measurement_settings = MeasurementSettings()
self.w_leftbox.addItem(self.w_measurement_settings, "Measurement settings")
self.w_measurement_settings.set_value("interval", AppConfig.MAIN_CFG.get_or("interval", 0.5))
# Measurement metadata
self.w_metadata = MetadataInput(metadata_init_dict)
self.w_leftbox.addItem(self.w_metadata, "Measurement metadata")
# Right: Tabs: Script, Plot
self.w_right_tab = QTabWidget()
layout.addWidget(self.w_right_tab)
self.w_plot = Plot()
self.w_right_tab.addTab(self.w_plot, "Plot")
# LED SCRIPT
self.w_led_script = LedScriptViewer(LedScript(0))
self.w_right_tab.addTab(self.w_led_script, "LED Script")
self.w_measurement_settings.w_led_script.script_changed.connect(self.led_script_load)
self.verbose = True
# devices
self.vmdev = None
self.leddev = None
self.vmdev_autoconnect()
self.leddev_autoconnect()
# Measurement
self.measurement_timer = None
self.led_script = None
self.data_collector = None
self.data_queue = None
self.proc_measure = None
self.set_status("Ready")
# add qt actions that open help and about dialog
self.a_open_help = QAction(text="Help", parent=self)
self.a_open_help.setIcon(QIcon.fromTheme(QIcon.ThemeIcon.HelpFaq))
self.a_open_help.setShortcut(QKeySequence("F1"))
self.a_open_help.triggered.connect(lambda: self.app_open_help())
self.a_open_about = QAction(text="About", parent=self)
self.a_open_about.setIcon(QIcon.fromTheme(QIcon.ThemeIcon.HelpAbout))
self.a_open_about.triggered.connect(lambda: self.app_open_about())
self.topbar.addAction(self.a_open_help)
self.topbar.addAction(self.a_open_about)
self.menuBar().m_file.addAction(self.a_open_help)
# self.a_open_help.setShortcut(QKeySequence("F1"))
def set_status(self, msg):
self.statusBar().showMessage(msg)
def create_toolbars(self) -> None:
"""
Creates and adds the top and right toolbars to the main window.
"""
# Top Toolbar [PyQt6.QtWidgets.QToolBar]
self.topbar = ToolBar(self, orientation=Qt.Orientation.Horizontal,
style=Qt.ToolButtonStyle.ToolButtonTextUnderIcon, icon_size=(24, 24))
# Top Toolbar Buttons
self.topbar.add_button("connect_vmdev", "CPD Devices", get_resource_path("icons/volt_device.svg"), self.vmdev_connect_from_dialog)
self.topbar.add_button("connect_leddev", "LED Devices", get_resource_path("icons/led_device.svg"), self.leddev_connect_from_dialog)
self.topbar.add_button("meas_start", "Start", QIcon.fromTheme(QIcon.ThemeIcon.MediaPlaybackStart), self.measure_start)
self.topbar.add_button("meas_stop", "Stop", QIcon.fromTheme(QIcon.ThemeIcon.MediaPlaybackStop), self.measure_stop)
self.topbar.add_button("meas_save", "Save", QIcon.fromTheme(QIcon.ThemeIcon.DocumentSaveAs), self.measurement_save)
self.topbar.add_button("app_about", "About", get_resource_path("icons/icon.svg"), self.app_open_about)
self.topbar.add_separator()
self.topbar.add_button("app_exit", "Exit", QIcon.fromTheme(QIcon.ThemeIcon.ApplicationExit), self.app_exit)
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.topbar)
# disable the Stop and Save buttons
self.topbar.disable_button("meas_stop")
self.topbar.disable_button("meas_save")
# Right Toolbar [PyQt6.QtWidgets.QToolBar]
# self.rightbar = ToolBar(self, orientation=Qt.Orientation.Vertical, style=Qt.ToolButtonStyle.ToolButtonIconOnly, icon_size=(24, 24))
# self.rightbar.add_separator()
# self.rightbar.add_button("Settings", "resources/assets/icons/windows/shell32-315.ico", self.settings_window)
# self.addToolBar(Qt.ToolBarArea.RightToolBarArea, self.rightbar)
# def create_treeview(self) -> TreeView:
"""
Creates and adds the tree view widget to the main window.
"""
# return TreeView(self)
# LED DEVICE MANAGEMENT
def leddev_connect(self, leddev_type, leddev_name):
log.info(f"Connecting to LED device {leddev_name} ({leddev_type})")
self.leddev = ledd.connect_device(leddev_type, leddev_name)
AppConfig.MAIN_CFG.set("led_device_last.type", leddev_type)
AppConfig.MAIN_CFG.set("led_device_last.name", leddev_name)
def leddev_autoconnect(self):
if AppConfig.MAIN_CFG.get_or("led_device_auto_reconnect", False):
try:
leddev_type = AppConfig.MAIN_CFG.get("led_device_last.type")
leddev_name = AppConfig.MAIN_CFG.get("led_device_last.name")
self.leddev_connect(leddev_type, leddev_name)
except KeyError:
pass
except Exception as e:
log.error(f"Failed to auto-connect to LED device: {e}")
def leddev_connect_from_dialog(self):
"""
Open a dialog that lets the user choose a LED control device,
and then connects to the device.
"""
devices = ledd.list_devices()
device_dialog = ListChoice(devices, window_title="Select LED device")
if device_dialog.exec() == QDialog.DialogCode.Accepted:
leddev_type, leddev_name = device_dialog.get_selected()
self.leddev_connect(leddev_type, leddev_name)
# VOLTAGE DEVICE MANAGEMENT
def vmdev_connect(self, vmdev_type, vmdev_name):
log.info(f"Connecting to voltage measurement device {vmdev_name} ({vmdev_type})")
self.vmdev = vmd.connect_device(vmdev_type, vmdev_name)
AppConfig.MAIN_CFG.set("voltage_measurement_device_last.type", vmdev_type)
AppConfig.MAIN_CFG.set("voltage_measurement_device_last.name", vmdev_name)
def vmdev_autoconnect(self):
if AppConfig.MAIN_CFG.get_or("voltage_measurement_device_auto_reconnect", False):
try:
vmdev_type = AppConfig.MAIN_CFG.get("voltage_measurement_device_last.type")
vmdev_name = AppConfig.MAIN_CFG.get("voltage_measurement_device_last.name")
self.vmdev_connect(vmdev_type, vmdev_name)
except KeyError:
pass
except Exception as e:
log.error(f"Failed to auto-connect to voltage measurement device: {e}")
def vmdev_connect_from_dialog(self):
"""
Open a dialog that lets the user choose a voltage measurement device,
and then connects to the device.
"""
devices = vmd.list_devices()
device_dialog = ListChoice(devices, window_title="Select CPD Measurement Device")
if device_dialog.exec() == QDialog.DialogCode.Accepted:
vmdev_type, vmdev_name = device_dialog.get_selected()
self.vmdev_connect(vmdev_type, vmdev_name)
# MEASUREMENT
def measure_start(self):
if self.vmdev is None:
self.vmdev_connect_from_dialog()
if self.vmdev is None:
raise ValueError("No measurement device selected")
if self.leddev is None:
self.leddev_connect_from_dialog()
if self.leddev is None:
raise ValueError("No led control device selected")
self.topbar.disable_button("meas_start")
self.topbar.disable_button("connect_vmdev")
self.topbar.disable_button("connect_leddev")
self.topbar.disable_button("meas_save")
self.topbar.enable_button("meas_stop")
self.w_plot.clear_data()
measurement_name = "guitest"
script = self.w_measurement_settings.get_value("led_script")
led_script = LedScript(script=script)
flush_after = self.w_measurement_settings.get_value("flush_after")
use_buffer = self.w_measurement_settings.get_value("use_buffer")
max_measurements = self.w_measurement_settings.get_value("max_measurements")
stop_on_script_end = self.w_measurement_settings.get_value("stop_on_script_end")
interval = self.w_measurement_settings.get_value("interval")
auto_add_metadata = self.w_measurement_settings.get_value("auto_add_metadata")
metadata = self.w_metadata.get_dict()
metadata["name"] = measurement_name
metadata["led_script"] = str(script)
self.w_metadata.update_from_dict({
"interval": str(interval),
"led_script": str(script)
})
log.info(f"Starting measurement with:\n\tinterval = {interval}\n\tflush_after = {flush_after}\n\tuse_buffer = {use_buffer}\n\tmax_measurements = {max_measurements}\n\tstop_on_script_end = {stop_on_script_end}")
self.led_script = LedScript(script=script, auto_update=True, verbose=True)
self.data_collector = DataCollector(metadata=metadata, data_path=AppConfig.MAIN_CFG.get("datadir"), data_name=measurement_name)
# data_collector.clear()
self.data_queue = mp.Queue()
self.command_queue = mp.Queue()
# Argument order must match the definition
self.proc_measure = mt.Thread(target=measure, args=(
self.vmdev,
self.leddev,
self.led_script,
self.data_collector,
interval,
flush_after,
use_buffer,
max_measurements,
stop_on_script_end,
False, # verbose
self.command_queue,
self.data_queue,
auto_add_metadata,
))
self.proc_measure.start()
self.measurement_timer = QTimer(self)
self.measurement_timer.timeout.connect(self.measure_update)
self.measurement_timer.start(300) # TODO: set interval
def measure_stop(self):
log.info("Stopping measurement")
if not self.measurement_is_running():
raise RuntimeError("measure_stop: Measurement is not running")
self.command_queue.put("stop")
self.measurement_timer.stop()
self.proc_measure.join()
self.set_status("Ready")
self.led_script.stop_updating() # stop watching for file updates (if enabled)
self.data_collector.save_csv(verbose=True)
data, metadata = self.data_collector.get_data()
self.proc_measure = None
self.led_script = None
self.topbar.enable_button("meas_start")
self.topbar.enable_button("connect_vmdev")
self.topbar.enable_button("connect_leddev")
self.topbar.enable_button("meas_save")
self.topbar.disable_button("meas_stop")
def measure_update(self):
if self.proc_measure.is_alive():
while not self.data_queue.empty():
# print(data_queue.qsize(), "\n\n")
current_data = self.data_queue.get(block=False)
i, tval, vval, led_val = current_data
# print(f"Data {i:03}: {tval}s, {vval}V, {led_val}%")
self.set_status(f"Data {i:03}: {tval:.3f}s, {vval:.10f}V, {led_val:03}%")
# update the plot
self.w_plot.update_plot(tval, vval, led_val)
else: # measurement might have stopped after max N or script end
self.measure_stop()
def measurement_is_running(self):
return self.proc_measure is not None
def measurement_save(self) -> None:
"""
Save the last measurement data to a file.
"""
if self.data_collector is None:
raise RuntimeError("Can not save, not data collector initialized")
csv = self.data_collector.to_csv()
# open file dialog
filename = self.data_collector.dirname + ".csv"
file_name, _ = QFileDialog.getSaveFileName(self, "Save File", filename, "CSV Files (*.csv)")
if file_name:
with open(file_name, "w") as f:
f.write(csv)
self.set_status(f"Saved data to {file_name}")
else:
self.set_status(f"Aborted saving data, no file selected")
def led_script_load(self):
script = self.w_measurement_settings.get_value("led_script")
try:
self.led_script = LedScript(script=script, auto_update=True, verbose=True)
except ValueError as e:
# show qt error
QMessageBox.critical(self, "LED script error", str(e))
return
self.w_led_script.set_script(self.led_script)
def app_exit(self) -> None:
"""
Closes the application.
"""
self.close()
def app_open_about(self) -> None:
dialog = QDialog()
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
buttons.accepted.connect(dialog.accept)
dialog.setLayout(QVBoxLayout())
# show the logo via a pixmap in a label
img_path = get_resource_path("icons/logo.svg")
pixmap = QPixmap(img_path)
pixmap = pixmap.scaled(128, 128, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
# qt cant find the file
label = QLabel()
label.setPixmap(pixmap)
label.setAlignment(Qt.AlignmentFlag.AlignCenter) # center the image
dialog.layout().addWidget(label)
# show about.md
dialog.layout().addWidget(MarkdownView("about.md"))
dialog.layout().addWidget(buttons)
dialog.exec()
def app_open_help(self) -> None:
dialog = QDialog()
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
buttons.accepted.connect(dialog.accept)
dialog.setLayout(QVBoxLayout())
# show help.md
dialog.layout().addWidget(MarkdownView("troubleshooting.md"))
dialog.layout().addWidget(buttons)
dialog.exec()
def closeEvent(self, event):
if self.measurement_is_running():
self.measure_stop()
# save the metadata
metadata = self.w_metadata.get_dict()
AppConfig.MEAS_CFG.set("metadata", metadata)
event.accept()