diff --git a/flake.nix b/flake.nix index 5cba347..a44aa0d 100644 --- a/flake.nix +++ b/flake.nix @@ -68,8 +68,27 @@ with pkgs.python3Packages; [ numpy matplotlib + pyqtgraph + pyqt6 + qasync + pglive ]; }; + + pglive = pkgs.python3Packages.buildPythonPackage rec { + pname = "pglive"; + version = "0.7.2"; + format = "pyproject"; + src = pkgs.fetchPypi { + inherit pname version; + hash = "sha256-jqj8X6H1N5mJQ4OrY5ANqRB0YJByqg/bNneEALWmH1A="; + }; + buildInputs = [ pkgs.python3Packages.poetry-core ]; + propagatedBuildInputs = with pkgs.python3Packages; [ + pyqtgraph + numpy + ]; + }; in { packages.x86_64-linux = { @@ -95,6 +114,10 @@ ++ (with python3Packages; [ numpy matplotlib + pyqtgraph + pyqt6 + qasync + pglive pythermostat ]); shellHook = '' diff --git a/pythermostat/pythermostat/gui/model/property.py b/pythermostat/pythermostat/gui/model/property.py new file mode 100644 index 0000000..badea1c --- /dev/null +++ b/pythermostat/pythermostat/gui/model/property.py @@ -0,0 +1,126 @@ +# A Custom Class that allows defining a QObject Property Dynamically +# Adapted from: https://stackoverflow.com/questions/48425316/how-to-create-pyqt-properties-dynamically + +from functools import wraps + +from PyQt6.QtCore import QObject, pyqtProperty, pyqtSignal + + +class PropertyMeta(type(QObject)): + """Lets a class succinctly define Qt properties.""" + + def __new__(cls, name, bases, attrs): + for key in list(attrs.keys()): + attr = attrs[key] + if not isinstance(attr, Property): + continue + + types = {list: "QVariantList", dict: "QVariantMap"} + type_ = types.get(attr.type_, attr.type_) + + notifier = pyqtSignal(type_) + attrs[f"{key}_update"] = notifier + attrs[key] = PropertyImpl(type_=type_, name=key, notify=notifier) + + return super().__new__(cls, name, bases, attrs) + + +class Property: + """Property definition. + + Instances of this class will be replaced with their full + implementation by the PropertyMeta metaclass. + """ + + def __init__(self, type_): + self.type_ = type_ + + +class PropertyImpl(pyqtProperty): + """Property implementation: gets, sets, and notifies of change.""" + + def __init__(self, type_, name, notify): + super().__init__(type_, self.getter, self.setter, notify=notify) + self.name = name + + def getter(self, instance): + return getattr(instance, f"_{self.name}") + + def setter(self, instance, value): + signal = getattr(instance, f"{self.name}_update") + + if type(value) in {list, dict}: + value = make_notified(value, signal) + + setattr(instance, f"_{self.name}", value) + signal.emit(value) + + +class MakeNotified: + """Adds notifying signals to lists and dictionaries. + + Creates the modified classes just once, on initialization. + """ + + change_methods = { + list: [ + "__delitem__", + "__iadd__", + "__imul__", + "__setitem__", + "append", + "extend", + "insert", + "pop", + "remove", + "reverse", + "sort", + ], + dict: [ + "__delitem__", + "__ior__", + "__setitem__", + "clear", + "pop", + "popitem", + "setdefault", + "update", + ], + } + + def __init__(self): + if not hasattr(dict, "__ior__"): + # Dictionaries don't have | operator in Python < 3.9. + self.change_methods[dict].remove("__ior__") + self.notified_class = { + type_: self.make_notified_class(type_) for type_ in [list, dict] + } + + def __call__(self, seq, signal): + """Returns a notifying version of the supplied list or dict.""" + notified_class = self.notified_class[type(seq)] + notified_seq = notified_class(seq) + notified_seq.signal = signal + return notified_seq + + @classmethod + def make_notified_class(cls, parent): + notified_class = type(f"notified_{parent.__name__}", (parent,), {}) + for method_name in cls.change_methods[parent]: + original = getattr(notified_class, method_name) + notified_method = cls.make_notified_method(original, parent) + setattr(notified_class, method_name, notified_method) + return notified_class + + @staticmethod + def make_notified_method(method, parent): + @wraps(method) + def notified_method(self, *args, **kwargs): + result = getattr(parent, method.__name__)(self, *args, **kwargs) + self.signal.emit(self) + return result + + return notified_method + + +make_notified = MakeNotified() diff --git a/pythermostat/pythermostat/gui/model/thermostat.py b/pythermostat/pythermostat/gui/model/thermostat.py new file mode 100644 index 0000000..c307546 --- /dev/null +++ b/pythermostat/pythermostat/gui/model/thermostat.py @@ -0,0 +1,135 @@ +import asyncio +import logging +from enum import Enum +from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot +from qasync import asyncSlot +from pythermostat.aioclient import AsyncioClient +from pythermostat.gui.model.property import Property, PropertyMeta + + +class ThermostatConnectionState(Enum): + DISCONNECTED = "disconnected" + CONNECTING = "connecting" + CONNECTED = "connected" + + +class Thermostat(QObject, metaclass=PropertyMeta): + connection_state = Property(ThermostatConnectionState) + hw_rev = Property(dict) + fan = Property(dict) + thermistor = Property(list) + pid = Property(list) + output = Property(list) + postfilter = Property(list) + report = Property(list) + + connection_error = pyqtSignal() + + NUM_CHANNELS = 2 + + def __init__(self, parent, update_s, disconnect_cb=None): + super().__init__(parent) + + self._update_s = update_s + self._client = AsyncioClient() + self._watch_task = None + self._update_params_task = None + self.disconnect_cb = disconnect_cb + self.connection_state = ThermostatConnectionState.DISCONNECTED + + async def start_session(self, host, port): + await self._client.connect(host, port) + self.hw_rev = await self._client.get_hwrev() + + @asyncSlot() + async def end_session(self): + self.stop_watching() + + if self.disconnect_cb is not None: + if asyncio.iscoroutinefunction(self.disconnect_cb): + await self.disconnect_cb() + else: + self.disconnect_cb() + + await self._client.disconnect() + + def start_watching(self): + self._watch_task = asyncio.create_task(self.run()) + + def stop_watching(self): + if self._watch_task is not None: + self._watch_task.cancel() + self._watch_task = None + self._update_params_task.cancel() + self._update_params_task = None + + async def run(self): + self._update_params_task = asyncio.create_task(self.update_params()) + while True: + if self._update_params_task.done(): + try: + self._update_params_task.result() + except OSError: + logging.error( + "Encountered an error while polling for information from Thermostat.", + exc_info=True, + ) + await self.end_session() + self.connection_state = ThermostatConnectionState.DISCONNECTED + self.connection_error.emit() + return + self._update_params_task = asyncio.create_task(self.update_params()) + await asyncio.sleep(self._update_s) + + async def update_params(self): + ( + self.fan, + self.output, + self.report, + self.pid, + self.thermistor, + self.postfilter, + ) = await asyncio.gather( + self._client.get_fan(), + self._client.get_output(), + self._client.get_report(), + self._client.get_pid(), + self._client.get_b_parameter(), + self._client.get_postfilter(), + ) + + def connected(self): + return self._client.connected() + + @pyqtSlot(float) + def set_update_s(self, update_s): + self._update_s = update_s + + async def set_ipv4(self, ipv4): + await self._client.set_param("ipv4", ipv4) + + async def get_ipv4(self): + return await self._client.get_ipv4() + + @asyncSlot() + async def save_cfg(self, ch=""): + await self._client.save_config(ch) + + @asyncSlot() + async def load_cfg(self, ch=""): + await self._client.load_config(ch) + + async def dfu(self): + await self._client.enter_dfu_mode() + + async def reset(self): + await self._client.reset() + + async def set_fan(self, power="auto"): + await self._client.set_fan(power) + + async def get_fan(self): + return await self._client.get_fan() + + async def set_param(self, topic, channel, field="", value=""): + await self._client.set_param(topic, channel, field, value) diff --git a/pythermostat/pythermostat/gui/resources/artiq.ico b/pythermostat/pythermostat/gui/resources/artiq.ico new file mode 100644 index 0000000..edb222d Binary files /dev/null and b/pythermostat/pythermostat/gui/resources/artiq.ico differ diff --git a/pythermostat/pythermostat/gui/view/MainWindow.ui b/pythermostat/pythermostat/gui/view/MainWindow.ui new file mode 100644 index 0000000..3d61e3d --- /dev/null +++ b/pythermostat/pythermostat/gui/view/MainWindow.ui @@ -0,0 +1,572 @@ + + + MainWindow + + + + 0 + 0 + 1280 + 720 + + + + + 1280 + 720 + + + + + 3840 + 2160 + + + + Thermostat Control Panel + + + + ../resources/artiq.ico../resources/artiq.ico + + + + + 1 + 1 + + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + 0 + + + + + false + + + + 1 + 1 + + + + QFrame::Shape::StyledPanel + + + QFrame::Shadow::Raised + + + + QLayout::SizeConstraint::SetDefaultConstraint + + + 3 + + + 3 + + + 3 + + + 3 + + + 2 + + + + + + + + + + + + + + + + + + 0 + 0 + + + + 0 + + + + + 0 + 0 + + + + Channel 0 + + + + + + + 0 + 0 + + + + + + + + + + 0 + 0 + + + + Channel 1 + + + + + + + 0 + 0 + + + + + + + + + + + + + + + + 0 + 0 + + + + + 0 + 40 + + + + + 16777215 + 40 + + + + QFrame::Shape::StyledPanel + + + QFrame::Shadow::Raised + + + + 3 + + + 3 + + + 3 + + + 3 + + + 3 + + + + + + + + 0 + 0 + + + + + 100 + 0 + + + + + 100 + 16777215 + + + + + 100 + 0 + + + + Connect + + + QToolButton::ToolButtonPopupMode::MenuButtonPopup + + + Qt::ToolButtonStyle::ToolButtonFollowStyle + + + + + + + + 0 + 0 + + + + + 240 + 0 + + + + + 120 + 16777215 + + + + + 120 + 50 + + + + Disconnected + + + + + + + false + + + + + + QToolButton::ToolButtonPopupMode::InstantPopup + + + + + + + Plot Settings + + + 📉 + + + QToolButton::ToolButtonPopupMode::InstantPopup + + + + + + + 1000000000 + + + + + + + Ready. + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + false + + + + 0 + 0 + + + + + 40 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 6 + + + QLayout::SizeConstraint::SetDefaultConstraint + + + 0 + + + + + Poll every: + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + + + + + + 0 + 0 + + + + + 70 + 0 + + + + + 70 + 16777215 + + + + + 70 + 0 + + + + s + + + 1 + + + 0.100000000000000 + + + 0.100000000000000 + + + QAbstractSpinBox::StepType::AdaptiveDecimalStepType + + + 1.000000000000000 + + + + + + + + 0 + 0 + + + + + 80 + 0 + + + + + 80 + 16777215 + + + + + 80 + 0 + + + + Apply + + + + + + + + + + + + + + + + + + + + Reset + + + Reset the Thermostat + + + QAction::MenuRole::NoRole + + + + + Enter DFU Mode + + + Reset thermostat and enter USB device firmware update (DFU) mode + + + QAction::MenuRole::NoRole + + + + + Network Settings + + + Configure IPv4 address, netmask length, and optional default gateway + + + QAction::MenuRole::NoRole + + + + + About Thermostat + + + Show Thermostat hardware revision, and settings related to i + + + QAction::MenuRole::NoRole + + + + + Load all channel configs from flash + + + Restore configuration for all channels from flash + + + QAction::MenuRole::NoRole + + + + + Save all channel configs to flash + + + Save configuration for all channels to flash + + + QAction::MenuRole::NoRole + + + + + + ParameterTree + QWidget +
pyqtgraph.parametertree
+ 1 +
+ + LivePlotWidget + QWidget +
pglive.sources.live_plot_widget
+ 1 +
+ + QtWaitingSpinner + QWidget +
pythermostat.gui.view.waitingspinnerwidget
+ 1 +
+
+ + +
diff --git a/pythermostat/pythermostat/gui/view/connection_details_menu.py b/pythermostat/pythermostat/gui/view/connection_details_menu.py new file mode 100644 index 0000000..7bef471 --- /dev/null +++ b/pythermostat/pythermostat/gui/view/connection_details_menu.py @@ -0,0 +1,73 @@ +from PyQt6 import QtWidgets, QtCore +from PyQt6.QtCore import pyqtSlot +from pythermostat.gui.model.thermostat import ThermostatConnectionState + + +class ConnectionDetailsMenu(QtWidgets.QMenu): + def __init__(self, thermostat, connect_btn): + super().__init__() + self._thermostat = thermostat + self._connect_btn = connect_btn + self._thermostat.connection_state_update.connect( + self.thermostat_state_change_handler + ) + + self.setTitle("Connection Settings") + + self.host_set_line = QtWidgets.QLineEdit() + self.host_set_line.setMinimumSize(QtCore.QSize(160, 0)) + self.host_set_line.setMaximumSize(QtCore.QSize(160, 16777215)) + self.host_set_line.setMaxLength(15) + self.host_set_line.setClearButtonEnabled(True) + + def connect_on_enter_press(): + self._connect_btn.click() + self.hide() + + self.host_set_line.returnPressed.connect(connect_on_enter_press) + + self.host_set_line.setText("192.168.1.26") + self.host_set_line.setPlaceholderText("IP for the Thermostat") + + host = QtWidgets.QWidgetAction(self) + host.setDefaultWidget(self.host_set_line) + self.addAction(host) + self.host = host + + self.port_set_spin = QtWidgets.QSpinBox() + self.port_set_spin.setMinimumSize(QtCore.QSize(70, 0)) + self.port_set_spin.setMaximumSize(QtCore.QSize(70, 16777215)) + self.port_set_spin.setMaximum(65535) + self.port_set_spin.setValue(23) + + def connect_only_if_enter_pressed(): + if ( + not self.port_set_spin.hasFocus() + ): # Don't connect if the spinbox only lost focus + return + connect_on_enter_press() + + self.port_set_spin.editingFinished.connect(connect_only_if_enter_pressed) + + port = QtWidgets.QWidgetAction(self) + port.setDefaultWidget(self.port_set_spin) + self.addAction(port) + self.port = port + + self.exit_button = QtWidgets.QPushButton() + self.exit_button.setText("Exit GUI") + self.exit_button.pressed.connect(QtWidgets.QApplication.instance().quit) + + exit_action = QtWidgets.QWidgetAction(self.exit_button) + exit_action.setDefaultWidget(self.exit_button) + self.addAction(exit_action) + self.exit_action = exit_action + + @pyqtSlot(ThermostatConnectionState) + def thermostat_state_change_handler(self, state): + self.host_set_line.setEnabled( + state == ThermostatConnectionState.DISCONNECTED + ) + self.port_set_spin.setEnabled( + state == ThermostatConnectionState.DISCONNECTED + ) diff --git a/pythermostat/pythermostat/gui/view/info_box.py b/pythermostat/pythermostat/gui/view/info_box.py new file mode 100644 index 0000000..3d6b7bf --- /dev/null +++ b/pythermostat/pythermostat/gui/view/info_box.py @@ -0,0 +1,14 @@ +from PyQt6 import QtWidgets +from PyQt6.QtCore import pyqtSlot + + +class InfoBox(QtWidgets.QMessageBox): + def __init__(self): + super().__init__() + self.setIcon(QtWidgets.QMessageBox.Icon.Information) + + @pyqtSlot(str, str) + def display_info_box(self, title, text): + self.setWindowTitle(title) + self.setText(text) + self.show() diff --git a/pythermostat/pythermostat/gui/view/waitingspinnerwidget.py b/pythermostat/pythermostat/gui/view/waitingspinnerwidget.py new file mode 100644 index 0000000..e37161a --- /dev/null +++ b/pythermostat/pythermostat/gui/view/waitingspinnerwidget.py @@ -0,0 +1,212 @@ +""" +The MIT License (MIT) + +Copyright (c) 2012-2014 Alexander Turkin +Copyright (c) 2014 William Hallatt +Copyright (c) 2015 Jacob Dawid +Copyright (c) 2016 Luca Weiss + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import math + +from PyQt6.QtCore import * +from PyQt6.QtGui import * +from PyQt6.QtWidgets import * + + +class QtWaitingSpinner(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + # WAS IN initialize() + self._color = QColor(Qt.GlobalColor.black) + self._roundness = 100.0 + self._minimumTrailOpacity = 3.14159265358979323846 + self._trailFadePercentage = 80.0 + self._revolutionsPerSecond = 1.57079632679489661923 + self._numberOfLines = 20 + self._lineLength = 5 + self._lineWidth = 2 + self._innerRadius = 5 + self._currentCounter = 0 + + self._timer = QTimer(self) + self._timer.timeout.connect(self.rotate) + self.updateSize() + self.updateTimer() + # END initialize() + + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + def paintEvent(self, QPaintEvent): + painter = QPainter(self) + painter.fillRect(self.rect(), Qt.GlobalColor.transparent) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + if self._currentCounter >= self._numberOfLines: + self._currentCounter = 0 + + painter.setPen(Qt.PenStyle.NoPen) + for i in range(0, self._numberOfLines): + painter.save() + painter.translate( + self._innerRadius + self._lineLength, + self._innerRadius + self._lineLength, + ) + rotateAngle = float(360 * i) / float(self._numberOfLines) + painter.rotate(rotateAngle) + painter.translate(self._innerRadius, 0) + distance = self.lineCountDistanceFromPrimary( + i, self._currentCounter, self._numberOfLines + ) + color = self.currentLineColor( + distance, + self._numberOfLines, + self._trailFadePercentage, + self._minimumTrailOpacity, + self._color, + ) + painter.setBrush(color) + painter.drawRoundedRect( + QRect(0, int(-self._lineWidth / 2), self._lineLength, self._lineWidth), + self._roundness, + self._roundness, + Qt.SizeMode.RelativeSize, + ) + painter.restore() + + def start(self): + if not self._timer.isActive(): + self._timer.start() + self._currentCounter = 0 + + def stop(self): + if self._timer.isActive(): + self._timer.stop() + self._currentCounter = 0 + + def setNumberOfLines(self, lines): + self._numberOfLines = lines + self._currentCounter = 0 + self.updateTimer() + + def setLineLength(self, length): + self._lineLength = length + self.updateSize() + + def setLineWidth(self, width): + self._lineWidth = width + self.updateSize() + + def setInnerRadius(self, radius): + self._innerRadius = radius + self.updateSize() + + def color(self): + return self._color + + def roundness(self): + return self._roundness + + def minimumTrailOpacity(self): + return self._minimumTrailOpacity + + def trailFadePercentage(self): + return self._trailFadePercentage + + def revolutionsPersSecond(self): + return self._revolutionsPerSecond + + def numberOfLines(self): + return self._numberOfLines + + def lineLength(self): + return self._lineLength + + def lineWidth(self): + return self._lineWidth + + def innerRadius(self): + return self._innerRadius + + def setRoundness(self, roundness): + self._roundness = max(0.0, min(100.0, roundness)) + + def setColor(self, color=Qt.GlobalColor.black): + self._color = QColor(color) + + def setRevolutionsPerSecond(self, revolutionsPerSecond): + self._revolutionsPerSecond = revolutionsPerSecond + self.updateTimer() + + def setTrailFadePercentage(self, trail): + self._trailFadePercentage = trail + + def setMinimumTrailOpacity(self, minimumTrailOpacity): + self._minimumTrailOpacity = minimumTrailOpacity + + def rotate(self): + self._currentCounter += 1 + if self._currentCounter >= self._numberOfLines: + self._currentCounter = 0 + self.update() + + def updateSize(self): + self.size = (self._innerRadius + self._lineLength) * 2 + self.setFixedSize(self.size, self.size) + + def updateTimer(self): + self._timer.setInterval( + int(1000 / (self._numberOfLines * self._revolutionsPerSecond)) + ) + + def lineCountDistanceFromPrimary(self, current, primary, totalNrOfLines): + distance = primary - current + if distance < 0: + distance += totalNrOfLines + return distance + + def currentLineColor( + self, countDistance, totalNrOfLines, trailFadePerc, minOpacity, colorinput + ): + color = QColor(colorinput) + if countDistance == 0: + return color + minAlphaF = minOpacity / 100.0 + distanceThreshold = int(math.ceil((totalNrOfLines - 1) * trailFadePerc / 100.0)) + if countDistance > distanceThreshold: + color.setAlphaF(minAlphaF) + else: + alphaDiff = color.alphaF() - minAlphaF + gradient = alphaDiff / float(distanceThreshold + 1) + resultAlpha = color.alphaF() - gradient * countDistance + # If alpha is out of bounds, clip it. + resultAlpha = min(1.0, max(0.0, resultAlpha)) + color.setAlphaF(resultAlpha) + return color + + +if __name__ == "__main__": + app = QApplication([]) + waiting_spinner = QtWaitingSpinner() + waiting_spinner.show() + waiting_spinner.start() + app.exec() diff --git a/pythermostat/pythermostat/thermostat_qt.py b/pythermostat/pythermostat/thermostat_qt.py new file mode 100755 index 0000000..8bb5058 --- /dev/null +++ b/pythermostat/pythermostat/thermostat_qt.py @@ -0,0 +1,169 @@ +"""GUI for the Sinara 8451 Thermostat""" + +import asyncio +import logging +import argparse +import importlib.resources +from PyQt6 import QtWidgets, QtGui, uic +from PyQt6.QtCore import pyqtSlot +import qasync +from qasync import asyncSlot, asyncClose +from pythermostat.gui.model.thermostat import Thermostat, ThermostatConnectionState +from pythermostat.gui.view.connection_details_menu import ConnectionDetailsMenu +from pythermostat.gui.view.info_box import InfoBox + + +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", + ) + + return parser + + +class MainWindow(QtWidgets.QMainWindow): + NUM_CHANNELS = 2 + + def __init__(self): + super().__init__() + + ui_file_path = importlib.resources.files("pythermostat.gui.view").joinpath("MainWindow.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 + ) + + @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) + + # Bottom bar menus + self.connection_details_menu = ConnectionDetailsMenu( + self._thermostat, self.connect_btn + ) + self.connect_btn.setMenu(self.connection_details_menu) + + 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") + + @asyncSlot() + async def on_connect_btn_clicked(self): + match self._thermostat.connection_state: + case ThermostatConnectionState.DISCONNECTED: + self._connecting_task = asyncio.current_task() + self._thermostat.connection_state = ThermostatConnectionState.CONNECTING + await self._thermostat.start_session( + host=self.connection_details_menu.host_set_line.text(), + port=self.connection_details_menu.port_set_spin.value(), + ) + 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("pythermostat.gui.resources").joinpath("artiq.ico")) + ) + ) + + main_window = MainWindow() + main_window.show() + + if args.connect: + if args.host: + main_window.connection_details_menu.host_set_line.setText(args.host) + if args.port: + main_window.connection_details_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()