Compare commits

..

10 Commits

Author SHA1 Message Date
0d7032b92f Connecting task moved? 2024-07-16 13:29:27 +08:00
155934da15 AsyncIO version Client -> AsyncioClient 2024-07-16 13:29:27 +08:00
f76b414426 More elegant exception rethrow 2024-07-16 13:29:27 +08:00
757fd1689a Exclusively use the Thermostat object as a medium
All calls to the Thermostat should be forwarded by the medium.
2024-07-16 13:29:23 +08:00
08925755a3 Just catch asyncio.TimeoutError
Will just change to TimeoutError once we switch to Python 3.11 in the
flake.
2024-07-16 13:28:47 +08:00
3d41254f43 Integrate WrappedClient into Thermostat model 2024-07-16 13:28:47 +08:00
744dabb868 Should not stop cancelling read if timeout'd 2024-07-16 13:28:47 +08:00
9dd4138276 Remove exception too general 2024-07-16 13:28:47 +08:00
a64532eb23 Fix Autotuner state for forceful disconnect 2024-07-16 13:28:47 +08:00
759b9e57a1 Correct exception catching
asyncio.Task.result() is simply going to throw the exception in
asyncio.Task.exception(), there is no need to manually throw it.
2024-07-16 13:28:47 +08:00
5 changed files with 85 additions and 101 deletions

View File

@ -1,9 +1,9 @@
import asyncio import asyncio
from pytec.aioclient import Client from pytec.aioclient import AsyncioClient
async def main(): async def main():
tec = Client() tec = AsyncioClient()
await tec.start_session() # (host="192.168.1.26", port=23) await tec.start_session() # (host="192.168.1.26", port=23)
await tec.set_param("s-h", 1, "t0", 20) await tec.set_param("s-h", 1, "t0", 20)
print(await tec.get_pwm()) print(await tec.get_pwm())

View File

@ -7,56 +7,30 @@ class CommandError(Exception):
pass pass
class StoppedConnecting(Exception): class AsyncioClient:
pass
class Client:
def __init__(self): def __init__(self):
self._reader = None self._reader = None
self._writer = None self._writer = None
self._connecting_task = None
self._command_lock = asyncio.Lock() self._command_lock = asyncio.Lock()
self._report_mode_on = False self._report_mode_on = False
self.timeout = None self.timeout = None
async def start_session(self, host="192.168.1.26", port=23, timeout=None): async def start_session(self, host="192.168.1.26", port=23):
"""Start session to Thermostat at specified host and port. """Start session to Thermostat at specified host and port.
Throws StoppedConnecting if disconnect was called while connecting.
Throws asyncio.TimeoutError if timeout was exceeded.
Example:: Example::
client = Client() client = AsyncioClient()
try:
await client.start_session() await client.start_session()
except StoppedConnecting:
print("Stopped connecting")
""" """
self._connecting_task = asyncio.create_task( self._reader, self._writer = await asyncio.open_connection(host, port)
asyncio.wait_for(asyncio.open_connection(host, port), timeout)
)
self.timeout = timeout
try:
self._reader, self._writer = await self._connecting_task
except asyncio.CancelledError:
raise StoppedConnecting
finally:
self._connecting_task = None
await self._check_zero_limits() await self._check_zero_limits()
def connecting(self):
"""Returns True if client is connecting"""
return self._connecting_task is not None
def connected(self): def connected(self):
"""Returns True if client is connected""" """Returns True if client is connected"""
return self._writer is not None return self._writer is not None
async def end_session(self): async def end_session(self):
"""End session to Thermostat if connected, cancel connection if connecting""" """End session to Thermostat"""
if self._connecting_task is not None:
self._connecting_task.cancel()
if self._writer is None: if self._writer is None:
return return
@ -93,8 +67,7 @@ class Client:
async def _command(self, *command): async def _command(self, *command):
async with self._command_lock: async with self._command_lock:
# protect the read-write process from being cancelled midway line = await self._read_write(command)
line = await asyncio.shield(self._read_write(command))
response = json.loads(line) response = json.loads(line)
logging.debug(f"{command}: {response}") logging.debug(f"{command}: {response}")

View File

