from pytec.gui.view.zero_limits_warning import ZeroLimitsWarningView from pytec.gui.view.thermostat_ctrl_menu import ThermostatCtrlMenu from pytec.gui.view.conn_menu import ConnMenu from pytec.gui.view.plot_options_menu import PlotOptionsMenu from pytec.gui.view.live_plot_view import LiveDataPlotter from pytec.gui.view.ctrl_panel import CtrlPanel from pytec.gui.view.info_box import InfoBox from pytec.gui.model.pid_autotuner import PIDAutoTuner from pytec.gui.model.thermostat import Thermostat, ThermostatConnectionState import json from autotune import PIDAutotuneState from qasync import asyncSlot, asyncClose import qasync import asyncio import logging import argparse from PyQt6 import QtWidgets, QtGui, uic from PyQt6.QtCore import pyqtSlot import importlib.resources def get_argparser(): parser = argparse.ArgumentParser(description="Thermostat Control Panel") parser.add_argument( "--connect", default=None, action="store_true", help="Automatically connect to the specified Thermostat in host:port format", ) parser.add_argument("HOST", metavar="host", default=None, nargs="?") parser.add_argument("PORT", metavar="port", default=None, nargs="?") parser.add_argument( "-l", "--log", dest="logLevel", choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], help="Set the logging level", ) parser.add_argument( "-p", "--param_tree", default=importlib.resources.files("pytec.gui.view").joinpath("param_tree.json"), help="Param Tree Description JSON File", ) return parser class MainWindow(QtWidgets.QMainWindow): NUM_CHANNELS = 2 def __init__(self, args): super().__init__() ui_file_path = importlib.resources.files("pytec.gui.view").joinpath("tec_qt.ui") uic.loadUi(ui_file_path, self) self.info_box = InfoBox() # Models self._thermostat = Thermostat(self, self.report_refresh_spin.value()) self._connecting_task = None self._thermostat.connection_state_update.connect( self._on_connection_state_changed ) self._autotuners = PIDAutoTuner(self, self._thermostat, 2) self._autotuners.autotune_state_changed.connect( self._on_pid_autotune_state_changed ) # Handlers for disconnections async def autotune_disconnect(): for ch in range(self.NUM_CHANNELS): if self._autotuners.get_state(ch) != PIDAutotuneState.STATE_OFF: await self._autotuners.stop_pid_from_running(ch) self._thermostat.disconnect_cb = autotune_disconnect @pyqtSlot() def handle_connection_error(): self.info_box.display_info_box( "Connection Error", "Thermostat connection lost. Is it unplugged?" ) self._thermostat.connection_error.connect(handle_connection_error) # Control Panel def get_ctrl_panel_config(args): with open(args.param_tree, "r", encoding="utf-8") as f: return json.load(f)["ctrl_panel"] self._ctrl_panel_view = CtrlPanel( self._thermostat, self._autotuners, self.info_box, [self.ch0_tree, self.ch1_tree], get_ctrl_panel_config(args), ) # Graphs self._channel_graphs = LiveDataPlotter( self._thermostat, [ [getattr(self, f"ch{ch}_t_graph"), getattr(self, f"ch{ch}_i_graph")] for ch in range(self.NUM_CHANNELS) ] ) # Bottom bar menus self.conn_menu = ConnMenu(self._thermostat, self.connect_btn) self.connect_btn.setMenu(self.conn_menu) self._thermostat_ctrl_menu = ThermostatCtrlMenu( self._thermostat, self.info_box, self.style() ) self.thermostat_settings.setMenu(self._thermostat_ctrl_menu) self._plot_options_menu = PlotOptionsMenu(self._channel_graphs) self.plot_settings.setMenu(self._plot_options_menu) # Status line self._zero_limits_warning = ZeroLimitsWarningView( self._thermostat, self.style(), self.limits_warning ) self.loading_spinner.hide() self.report_apply_btn.clicked.connect( lambda: self._thermostat.set_update_s(self.report_refresh_spin.value()) ) @asyncClose async def closeEvent(self, _event): try: await self._thermostat.end_session() self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED except: pass @pyqtSlot(ThermostatConnectionState) def _on_connection_state_changed(self, state): self.graph_group.setEnabled(state == ThermostatConnectionState.CONNECTED) self.thermostat_settings.setEnabled( state == ThermostatConnectionState.CONNECTED ) self.report_group.setEnabled(state == ThermostatConnectionState.CONNECTED) match state: case ThermostatConnectionState.CONNECTED: self.connect_btn.setText("Disconnect") self.status_lbl.setText( "Connected to Thermostat v" f"{self._thermostat.hw_rev['rev']['major']}." f"{self._thermostat.hw_rev['rev']['minor']}" ) case ThermostatConnectionState.CONNECTING: self.connect_btn.setText("Stop") self.status_lbl.setText("Connecting...") case ThermostatConnectionState.DISCONNECTED: self.connect_btn.setText("Connect") self.status_lbl.setText("Disconnected") @pyqtSlot(int, PIDAutotuneState) def _on_pid_autotune_state_changed(self, _ch, _state): ch_tuning = [] for ch in range(self.NUM_CHANNELS): if self._autotuners.get_state(ch) in { PIDAutotuneState.STATE_READY, PIDAutotuneState.STATE_RELAY_STEP_UP, PIDAutotuneState.STATE_RELAY_STEP_DOWN, }: ch_tuning.append(ch) if len(ch_tuning) == 0: self.background_task_lbl.setText("Ready.") self.loading_spinner.hide() self.loading_spinner.stop() else: self.background_task_lbl.setText( "Autotuning channel {ch}...".format(ch=ch_tuning) ) self.loading_spinner.start() self.loading_spinner.show() @asyncSlot() async def on_connect_btn_clicked(self): match self._thermostat.connection_state: case ThermostatConnectionState.DISCONNECTED: self._connecting_task = asyncio.create_task( self._thermostat.start_session( host=self.conn_menu.host_set_line.text(), port=self.conn_menu.port_set_spin.value(), ) ) self._thermostat.connection_state = ThermostatConnectionState.CONNECTING await self._connecting_task self._connecting_task = None self._thermostat.connection_state = ThermostatConnectionState.CONNECTED self._thermostat.start_watching() case ThermostatConnectionState.CONNECTING: self._connecting_task.cancel() self._connecting_task = None await self._thermostat.end_session() self._thermostat.connection_state = ( ThermostatConnectionState.DISCONNECTED ) case ThermostatConnectionState.CONNECTED: await self._thermostat.end_session() self._thermostat.connection_state = ( ThermostatConnectionState.DISCONNECTED ) async def coro_main(): args = get_argparser().parse_args() if args.logLevel: logging.basicConfig(level=getattr(logging, args.logLevel)) app_quit_event = asyncio.Event() app = QtWidgets.QApplication.instance() app.aboutToQuit.connect(app_quit_event.set) app.setWindowIcon( QtGui.QIcon( str(importlib.resources.files("pytec.gui.resources").joinpath("artiq.ico")) ) ) main_window = MainWindow(args) main_window.show() if args.connect: if args.HOST: main_window.conn_menu.host_set_line.setText(args.HOST) if args.PORT: main_window.conn_menu.port_set_spin.setValue(int(args.PORT)) main_window.connect_btn.click() await app_quit_event.wait() def main(): qasync.run(coro_main()) if __name__ == "__main__": main()