From 2b32ca4851a3f9a1e716b5523e7877b5db54dbf6 Mon Sep 17 00:00:00 2001 From: atse Date: Mon, 4 Nov 2024 13:23:26 +0800 Subject: [PATCH] PyThermostat GUI: Thermostat/Plot settings menus Co-authored-by: linuswck Co-authored-by: Egor Savkin --- .../gui/view/connection_details_menu.py | 73 ----- pythermostat/pythermostat/gui/view/menus.py | 310 ++++++++++++++++++ .../gui/view/net_settings_input_diag.py | 36 ++ pythermostat/pythermostat/thermostat_qt.py | 10 +- 4 files changed, 355 insertions(+), 74 deletions(-) delete mode 100644 pythermostat/pythermostat/gui/view/connection_details_menu.py create mode 100644 pythermostat/pythermostat/gui/view/menus.py create mode 100644 pythermostat/pythermostat/gui/view/net_settings_input_diag.py diff --git a/pythermostat/pythermostat/gui/view/connection_details_menu.py b/pythermostat/pythermostat/gui/view/connection_details_menu.py deleted file mode 100644 index 7bef471..0000000 --- a/pythermostat/pythermostat/gui/view/connection_details_menu.py +++ /dev/null @@ -1,73 +0,0 @@ -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/menus.py b/pythermostat/pythermostat/gui/view/menus.py new file mode 100644 index 0000000..8476e03 --- /dev/null +++ b/pythermostat/pythermostat/gui/view/menus.py @@ -0,0 +1,310 @@ +import logging +from PyQt6 import QtWidgets, QtCore, QtGui +from PyQt6.QtCore import pyqtSignal, pyqtSlot, QSignalBlocker +from qasync import asyncSlot +from pythermostat.gui.model.thermostat import ThermostatConnectionState +from pythermostat.gui.view.net_settings_input_diag import NetSettingsInputDiag +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 + ) + + +class PlotOptionsMenu(QtWidgets.QMenu): + def __init__(self, channel_graphs, max_samples=1000): + super().__init__() + self.channel_graphs = channel_graphs + + self.setTitle("Plot Settings") + + clear = QtGui.QAction("Clear graphs", self) + self.addAction(clear) + self.clear = clear + self.clear.triggered.connect(self.channel_graphs.clear_graphs) + + self.samples_spinbox = QtWidgets.QSpinBox() + self.samples_spinbox.setRange(2, 100000) + self.samples_spinbox.setSuffix(" samples") + self.samples_spinbox.setValue(max_samples) + self.samples_spinbox.valueChanged.connect(self.channel_graphs.set_max_samples) + + limit_samples = QtWidgets.QWidgetAction(self) + limit_samples.setDefaultWidget(self.samples_spinbox) + self.addAction(limit_samples) + self.limit_samples = limit_samples + + +class ThermostatSettingsMenu(QtWidgets.QMenu): + def __init__(self, thermostat, info_box, style): + super().__init__() + self._thermostat = thermostat + self._info_box = info_box + self._style = style + self.setTitle("Thermostat settings") + + self.hw_rev_data = dict() + self._thermostat.hw_rev_update.connect(self.hw_rev) + self._thermostat.connection_state_update.connect( + self.thermostat_state_change_handler + ) + + self.fan_group = QtWidgets.QWidget() + self.fan_group.setEnabled(False) + self.fan_group.setMinimumSize(QtCore.QSize(40, 0)) + self.fan_layout = QtWidgets.QHBoxLayout(self.fan_group) + self.fan_layout.setSpacing(9) + self.fan_lbl = QtWidgets.QLabel(parent=self.fan_group) + self.fan_lbl.setMinimumSize(QtCore.QSize(40, 0)) + self.fan_lbl.setMaximumSize(QtCore.QSize(40, 16777215)) + self.fan_lbl.setBaseSize(QtCore.QSize(40, 0)) + self.fan_layout.addWidget(self.fan_lbl) + self.fan_power_slider = QtWidgets.QSlider(parent=self.fan_group) + self.fan_power_slider.setMinimumSize(QtCore.QSize(200, 0)) + self.fan_power_slider.setMaximumSize(QtCore.QSize(200, 16777215)) + self.fan_power_slider.setBaseSize(QtCore.QSize(200, 0)) + self.fan_power_slider.setRange(1, 100) + self.fan_power_slider.setOrientation(QtCore.Qt.Orientation.Horizontal) + self.fan_layout.addWidget(self.fan_power_slider) + self.fan_auto_box = QtWidgets.QCheckBox(parent=self.fan_group) + self.fan_auto_box.setMinimumSize(QtCore.QSize(70, 0)) + self.fan_auto_box.setMaximumSize(QtCore.QSize(70, 16777215)) + self.fan_layout.addWidget(self.fan_auto_box) + self.fan_pwm_warning = QtWidgets.QLabel(parent=self.fan_group) + self.fan_pwm_warning.setMinimumSize(QtCore.QSize(16, 0)) + self.fan_layout.addWidget(self.fan_pwm_warning) + + self.fan_power_slider.valueChanged.connect(self.fan_set_request) + self.fan_auto_box.stateChanged.connect(self.fan_auto_set_request) + self._thermostat.fan_update.connect(self.fan_update) + + self.fan_lbl.setToolTip("Adjust the fan") + self.fan_lbl.setText("Fan:") + self.fan_auto_box.setText("Auto") + + fan = QtWidgets.QWidgetAction(self) + fan.setDefaultWidget(self.fan_group) + self.addAction(fan) + self.fan = fan + + self.actionReset = QtGui.QAction("Reset Thermostat", self) + self.actionReset.triggered.connect(self.reset_request) + self.addAction(self.actionReset) + + self.actionEnter_DFU_Mode = QtGui.QAction("Enter DFU Mode", self) + self.actionEnter_DFU_Mode.triggered.connect(self.dfu_request) + self.addAction(self.actionEnter_DFU_Mode) + + self.actionnet_settings_input_diag = QtGui.QAction("Set IPV4 Settings", self) + self.actionnet_settings_input_diag.triggered.connect(self.net_settings_request) + self.addAction(self.actionnet_settings_input_diag) + + @asyncSlot(bool) + async def load(_): + await self._thermostat.load_cfg() + + self._info_box.display_info_box( + "Config loaded", "All channel configs have been loaded from flash." + ) + + self.actionLoad_all_configs = QtGui.QAction("Load Config", self) + self.actionLoad_all_configs.triggered.connect(load) + self.addAction(self.actionLoad_all_configs) + + @asyncSlot(bool) + async def save(_): + await self._thermostat.save_cfg() + + self._info_box.display_info_box( + "Config saved", "All channel configs have been saved to flash." + ) + + self.actionSave_all_configs = QtGui.QAction("Save Config", self) + self.actionSave_all_configs.triggered.connect(save) + self.addAction(self.actionSave_all_configs) + + def about_thermostat(): + QtWidgets.QMessageBox.about( + self, + "About Thermostat", + f""" +

Sinara 8451 Thermostat v{self.hw_rev_data['rev']['major']}.{self.hw_rev_data['rev']['minor']}

+ +
+ +

Settings:

+ Default fan curve: + a = {self.hw_rev_data['settings']['fan_k_a']}, + b = {self.hw_rev_data['settings']['fan_k_b']}, + c = {self.hw_rev_data['settings']['fan_k_c']} +
+ Fan PWM range: + {self.hw_rev_data['settings']['min_fan_pwm']} \u2013 {self.hw_rev_data['settings']['max_fan_pwm']} +
+ Fan PWM frequency: {self.hw_rev_data['settings']['fan_pwm_freq_hz']} Hz +
+ Fan available: {self.hw_rev_data['settings']['fan_available']} +
+ Fan PWM recommended: {self.hw_rev_data['settings']['fan_pwm_recommended']} + """, + ) + + self.actionAbout_Thermostat = QtGui.QAction("About Thermostat", self) + self.actionAbout_Thermostat.triggered.connect(about_thermostat) + self.addAction(self.actionAbout_Thermostat) + + @pyqtSlot("QVariantMap") + def fan_update(self, fan_settings): + logging.debug(fan_settings) + if fan_settings is None: + return + with QSignalBlocker(self.fan_power_slider): + self.fan_power_slider.setValue( + fan_settings["fan_pwm"] or 100 # 0 = PWM off = full strength + ) + with QSignalBlocker(self.fan_auto_box): + self.fan_auto_box.setChecked(fan_settings["auto_mode"]) + + def set_fan_pwm_warning(self): + if self.fan_power_slider.value() != 100: + pixmapi = getattr(QtWidgets.QStyle.StandardPixmap, "SP_MessageBoxWarning") + icon = self._style.standardIcon(pixmapi) + self.fan_pwm_warning.setPixmap(icon.pixmap(16, 16)) + self.fan_pwm_warning.setToolTip( + "Throttling the fan (not recommended on this hardware rev)" + ) + else: + self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) + self.fan_pwm_warning.setToolTip("") + + @pyqtSlot(ThermostatConnectionState) + def thermostat_state_change_handler(self, state): + if state == ThermostatConnectionState.DISCONNECTED: + self.fan_pwm_warning.setPixmap(QtGui.QPixmap()) + self.fan_pwm_warning.setToolTip("") + + @pyqtSlot("QVariantMap") + def hw_rev(self, hw_rev): + self.hw_rev_data = hw_rev + self.fan_group.setEnabled(self.hw_rev_data["settings"]["fan_available"]) + + @asyncSlot(int) + async def fan_set_request(self, value): + assert self._thermostat.connected() + + if self.fan_auto_box.isChecked(): + with QSignalBlocker(self.fan_auto_box): + self.fan_auto_box.setChecked(False) + await self._thermostat.set_fan(value) + if not self.hw_rev_data["settings"]["fan_pwm_recommended"]: + self.set_fan_pwm_warning() + + @asyncSlot(int) + async def fan_auto_set_request(self, enabled): + assert self._thermostat.connected() + + if enabled: + await self._thermostat.set_fan("auto") + self.fan_update(await self._thermostat.get_fan()) + else: + await self.thermostat.set_fan( + self.fan_power_slider.value() + ) + + @asyncSlot(bool) + async def reset_request(self, _): + assert self._thermostat.connected() + + await self._thermostat.reset() + await self._thermostat.end_session() + self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED + + @asyncSlot(bool) + async def dfu_request(self, _): + assert self._thermostat.connected() + + await self._thermostat.dfu() + await self._thermostat.end_session() + self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED + + @asyncSlot(bool) + async def net_settings_request(self, _): + assert self._thermostat.connected() + + ipv4 = await self._thermostat.get_ipv4() + self.net_settings_input_diag = NetSettingsInputDiag(ipv4["addr"]) + self.net_settings_input_diag.set_ipv4_act.connect(self.set_net_settings_request) + + @asyncSlot(str) + async def set_net_settings_request(self, ipv4_settings): + assert self._thermostat.connected() + + await self._thermostat.set_ipv4(ipv4_settings) + await self._thermostat.end_session() + self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED \ No newline at end of file diff --git a/pythermostat/pythermostat/gui/view/net_settings_input_diag.py b/pythermostat/pythermostat/gui/view/net_settings_input_diag.py new file mode 100644 index 0000000..1ef4f61 --- /dev/null +++ b/pythermostat/pythermostat/gui/view/net_settings_input_diag.py @@ -0,0 +1,36 @@ +from PyQt6 import QtWidgets +from PyQt6.QtWidgets import QAbstractButton +from PyQt6.QtCore import pyqtSignal, pyqtSlot + + +class NetSettingsInputDiag(QtWidgets.QInputDialog): + set_ipv4_act = pyqtSignal(str) + + def __init__(self, current_ipv4_settings): + super().__init__() + self.setWindowTitle("Network Settings") + self.setLabelText( + "Set the Thermostat's IPv4 address, netmask and gateway (optional)" + ) + self.setTextValue(current_ipv4_settings) + self._new_ipv4 = "" + + @pyqtSlot(str) + def set_ipv4(ipv4_settings): + self._new_ipv4 = ipv4_settings + + sure = QtWidgets.QMessageBox(self) + sure.setWindowTitle("Set network?") + sure.setText( + f"Setting this as network and disconnecting:
{ipv4_settings}" + ) + + sure.buttonClicked.connect(self._emit_sig) + sure.show() + + self.textValueSelected.connect(set_ipv4) + self.show() + + @pyqtSlot(QAbstractButton) + def _emit_sig(self, _): + self.set_ipv4_act.emit(self._new_ipv4) diff --git a/pythermostat/pythermostat/thermostat_qt.py b/pythermostat/pythermostat/thermostat_qt.py index 57f8f5b..d683d3d 100755 --- a/pythermostat/pythermostat/thermostat_qt.py +++ b/pythermostat/pythermostat/thermostat_qt.py @@ -11,8 +11,8 @@ from qasync import asyncSlot, asyncClose from pythermostat.autotune import PIDAutotuneState from pythermostat.gui.model.thermostat import Thermostat, ThermostatConnectionState from pythermostat.gui.model.pid_autotuner import PIDAutoTuner -from pythermostat.gui.view.connection_details_menu import ConnectionDetailsMenu from pythermostat.gui.view.info_box import InfoBox +from pythermostat.gui.view.menus import PlotOptionsMenu, ThermostatSettingsMenu, ConnectionDetailsMenu from pythermostat.gui.view.live_plot_view import LiveDataPlotter from pythermostat.gui.view.zero_limits_warning_view import ZeroLimitsWarningView @@ -93,6 +93,14 @@ class MainWindow(QtWidgets.QMainWindow): ) self.connect_btn.setMenu(self.connection_details_menu) + self._thermostat_settings_menu = ThermostatSettingsMenu( + self._thermostat, self._info_box, self.style() + ) + self.thermostat_settings.setMenu(self._thermostat_settings_menu) + + self._plot_options_menu = PlotOptionsMenu(self._channel_graphs) + self.plot_settings.setMenu(self._plot_options_menu) + # Status line self._zero_limits_warning_view = ZeroLimitsWarningView( self._thermostat, self.style(), self.limits_warning