Compare commits

...

24 Commits

Author SHA1 Message Date
b7cc9db03c No need to create a new task for waiting 2024-09-09 18:29:24 +08:00
92d574a0e3 fixup! Zero limits warning cleanup 2024-09-09 16:29:32 +08:00
2d3bc96da9 super init's first 2024-09-09 15:44:07 +08:00
7efd184496 Format 2024-09-09 15:10:30 +08:00
14621e11a6 ch_tuning -> autotuning_channels 2024-09-09 15:10:16 +08:00
cfe9ee3985 Module docstring?? 2024-09-09 15:09:04 +08:00
a60f55d1c8 Reorder imports 2024-09-09 15:08:17 +08:00
a466e3e09d Sensible names for views 2024-09-09 15:06:22 +08:00
b0a4e1218a fixup! tec_qt: Private everything possible 2024-09-09 15:00:44 +08:00
d199770de1 Unprivate conn_menu
Used for autoconnect
2024-09-09 15:00:14 +08:00
9911f72059 Order? 2024-09-09 14:54:37 +08:00
f62adbbc1a Reorder 2024-09-09 14:54:37 +08:00
c416fd8b2b Not async 2024-09-09 14:54:37 +08:00
b3ba577268 Remove report mode from thermostat data model 2024-09-09 14:54:37 +08:00
3c1228e8a8 Remove report mode box 2024-09-09 14:54:37 +08:00
23653a1ecd More sensible names 2024-09-09 14:54:37 +08:00
29221ce570 tec_qt: Private everything possible 2024-09-09 14:54:35 +08:00
2334a922f7 Remove error handling for connecting task
Just let the exception propagate, even when stopping the connection
2024-09-09 14:54:24 +08:00
1aab3ca1d6 Update thermostat state from controller code 2024-09-09 14:54:24 +08:00
41d4154a28 extra func out 2024-09-09 14:54:24 +08:00
6473905488 No need extra function 2024-09-09 14:54:24 +08:00
b13f481381 State update explicitly 2024-09-09 14:54:24 +08:00
66303efd11 Zero limits warning cleanup 2024-09-02 16:12:55 +08:00
f0e7488682 Add NUM_CHANNELS to Thermostat! 2024-09-02 16:06:21 +08:00
8 changed files with 147 additions and 199 deletions

View File