@ -4,10 +4,10 @@ from autotune import PIDAutotuneState, PIDAutotune
class PIDAutoTuner(QObject): class PIDAutoTuner(QObject):
def __init__(self, parent, client, num_of_channel): def __init__(self, parent, thermostat, num_of_channel):
super().__init__(parent) super().__init__(parent)
self._client = client self._thermostat = thermostat
self.autotuners = [PIDAutotune(25) for _ in range(num_of_channel)] self.autotuners = [PIDAutotune(25) for _ in range(num_of_channel)]
self.target_temp = [20.0 for _ in range(num_of_channel)] self.target_temp = [20.0 for _ in range(num_of_channel)]
self.test_current = [1.0 for _ in range(num_of_channel)] self.test_current = [1.0 for _ in range(num_of_channel)]
@ -37,7 +37,7 @@ class PIDAutoTuner(QObject):
async def stop_pid_from_running(self, ch): async def stop_pid_from_running(self, ch):
self.autotuners[ch].setOff() self.autotuners[ch].setOff()
await self._client.set_param("pwm", ch, "i_set", 0) await self._thermostat.set_param("pwm", ch, "i_set", 0)
@asyncSlot(list) @asyncSlot(list)
async def tick(self, report): async def tick(self, report):
@ -56,21 +56,21 @@ class PIDAutoTuner(QObject):
self.autotuners[ch].run( self.autotuners[ch].run(
channel_report["temperature"], channel_report["time"] channel_report["temperature"], channel_report["time"]
) )
await self._client.set_param( await self._thermostat.set_param(
"pwm", ch, "i_set", self.autotuners[ch].output() "pwm", ch, "i_set", self.autotuners[ch].output()
) )
case PIDAutotuneState.STATE_SUCCEEDED: case PIDAutotuneState.STATE_SUCCEEDED:
kp, ki, kd = self.autotuners[ch].get_tec_pid() kp, ki, kd = self.autotuners[ch].get_tec_pid()
self.autotuners[ch].setOff() self.autotuners[ch].setOff()
await self._client.set_param("pid", ch, "kp", kp) await self._thermostat.set_param("pid", ch, "kp", kp)
await self._client.set_param("pid", ch, "ki", ki) await self._thermostat.set_param("pid", ch, "ki", ki)
await self._client.set_param("pid", ch, "kd", kd) await self._thermostat.set_param("pid", ch, "kd", kd)
await self._client.set_param("pwm", ch, "pid") await self._thermostat.set_param("pwm", ch, "pid")
await self._client.set_param( await self._thermostat.set_param(
"pid", ch, "target", self.target_temp[ch] "pid", ch, "target", self.target_temp[ch]
) )
case PIDAutotuneState.STATE_FAILED: case PIDAutotuneState.STATE_FAILED:
self.autotuners[ch].setOff() self.autotuners[ch].setOff()
await self._client.set_param("pwm", ch, "i_set", 0) await self._thermostat.set_param("pwm", ch, "i_set", 0)

View File

@ -1,20 +1,9 @@
from pytec.aioclient import Client
from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot
from qasync import asyncSlot from qasync import asyncSlot
from pytec.gui.model.property import Property, PropertyMeta from pytec.gui.model.property import Property, PropertyMeta
import asyncio import asyncio
import logging import logging
from pytec.aioclient import AsyncioClient
class WrappedClient(QObject, Client):
connection_error = pyqtSignal()
async def _read_line(self):
try:
return await super()._read_line()
except (Exception, TimeoutError, asyncio.exceptions.TimeoutError):
logging.error("Client connection error, disconnecting", exc_info=True)
self.connection_error.emit()
class Thermostat(QObject, metaclass=PropertyMeta): class Thermostat(QObject, metaclass=PropertyMeta):
@ -27,35 +16,40 @@ class Thermostat(QObject, metaclass=PropertyMeta):
interval = Property(list) interval = Property(list)
report = Property(list) report = Property(list)
info_box_trigger = pyqtSignal(str, str) info_box_trigger = pyqtSignal(str, str)
connection_error = pyqtSignal()
def __init__(self, parent, client, update_s): def __init__(self, parent, update_s):
self._update_s = update_s self._update_s = update_s
self._client = client self._client = AsyncioClient()
self._watch_task = None self._watch_task = None
self._report_mode_task = None self._report_mode_task = None
self._poll_for_report = True self._poll_for_report = True
self.connection_errored = False
super().__init__(parent) super().__init__(parent)
async def start_session(self, host, port):
await self._client.start_session(host, port)
async def run(self): async def run(self):
self.task = asyncio.create_task(self.update_params()) self.task = asyncio.create_task(self.update_params())
while True: while True:
if self.task.done(): if self.task.done():
if self.task.exception() is not None:
try: try:
raise self.task.exception() _ = self.task.result()
except ( except asyncio.TimeoutError:
Exception,
TimeoutError,
asyncio.exceptions.TimeoutError,
):
logging.error( logging.error(
"Encountered an error while updating parameter tree.", "Encountered an error while updating parameter tree.",
exc_info=True, exc_info=True,
) )
_ = self.task.result() self.connection_error.emit()
return
self.task = asyncio.create_task(self.update_params()) self.task = asyncio.create_task(self.update_params())
await asyncio.sleep(self._update_s) await asyncio.sleep(self._update_s)
@pyqtSlot()
def timed_out(self):
self.connection_errored = True
async def get_hw_rev(self): async def get_hw_rev(self):
self.hw_rev = await self._client.hw_rev() self.hw_rev = await self._client.hw_rev()
return self.hw_rev return self.hw_rev
@ -75,9 +69,6 @@ class Thermostat(QObject, metaclass=PropertyMeta):
def connected(self): def connected(self):
return self._client.connected() return self._client.connected()
def connecting(self):
return self._client.connecting()
def start_watching(self): def start_watching(self):
self._watch_task = asyncio.create_task(self.run()) self._watch_task = asyncio.create_task(self.run())
@ -106,6 +97,7 @@ class Thermostat(QObject, metaclass=PropertyMeta):
async def end_session(self): async def end_session(self):
await self._client.end_session() await self._client.end_session()
self.connection_errored = False
async def set_ipv4(self, ipv4): async def set_ipv4(self, ipv4):
await self._client.set_param("ipv4", ipv4) await self._client.set_param("ipv4", ipv4)
@ -136,3 +128,12 @@ class Thermostat(QObject, metaclass=PropertyMeta):
@pyqtSlot(float) @pyqtSlot(float)
def set_update_s(self, update_s): def set_update_s(self, update_s):
self._update_s = update_s self._update_s = update_s
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)

