From ca4e43b0d9c86cff01172473f118c62603fe05b3 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 4 Nov 2024 12:45:04 +0800 Subject: [PATCH] PyThermostat: Create GUI to Thermostat - Add connection menu - Add basic GUI layout skeleton Co-authored-by: linuswck Co-authored-by: Egor Savkin --- flake.nix | 19 + .../pythermostat/gui/model/property.py | 126 ++++ .../pythermostat/gui/model/thermostat.py | 135 +++++ .../pythermostat/gui/resources/artiq.ico | Bin 0 -> 134526 bytes .../gui/view/connection_details_menu.py | 73 +++ .../pythermostat/gui/view/info_box.py | 14 + pythermostat/pythermostat/gui/view/tec_qt.ui | 572 ++++++++++++++++++ .../gui/view/waitingspinnerwidget.py | 212 +++++++ pythermostat/pythermostat/thermostat_qt.py | 169 ++++++ 9 files changed, 1320 insertions(+) create mode 100644 pythermostat/pythermostat/gui/model/property.py create mode 100644 pythermostat/pythermostat/gui/model/thermostat.py create mode 100644 pythermostat/pythermostat/gui/resources/artiq.ico create mode 100644 pythermostat/pythermostat/gui/view/connection_details_menu.py create mode 100644 pythermostat/pythermostat/gui/view/info_box.py create mode 100644 pythermostat/pythermostat/gui/view/tec_qt.ui create mode 100644 pythermostat/pythermostat/gui/view/waitingspinnerwidget.py create mode 100755 pythermostat/pythermostat/thermostat_qt.py diff --git a/flake.nix b/flake.nix index c846d21..ad5bb8a 100644 --- a/flake.nix +++ b/flake.nix @@ -69,6 +69,21 @@ matplotlib ]; }; + + 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 = { @@ -94,6 +109,10 @@ ++ (with python3Packages; [ numpy matplotlib + pyqtgraph + pyqt6 + qasync + pglive ]); }; 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 0000000000000000000000000000000000000000..edb222d44f14e25f253f7b3ae8ee1d0c169edc38 GIT binary patch literal 134526 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 literal 0 HcmV?d00001 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/tec_qt.ui b/pythermostat/pythermostat/gui/view/tec_qt.ui new file mode 100644 index 0000000..3d61e3d --- /dev/null +++ b/pythermostat/pythermostat/gui/view/tec_qt.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/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..d6e5f4a --- /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("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 + ) + + @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()