@ -14,6 +14,7 @@ class ThermostatConnectionState(Enum):
class Thermostat(QObject, metaclass=PropertyMeta):
connection_state = Property(ThermostatConnectionState)
hw_rev = Property(dict)
fan = Property(dict)
thermistor = Property(list)
@ -23,24 +24,44 @@ class Thermostat(QObject, metaclass=PropertyMeta):
report = Property(list)
connection_error = pyqtSignal()
connection_state_changed = pyqtSignal(ThermostatConnectionState)
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._report_mode_task = None
self._poll_for_report = True
self._update_params_task = None
self.disconnect_cb = disconnect_cb
super().__init__(parent)
self.connection_state = ThermostatConnectionState.DISCONNECTED
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.get_hw_rev()
self.connection_state_changed.emit(ThermostatConnectionState.CONNECTED)
self.start_watching()
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
async def run(self):
self._update_params_task = asyncio.create_task(self.update_params())
@ -53,28 +74,16 @@ class Thermostat(QObject, metaclass=PropertyMeta):
"Encountered an error while polling for information from Thermostat.",
exc_info=True,
)
await self.handle_connection_error()
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 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):
if self._poll_for_report:
(
self.fan,
self.pwm,
self.report,
self.pid,
self.thermistor,
self.postfilter,
) = await asyncio.gather(
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(),
@ -82,56 +91,14 @@ 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()
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)
@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)
@ -153,10 +120,6 @@ 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 ConnMenu(QtWidgets.QMenu):
class ConnectionDetailsMenu(QtWidgets.QMenu):
def __init__(self, thermostat, connect_btn):
super().__init__()
self._thermostat = thermostat
self._connect_btn = connect_btn
self._thermostat.connection_state_changed.connect(
self._thermostat.connection_state_update.connect(
self.thermostat_state_change_handler
)

View File

@ -46,8 +46,6 @@ registerParameterType("mutex", MutexParameter)
class CtrlPanel(QObject):
set_zero_limits_warning_sig = pyqtSignal(list)
def __init__(
self,
thermostat,
@ -231,8 +229,6 @@ 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]):
@ -246,11 +242,6 @@ 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_changed.connect(
self._thermostat.connection_state_update.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,1">
<layout class="QHBoxLayout" name="report_layout" stretch="0,1,1">
<property name="spacing">
<number>6</number>
</property>
@ -435,31 +435,6 @@
</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 ThermostatCtrlMenu(QtWidgets.QMenu):
class ThermostatSettingsMenu(QtWidgets.QMenu):
def __init__(self, thermostat, info_box, style):
super().__init__()
self._thermostat = thermostat
@ -16,7 +16,7 @@ class ThermostatCtrlMenu(QtWidgets.QMenu):
self.hw_rev_data = dict()
self._thermostat.hw_rev_update.connect(self.hw_rev)
self._thermostat.connection_state_changed.connect(
self._thermostat.connection_state_update.connect(
self.thermostat_state_change_handler
)
@ -188,6 +188,7 @@ class ThermostatCtrlMenu(QtWidgets.QMenu):
await self._thermostat.reset()
await self._thermostat.end_session()
self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED
@asyncSlot(bool)
async def dfu_request(self, _):
@ -195,6 +196,7 @@ class ThermostatCtrlMenu(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, _):
@ -210,3 +212,4 @@ class ThermostatCtrlMenu(QtWidgets.QMenu):
await self._thermostat.set_ipv4(ipv4_settings)
await self._thermostat.end_session()
self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED

View File

@ -3,15 +3,24 @@ from PyQt6 import QtWidgets, QtGui
class ZeroLimitsWarningView(QObject):
def __init__(self, style, limit_warning):
def __init__(self, thermostat, 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, channels_zeroed_limits: list):
channel_disabled = [False, False]
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)
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,22 +1,24 @@
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
"""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.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():
@ -56,86 +58,92 @@ 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_changed.connect(self._on_connection_changed)
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.pid_autotune_handler)
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
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.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.connection_details_menu = ConnectionDetailsMenu(
self._thermostat, self.connect_btn
)
self.thermostat_settings.setMenu(self.thermostat_ctrl_menu)
self.connect_btn.setMenu(self.connection_details_menu)
self.plot_options_menu = PlotOptionsMenu(self.channel_graphs)
self.plot_settings.setMenu(self.plot_options_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 = ZeroLimitsWarningView(
self.style(), self.limits_warning
)
self.ctrl_panel_view.set_zero_limits_warning_sig.connect(
self.zero_limits_warning.set_limits_warning
self._zero_limits_warning_view = 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())
lambda: self._thermostat.set_update_s(self.report_refresh_spin.value())
)
@asyncClose
async def closeEvent(self, _event):
try:
await self.thermostat.end_session()
await self._thermostat.end_session()
self._thermostat.connection_state = ThermostatConnectionState.DISCONNECTED
except:
pass
@pyqtSlot(ThermostatConnectionState)
def _on_connection_changed(self, state):
def _on_connection_state_changed(self, state):
self.graph_group.setEnabled(state == ThermostatConnectionState.CONNECTED)
self.thermostat_settings.setEnabled(
state == ThermostatConnectionState.CONNECTED
@ -147,8 +155,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:
@ -158,57 +166,56 @@ 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 pid_autotune_handler(self, _ch, _state):
ch_tuning = []
def _on_pid_autotune_state_changed(self, _ch, _state):
autotuning_channels = []
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,
}:
ch_tuning.append(ch)
autotuning_channels.append(ch)
if len(ch_tuning) == 0:
if len(autotuning_channels) == 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)
f"Autotuning channel {autotuning_channels}..."
)
self.loading_spinner.start()
self.loading_spinner.show()
@asyncSlot()
async def on_connect_btn_clicked(self):
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(),
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(),
)
)
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()
elif self._connecting_task is not None:
self._connecting_task.cancel()
else:
await self.thermostat.end_session()
case ThermostatConnectionState.CONNECTING:
self._connecting_task.cancel()
self._connecting_task = None
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)
case ThermostatConnectionState.CONNECTED:
await self._thermostat.end_session()
self._thermostat.connection_state = (
ThermostatConnectionState.DISCONNECTED
)
async def coro_main():
@ -231,9 +238,9 @@ async def coro_main():
if args.connect:
if args.HOST:
main_window.conn_menu.host_set_line.setText(args.HOST)
main_window.connection_details_menu.host_set_line.setText(args.HOST)
if args.PORT:
main_window.conn_menu.port_set_spin.setValue(int(args.PORT))
main_window.connection_details_menu.port_set_spin.setValue(int(args.PORT))
main_window.connect_btn.click()
await app_quit_event.wait()