Compare commits

..

No commits in common. "b7cc9db03c91b9bc47a3f56eb0f73da79bf27d15" and "70627342f33448d118b3d721e293e0174ea2dfe0" have entirely different histories.

8 changed files with 198 additions and 146 deletions

View File

@ -14,7 +14,6 @@ class ThermostatConnectionState(Enum):
class Thermostat(QObject, metaclass=PropertyMeta):
connection_state = Property(ThermostatConnectionState)
hw_rev = Property(dict)
fan = Property(dict)
thermistor = Property(list)
@ -24,44 +23,24 @@ class Thermostat(QObject, metaclass=PropertyMeta):
report = Property(list)
connection_error = pyqtSignal()
NUM_CHANNELS = 2
connection_state_changed = pyqtSignal(ThermostatConnectionState)
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._report_mode_task = None
self._poll_for_report = True
self._update_params_task = None
self.disconnect_cb = disconnect_cb
self.connection_state = ThermostatConnectionState.DISCONNECTED
super().__init__(parent)
async def start_session(self, host, port):
self.connection_state_changed.emit(ThermostatConnectionState.CONNECTING)
await self._client.connect(host, port)
self.hw_rev = await self._client.hw_rev()
@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
self.hw_rev = await self.get_hw_rev()
self.connection_state_changed.emit(ThermostatConnectionState.CONNECTED)
self.start_watching()
async def run(self):
self._update_params_task = asyncio.create_task(self.update_params())
@ -74,16 +53,28 @@ class Thermostat(QObject, metaclass=PropertyMeta):
"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()
await self.handle_connection_error()
return
self._update_params_task = asyncio.create_task(self.update_params())
await asyncio.sleep(self._update_s)
async def handle_connection_error(self):
await self.end_session()
self.connection_error.emit()
async def get_hw_rev(self):
return await self._client.hw_rev()
async def update_params(self):
self.fan, self.pwm, self.report, self.pid, self.thermistor, self.postfilter = (
await asyncio.gather(
if self._poll_for_report:
(
self.fan,
self.pwm,
self.report,
self.pid,
self.thermistor,
self.postfilter,
) = await asyncio.gather(
self._client.get_fan(),
self._client.get_pwm(),
self._client.report(),
@ -91,14 +82,56 @@ class Thermostat(QObject, metaclass=PropertyMeta):
self._client.get_steinhart_hart(),
self._client.get_postfilter(),
)
else:
self.fan, self.pwm, self.pid, self.thermistor, self.postfilter = (
await asyncio.gather(
self._client.get_fan(),
self._client.get_pwm(),
self._client.get_pid(),
self._client.get_steinhart_hart(),
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
def start_watching(self):
self._watch_task = asyncio.create_task(self.run())
@asyncSlot()
async def stop_watching(self):
if self._watch_task is not None:
await self.set_report_mode(False)
self._watch_task.cancel()
self._watch_task = None
self._update_params_task.cancel()
self._update_params_task = None
async def set_report_mode(self, enabled: bool):
self._poll_for_report = not enabled
if enabled:
self._report_mode_task = asyncio.create_task(self.report_mode())
else:
self._client.stop_report_mode()
async def report_mode(self):
async for report in self._client.report_mode():
self.report_update.emit(report)
@asyncSlot()
async def end_session(self):
await self.set_report_mode(False)
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()
self.connection_state_changed.emit(ThermostatConnectionState.DISCONNECTED)
async def set_ipv4(self, ipv4):
await self._client.set_param("ipv4", ipv4)
@ -120,6 +153,10 @@ class Thermostat(QObject, metaclass=PropertyMeta):
async def reset(self):
await self._client.reset()
@pyqtSlot(float)
def set_update_s(self, update_s):
self._update_s = update_s
async def set_fan(self, power="auto"):
await self._client.set_fan(power)

View File

@ -3,12 +3,12 @@ from PyQt6.QtCore import pyqtSlot
from pytec.gui.model.thermostat import ThermostatConnectionState
class ConnectionDetailsMenu(QtWidgets.QMenu):
class ConnMenu(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.connection_state_changed.connect(
self.thermostat_state_change_handler
)

View File

@ -46,6 +46,8 @@ registerParameterType("mutex", MutexParameter)
class CtrlPanel(QObject):
set_zero_limits_warning_sig = pyqtSignal(list)
def __init__(
self,
thermostat,
@ -229,6 +231,8 @@ class CtrlPanel(QObject):
@pyqtSlot("QVariantList")
def update_pwm(self, pwm_data):
channels_zeroed_limits = [set() for i in range(self.NUM_CHANNELS)]
for pwm_params in pwm_data:
channel = pwm_params["channel"]
with QSignalBlocker(self.params[channel]):
@ -242,6 +246,11 @@ class CtrlPanel(QObject):
"Output Config", "Limits", "Max Heating Current"
).setValue(pwm_params["max_i_neg"]["value"] * 1000)
for limit in "max_i_pos", "max_i_neg", "max_v":
if pwm_params[limit]["value"] == 0.0:
channels_zeroed_limits[channel].add(limit)
self.set_zero_limits_warning_sig.emit(channels_zeroed_limits)
@pyqtSlot("QVariantList")
def update_postfilter(self, postfilter_data):
for postfilter_params in postfilter_data:

View File

@ -17,7 +17,7 @@ class LiveDataPlotter(QObject):
self._thermostat.report_update.connect(self.update_report)
self._thermostat.pid_update.connect(self.update_pid)
self._thermostat.connection_state_update.connect(
self._thermostat.connection_state_changed.connect(
self.thermostat_state_change_handler
)

View File

@ -369,7 +369,7 @@
<number>0</number>
</property>
<item>
<layout class="QHBoxLayout" name="report_layout" stretch="0,1,1">
<layout class="QHBoxLayout" name="report_layout" stretch="0,1,1,1">
<property name="spacing">
<number>6</number>
</property>
@ -435,6 +435,31 @@
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="report_box">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="maximumSize">
<size>
<width>80</width>
<height>16777215</height>
</size>
</property>
<property name="baseSize">
<size>
<width>80</width>
<height>0</height>
</size>
</property>
<property name="text">
<string>Report</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="report_apply_btn">
<property name="sizePolicy">

View File

@ -6,7 +6,7 @@ from pytec.gui.view.net_settings_input_diag import NetSettingsInputDiag
from pytec.gui.model.thermostat import ThermostatConnectionState
class ThermostatSettingsMenu(QtWidgets.QMenu):
class ThermostatCtrlMenu(QtWidgets.QMenu):
def __init__(self, thermostat, info_box, style):
super().__init__()
self._thermostat = thermostat
@ -16,7 +16,7 @@ class ThermostatSettingsMenu(QtWidgets.QMenu):
self.hw_rev_data = dict()
self._thermostat.hw_rev_update.connect(self.hw_rev)
self._thermostat.connection_state_update.connect(
self._thermostat.connection_state_changed.connect(
self.thermostat_state_change_handler
)
@ -188,7 +188,6 @@ class ThermostatSettingsMenu(QtWidgets.QMenu):
await self._thermostat.reset()
await self._thermostat.end_session()
self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED
@asyncSlot(bool)
async def dfu_request(self, _):
@ -196,7 +195,6 @@ class ThermostatSettingsMenu(QtWidgets.QMenu):
await self._thermostat.dfu()
await self._thermostat.end_session()
self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED
@asyncSlot(bool)
async def net_settings_request(self, _):
@ -212,4 +210,3 @@ class ThermostatSettingsMenu(QtWidgets.QMenu):
await self._thermostat.set_ipv4(ipv4_settings)
await self._thermostat.end_session()
self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED

View File

@ -3,24 +3,15 @@ from PyQt6 import QtWidgets, QtGui
class ZeroLimitsWarningView(QObject):
def __init__(self, thermostat, style, limit_warning):
def __init__(self, style, limit_warning):
super().__init__()
self._thermostat = thermostat
self._thermostat.pwm_update.connect(self.set_limits_warning)
self._lbl = limit_warning
self._style = style
@pyqtSlot("QVariantList")
def set_limits_warning(self, pwm_data: list):
channels_zeroed_limits = [set() for i in range(self._thermostat.NUM_CHANNELS)]
for pwm_params in pwm_data:
channel = pwm_params["channel"]
for limit in "max_i_pos", "max_i_neg", "max_v":
if pwm_params[limit]["value"] == 0.0:
channels_zeroed_limits[channel].add(limit)
def set_limits_warning(self, channels_zeroed_limits: list):
channel_disabled = [False, False]
report_str = "The following output limit(s) are set to zero:\n"
for ch, zeroed_limits in enumerate(channels_zeroed_limits):
if {"max_i_pos", "max_i_neg"}.issubset(zeroed_limits):

View File

@ -1,24 +1,22 @@
"""GUI for the Sinara 8451 Thermostat"""
import json
import asyncio
import logging
import argparse
import importlib.resources
import qasync
from qasync import asyncSlot, asyncClose
from autotune import PIDAutotuneState
from PyQt6 import QtWidgets, QtGui, uic
from PyQt6.QtCore import pyqtSlot
from pytec.gui.model.thermostat import Thermostat, ThermostatConnectionState
from pytec.gui.model.pid_autotuner import PIDAutoTuner
from pytec.gui.view.zero_limits_warning_view import ZeroLimitsWarningView
from pytec.gui.view.thermostat_settings_menu import ThermostatSettingsMenu
from pytec.gui.view.connection_details_menu import ConnectionDetailsMenu
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():
@ -58,92 +56,86 @@ class MainWindow(QtWidgets.QMainWindow):
ui_file_path = importlib.resources.files("pytec.gui.view").joinpath("tec_qt.ui")
uic.loadUi(ui_file_path, self)
self._info_box = InfoBox()
self.info_box = InfoBox()
# Models
self._thermostat = Thermostat(self, self.report_refresh_spin.value())
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.thermostat.connection_state_changed.connect(self._on_connection_changed)
self._autotuners = PIDAutoTuner(self, self._thermostat, 2)
self._autotuners.autotune_state_changed.connect(
self._on_pid_autotune_state_changed
)
self.autotuners = PIDAutoTuner(self, self.thermostat, 2)
self.autotuners.autotune_state_changed.connect(self.pid_autotune_handler)
# 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
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(
self.info_box.display_info_box(
"Connection Error", "Thermostat connection lost. Is it unplugged?"
)
self._thermostat.connection_error.connect(handle_connection_error)
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.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,
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.connection_details_menu = ConnectionDetailsMenu(
self._thermostat, self.connect_btn
)
self.connect_btn.setMenu(self.connection_details_menu)
self.conn_menu = ConnMenu(self.thermostat, self.connect_btn)
self.connect_btn.setMenu(self.conn_menu)
self._thermostat_settings_menu = ThermostatSettingsMenu(
self._thermostat, self._info_box, self.style()
self.thermostat_ctrl_menu = ThermostatCtrlMenu(
self.thermostat, self.info_box, self.style()
)
self.thermostat_settings.setMenu(self._thermostat_settings_menu)
self.thermostat_settings.setMenu(self.thermostat_ctrl_menu)
self._plot_options_menu = PlotOptionsMenu(self._channel_graphs)
self.plot_settings.setMenu(self._plot_options_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
self.zero_limits_warning = ZeroLimitsWarningView(
self.style(), self.limits_warning
)
self.ctrl_panel_view.set_zero_limits_warning_sig.connect(
self.zero_limits_warning.set_limits_warning
)
self.loading_spinner.hide()
self.report_apply_btn.clicked.connect(
lambda: self._thermostat.set_update_s(self.report_refresh_spin.value())
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
await self.thermostat.end_session()
except:
pass
@pyqtSlot(ThermostatConnectionState)
def _on_connection_state_changed(self, state):
def _on_connection_changed(self, state):
self.graph_group.setEnabled(state == ThermostatConnectionState.CONNECTED)
self.thermostat_settings.setEnabled(
state == ThermostatConnectionState.CONNECTED
@ -155,8 +147,8 @@ class MainWindow(QtWidgets.QMainWindow):
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']}"
f"{self.thermostat.hw_rev['rev']['major']}."
f"{self.thermostat.hw_rev['rev']['minor']}"
)
case ThermostatConnectionState.CONNECTING:
@ -166,56 +158,57 @@ class MainWindow(QtWidgets.QMainWindow):
case ThermostatConnectionState.DISCONNECTED:
self.connect_btn.setText("Connect")
self.status_lbl.setText("Disconnected")
self.report_box.setChecked(False)
@pyqtSlot(int, PIDAutotuneState)
def _on_pid_autotune_state_changed(self, _ch, _state):
autotuning_channels = []
def pid_autotune_handler(self, _ch, _state):
ch_tuning = []
for ch in range(self.NUM_CHANNELS):
if self._autotuners.get_state(ch) in {
if self.autotuners.get_state(ch) in {
PIDAutotuneState.STATE_READY,
PIDAutotuneState.STATE_RELAY_STEP_UP,
PIDAutotuneState.STATE_RELAY_STEP_DOWN,
}:
autotuning_channels.append(ch)
ch_tuning.append(ch)
if len(autotuning_channels) == 0:
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(
f"Autotuning channel {autotuning_channels}..."
"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.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(),
if (self._connecting_task is None) and (not self.thermostat.connected()):
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(),
)
)
try:
await self._connecting_task
except (OSError, asyncio.CancelledError) as exc:
await self.thermostat.end_session()
if isinstance(exc, asyncio.CancelledError):
return
raise
finally:
self._connecting_task = None
self._thermostat.connection_state = ThermostatConnectionState.CONNECTED
self._thermostat.start_watching()
case ThermostatConnectionState.CONNECTING:
elif self._connecting_task is not None:
self._connecting_task.cancel()
self._connecting_task = None
await self._thermostat.end_session()
self._thermostat.connection_state = (
ThermostatConnectionState.DISCONNECTED
)
else:
await self.thermostat.end_session()
case ThermostatConnectionState.CONNECTED:
await self._thermostat.end_session()
self._thermostat.connection_state = (
ThermostatConnectionState.DISCONNECTED
)
@asyncSlot(int)
async def on_report_box_stateChanged(self, enabled):
await self.thermostat.set_report_mode(enabled)
async def coro_main():
@ -238,9 +231,9 @@ async def coro_main():
if args.connect:
if args.HOST:
main_window.connection_details_menu.host_set_line.setText(args.HOST)
main_window.conn_menu.host_set_line.setText(args.HOST)
if args.PORT:
main_window.connection_details_menu.port_set_spin.setValue(int(args.PORT))
main_window.conn_menu.port_set_spin.setValue(int(args.PORT))
main_window.connect_btn.click()
await app_quit_event.wait()