469 lines
20 KiB
Python
469 lines
20 KiB
Python
''' cpdctrl_gui/ui/main_window.py '''
|
|
import os.path
|
|
|
|
from PyQt6.QtCore import Qt, QTimer
|
|
from PyQt6.QtWidgets import QMainWindow, QWidget, QHBoxLayout, QLabel, QStatusBar, QFileDialog, \
|
|
QVBoxLayout
|
|
from PyQt6.QtWidgets import 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 cpdctrl_gui.ui.widgets.settings import MeasurementSettings, AppSettings
|
|
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 time
|
|
|
|
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)
|
|
x = AppConfig.MAIN_CFG.get_or("geometry_x", 100)
|
|
y = AppConfig.MAIN_CFG.get_or("geometry_y", 100)
|
|
width = AppConfig.MAIN_CFG.get_or("geometry_width", 1000)
|
|
height = AppConfig.MAIN_CFG.get_or("geometry_height", 700)
|
|
self.setGeometry(x, y, width, height)
|
|
|
|
central_widget = QWidget(self)
|
|
self.setCentralWidget(central_widget)
|
|
|
|
# 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(get_resource_path("icons/icon.svg")))
|
|
self.a_open_about.triggered.connect(lambda: self.app_open_about())
|
|
|
|
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_lefttab = QTabWidget(self)
|
|
self.w_lefttab.setMinimumWidth(300)
|
|
layout.addWidget(self.w_lefttab)
|
|
|
|
metadata_init_dict = AppConfig.MEAS_CFG.get_or("metadata", {})
|
|
# Measurement settings
|
|
self.w_measurement_settings = MeasurementSettings()
|
|
self.w_lefttab.addTab(self.w_measurement_settings, "Measurement settings")
|
|
# Measurement metadata
|
|
self.w_metadata = MetadataInput(metadata_init_dict)
|
|
self.w_lefttab.addTab(self.w_metadata, "Measurement metadata")
|
|
# TODO: the call_f solution isnt pretty
|
|
# TODO: only accept single file?
|
|
class RightTab(QTabWidget):
|
|
def __init__(self, call_f, parent=None):
|
|
super().__init__(parent)
|
|
self.setAcceptDrops(True)
|
|
self.call_f = call_f
|
|
def dragEnterEvent(self, event):
|
|
if event.mimeData().hasUrls():
|
|
event.accept()
|
|
else:
|
|
event.ignore()
|
|
def dropEvent(self, event):
|
|
for url in event.mimeData().urls():
|
|
file_path = url.path()[1:]
|
|
if file_path.endswith("csv"):
|
|
self.call_f(file_path)
|
|
return
|
|
# Right: Tabs: Script, Plot
|
|
self.w_right_tab = RightTab(self.measurement_load)
|
|
layout.addWidget(self.w_right_tab)
|
|
max_data_points = AppConfig.MAIN_CFG.get_or("plot_max_data_points", 40000)
|
|
self.w_plot = Plot(max_data_points=max_data_points)
|
|
self.w_right_tab.addTab(self.w_plot, "Plot")
|
|
|
|
# LED SCRIPT
|
|
self.w_led_script = LedScriptViewer(LedScript(0))
|
|
self.w_lefttab.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")
|
|
|
|
self.menuBar().m_file.addAction(self.a_open_about)
|
|
self.menuBar().m_file.addAction(self.a_open_help)
|
|
|
|
|
|
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("meas_load", "Load", QIcon.fromTheme(QIcon.ThemeIcon.DocumentOpen), self.measurement_load_dialog)
|
|
self.topbar.add_separator()
|
|
self.topbar.add_button("app_settings", "Settings", QIcon.fromTheme(QIcon.ThemeIcon.DocumentProperties), self.app_open_settings)
|
|
self.topbar.addAction(self.a_open_help)
|
|
self.topbar.addAction(self.a_open_about)
|
|
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)
|
|
# Update the settings widget value
|
|
self.w_measurement_settings.set_value("device_led_controller", str(self.leddev))
|
|
led_name = self.leddev.get_led_name()
|
|
if not led_name: led_name = "Unknown"
|
|
self.w_measurement_settings.set_value("device_led", led_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)
|
|
# Update the settings widget value
|
|
self.w_measurement_settings.set_value("device_voltage_measurement", str(self.vmdev))
|
|
|
|
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.disable_button("meas_load")
|
|
self.topbar.enable_button("meas_stop")
|
|
self.w_plot.clear_data()
|
|
|
|
name = self.w_measurement_settings.get_value("name")
|
|
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"] = name
|
|
metadata["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}")
|
|
|
|
# the time left estimation might be a little to short, since the actual measurement is started a few lines of
|
|
# code later
|
|
t_now = time.time()
|
|
self.w_led_script.w_time_left.set_start_end_time(t_now, t_now + led_script.script["dtsum"][-1])
|
|
|
|
self.led_script = LedScript(script=script, auto_update=True, verbose=True)
|
|
self.data_collector = DataCollector(metadata=metadata, data_path=AppConfig.MAIN_CFG.get("dir_cache"), data_name=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.enable_button("meas_load")
|
|
self.topbar.disable_button("meas_stop")
|
|
|
|
def measure_update(self):
|
|
import time
|
|
self.w_led_script.update_time(time.time())
|
|
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 measurement_load(self, filepath):
|
|
if self.measurement_is_running():
|
|
QMessageBox.critical(self, "Measurement running", "Can not load data while measurement is running")
|
|
return
|
|
|
|
|
|
if os.path.isfile(filepath):
|
|
data, mdata = DataCollector.load_data_from_csv(filepath)
|
|
else:
|
|
raise FileNotFoundError(f"No such file or directory {filepath} not found")
|
|
self.w_plot.set_data(data[:,1], data[:,2], data[:,3])
|
|
|
|
def measurement_load_dialog(self):
|
|
if self.measurement_is_running():
|
|
QMessageBox.critical(self, "Measurement running", "Can not load data while measurement is running")
|
|
return
|
|
|
|
last_dir = AppConfig.MAIN_CFG.get_or("tmp_last_measurement_load_dir", "")
|
|
file_path, _ = QFileDialog.getOpenFileName(self, "Load File", last_dir, "CSV Files (*.csv)")
|
|
if file_path:
|
|
dir_name = os.path.dirname(file_path)
|
|
AppConfig.MAIN_CFG.set("tmp_last_measurement_load_dir", dir_name)
|
|
self.measurement_load(file_path)
|
|
else:
|
|
self.set_status(f"Aborted loading data, no file selected")
|
|
self.measurement_load(file_path)
|
|
|
|
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()
|
|
dialog.setWindowTitle("About cpdctrl-gui")
|
|
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()
|
|
dialog.setWindowTitle("Help")
|
|
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(MarkdownView("user_guide.md"))
|
|
dialog.layout().addWidget(buttons)
|
|
# set larger window size
|
|
dialog.resize(800, 600)
|
|
dialog.exec()
|
|
|
|
def app_open_settings(self) -> None:
|
|
dialog = QDialog()
|
|
dialog.setWindowTitle("Settings")
|
|
layout = QVBoxLayout()
|
|
w_app_settings = AppSettings()
|
|
layout.addWidget(w_app_settings)
|
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
|
|
buttons.accepted.connect(dialog.accept)
|
|
layout.addWidget(buttons)
|
|
dialog.setLayout(layout)
|
|
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)
|
|
# store the geometry as text values in config
|
|
geo = self.geometry()
|
|
AppConfig.MAIN_CFG.set("geometry_x", geo.x())
|
|
AppConfig.MAIN_CFG.set("geometry_y", geo.y())
|
|
AppConfig.MAIN_CFG.set("geometry_width", geo.width())
|
|
AppConfig.MAIN_CFG.set("geometry_height", geo.height())
|
|
|