View File

@ -7,12 +7,11 @@ from pytec.gui.view.live_plot_view import LiveDataPlotter
from pytec.gui.view.ctrl_panel import CtrlPanel from pytec.gui.view.ctrl_panel import CtrlPanel
from pytec.gui.view.info_box import InfoBox from pytec.gui.view.info_box import InfoBox
from pytec.gui.model.pid_autotuner import PIDAutoTuner from pytec.gui.model.pid_autotuner import PIDAutoTuner
from pytec.gui.model.thermostat import WrappedClient, Thermostat from pytec.gui.model.thermostat import Thermostat
import json import json
from autotune import PIDAutotuneState from autotune import PIDAutotuneState
from qasync import asyncSlot, asyncClose from qasync import asyncSlot, asyncClose
import qasync import qasync
from pytec.aioclient import StoppedConnecting
import asyncio import asyncio
import logging import logging
import argparse import argparse
@ -63,7 +62,9 @@ class MainWindow(QtWidgets.QMainWindow):
self.hw_rev_data = None self.hw_rev_data = None
self.info_box = InfoBox() self.info_box = InfoBox()
self.client = WrappedClient(self) self.thermostat = Thermostat(
self, self.report_refresh_spin.value()
)
def handle_connection_error(): def handle_connection_error():
self.info_box.display_info_box( self.info_box.display_info_box(
@ -72,13 +73,12 @@ class MainWindow(QtWidgets.QMainWindow):
self.bail() self.bail()
self.client.connection_error.connect(handle_connection_error) self.thermostat.connection_error.connect(handle_connection_error)
self.thermostat = Thermostat( self.client.connection_error.connect(self.thermostat.timed_out)
self, self.client, self.report_refresh_spin.value() self.client.connection_error.connect(self.bail)
)
self.autotuners = PIDAutoTuner(self, self.client, 2) self.autotuners = PIDAutoTuner(self, self.thermostat, 2)
def get_ctrl_panel_config(args): def get_ctrl_panel_config(args):
with open(args.param_tree, "r") as f: with open(args.param_tree, "r") as f:
@ -197,9 +197,12 @@ class MainWindow(QtWidgets.QMainWindow):
self.thermostat_ctrl_menu.fan_pwm_warning.setToolTip("") self.thermostat_ctrl_menu.fan_pwm_warning.setToolTip("")
self.clear_graphs() self.clear_graphs()
self.report_box.setChecked(False) self.report_box.setChecked(False)
if not Thermostat.connecting or Thermostat.connected:
for ch in range(self.NUM_CHANNELS): for ch in range(self.NUM_CHANNELS):
if self.autotuners.get_state(ch) != PIDAutotuneState.STATE_OFF: if self.autotuners.get_state(ch) != PIDAutotuneState.STATE_OFF:
if self.thermostat.connection_errored:
# Don't send any commands, just reset local state
self.autotuners.autotuners[ch].setOff()
else:
await self.autotuners.stop_pid_from_running(ch) await self.autotuners.stop_pid_from_running(ch)
await self.thermostat.set_report_mode(False) await self.thermostat.set_report_mode(False)
self.thermostat.stop_watching() self.thermostat.stop_watching()
@ -241,23 +244,30 @@ class MainWindow(QtWidgets.QMainWindow):
self.conn_menu.host_set_line.text(), self.conn_menu.host_set_line.text(),
self.conn_menu.port_set_spin.value(), self.conn_menu.port_set_spin.value(),
) )
self._connecting_task = None
try: try:
if not (self.client.connecting() or self.client.connected()): if (self._connecting_task is None) or (not self.thermostat.connected()):
self.status_lbl.setText("Connecting...") self.status_lbl.setText("Connecting...")
self.connect_btn.setText("Stop") self.connect_btn.setText("Stop")
self.conn_menu.host_set_line.setEnabled(False) self.conn_menu.host_set_line.setEnabled(False)
self.conn_menu.port_set_spin.setEnabled(False) self.conn_menu.port_set_spin.setEnabled(False)
try: try:
await self.client.start_session(host=host, port=port, timeout=5) self._connecting_task = asyncio.wait_for(
except StoppedConnecting: self.thermostat.start_session(host=host, port=port), timeout=5
)
await self._connecting_task
except asyncio.TimeoutError:
return return
await self._on_connection_changed(True) await self._on_connection_changed(True)
else: else:
if self._connecting_task is not None:
self._connecting_task.cancel()
await self.bail() await self.bail()
# TODO: Remove asyncio.TimeoutError in Python 3.11 # TODO: Remove asyncio.TimeoutError in Python 3.11
except (OSError, TimeoutError, asyncio.TimeoutError): except (OSError, asyncio.TimeoutError):
try: try:
await self.bail() await self.bail()
except ConnectionResetError: except ConnectionResetError:
@ -266,7 +276,7 @@ class MainWindow(QtWidgets.QMainWindow):
@asyncSlot() @asyncSlot()
async def bail(self): async def bail(self):
await self._on_connection_changed(False) await self._on_connection_changed(False)
await self.client.end_session() await self.thermostat.disconnect()
@asyncSlot(object, object) @asyncSlot(object, object)
async def send_command(self, param, changes): async def send_command(self, param, changes):
@ -288,7 +298,7 @@ class MainWindow(QtWidgets.QMainWindow):
else: else:
set_param_args = (*thermostat_param, data) set_param_args = (*thermostat_param, data)
param.child(*param.childPath(inner_param)).setOpts(lock=True) param.child(*param.childPath(inner_param)).setOpts(lock=True)
await self.client.set_param(*set_param_args) await self.thermostat.set_param(*set_param_args)
param.child(*param.childPath(inner_param)).setOpts(lock=False) param.child(*param.childPath(inner_param)).setOpts(lock=False)
if inner_param.opts.get("pid_autotune", None) is not None: if inner_param.opts.get("pid_autotune", None) is not None:
@ -312,7 +322,7 @@ class MainWindow(QtWidgets.QMainWindow):
thermostat_param[1] = ch thermostat_param[1] = ch
inner_param.setOpts(lock=True) inner_param.setOpts(lock=True)
await self.client.set_param(*thermostat_param) await self.thermostat.set_param(*thermostat_param)
inner_param.setOpts(lock=False) inner_param.setOpts(lock=False)
@asyncSlot() @asyncSlot()
@ -376,24 +386,24 @@ class MainWindow(QtWidgets.QMainWindow):
@asyncSlot(int) @asyncSlot(int)
async def fan_set_request(self, value): async def fan_set_request(self, value):
assert self.client.connected() assert self.thermostat.connected()
if self.thermostat_ctrl_menu.fan_auto_box.isChecked(): if self.thermostat_ctrl_menu.fan_auto_box.isChecked():
with QSignalBlocker(self.thermostat_ctrl_menu.fan_auto_box): with QSignalBlocker(self.thermostat_ctrl_menu.fan_auto_box):
self.thermostat_ctrl_menu.fan_auto_box.setChecked(False) self.thermostat_ctrl_menu.fan_auto_box.setChecked(False)
await self.client.set_fan(value) await self.thermostat.set_fan(value)
if not self.hw_rev_data["settings"]["fan_pwm_recommended"]: if not self.hw_rev_data["settings"]["fan_pwm_recommended"]:
self.thermostat_ctrl_menu.set_fan_pwm_warning() self.thermostat_ctrl_menu.set_fan_pwm_warning()
@asyncSlot(int) @asyncSlot(int)
async def fan_auto_set_request(self, enabled): async def fan_auto_set_request(self, enabled):
assert self.client.connected() assert self.thermostat.connected()
if enabled: if enabled:
await self.client.set_fan("auto") await self.thermostat.set_fan("auto")
self.fan_update(await self.client.get_fan()) self.fan_update(await self.thermostat.get_fan())
else: else:
await self.client.set_fan( await self.thermostat.set_fan(
self.thermostat_ctrl_menu.fan_power_slider.value() self.thermostat_ctrl_menu.fan_power_slider.value()
) )
@ -439,7 +449,7 @@ class MainWindow(QtWidgets.QMainWindow):
assert self.thermostat.connected() assert self.thermostat.connected()
await self.thermostat.set_ipv4(ipv4_settings) await self.thermostat.set_ipv4(ipv4_settings)
await self.thermostat._client.end_session() await self.thermostat.end_session()
await self._on_connection_changed(False) await self._on_connection_changed(False)