diff --git a/pytec/examples/aioexample.py b/pytec/examples/aioexample.py new file mode 100644 index 0000000..f9da4af --- /dev/null +++ b/pytec/examples/aioexample.py @@ -0,0 +1,36 @@ +import asyncio +from contextlib import suppress +from pytec.aioclient import AsyncioClient + + +async def poll_for_info(tec): + while True: + print(tec.get_output()) + print(tec.get_b_parameter()) + print(tec.get_pid()) + print(tec.get_postfilter()) + print(tec.get_fan()) + + await asyncio.sleep(1) + + +async def main(): + tec = AsyncioClient() + await tec.connect() # (host="192.168.1.26", port=23) + await tec.set_param("b-p", 1, "t0", 20) + print(await tec.get_output()) + print(await tec.get_pid()) + print(await tec.get_output()) + print(await tec.get_postfilter()) + print(await tec.get_b_parameter()) + + polling_task = asyncio.create_task(poll_for_info(tec)) + + async for data in tec.report_mode(): + print(data) + + polling_task.cancel() + with suppress(asyncio.CancelledError): + await polling_task + +asyncio.run(main()) diff --git a/pytec/example.py b/pytec/examples/example.py similarity index 100% rename from pytec/example.py rename to pytec/examples/example.py diff --git a/pytec/pytec/aioclient.py b/pytec/pytec/aioclient.py new file mode 100644 index 0000000..44ba40d --- /dev/null +++ b/pytec/pytec/aioclient.py @@ -0,0 +1,240 @@ +import asyncio +import json +import logging + + +class CommandError(Exception): + pass + + +class AsyncioClient: + def __init__(self): + self._reader = None + self._writer = None + self._command_lock = asyncio.Lock() + + async def connect(self, host="192.168.1.26", port=23): + """Connect to Thermostat at specified host and port. + + Example:: + client = AsyncioClient() + await client.connect() + """ + self._reader, self._writer = await asyncio.open_connection(host, port) + await self._check_zero_limits() + + def connected(self): + """Returns True if client is connected""" + return self._writer is not None + + async def disconnect(self): + """Disconnect from the Thermostat""" + + if self._writer is None: + return + + # Reader needn't be closed + self._writer.close() + await self._writer.wait_closed() + self._reader = None + self._writer = None + + async def _check_zero_limits(self): + output_report = await self.get_output() + for output_channel in output_report: + for limit in ["max_i_neg", "max_i_pos", "max_v"]: + if output_channel[limit] == 0.0: + logging.warning( + "`{}` limit is set to zero on channel {}".format( + limit, output_channel["channel"] + ) + ) + + async def _read_line(self): + # read 1 line + chunk = await self._reader.readline() + return chunk.decode("utf-8", errors="ignore") + + async def _read_write(self, command): + self._writer.write(((" ".join(command)).strip() + "\n").encode("utf-8")) + await self._writer.drain() + + return await self._read_line() + + async def _command(self, *command): + async with self._command_lock: + line = await self._read_write(command) + + response = json.loads(line) + if "error" in response: + raise CommandError(response["error"]) + return response + + async def _get_conf(self, topic): + result = [None, None] + for item in await self._command(topic): + result[int(item["channel"])] = item + return result + + async def get_output(self): + """Retrieve output limits for the TEC + + Example:: + [{'channel': 0, + 'center': 'vref', + 'i_set': -0.02002179650216762, + 'max_i_neg': 2.0, + 'max_v': : 3.988, + 'max_i_pos': 2.0, + 'polarity': 'normal'}, + {'channel': 1, + 'center': 'vref', + 'i_set': -0.02002179650216762, + 'max_i_neg': 2.0, + 'max_v': : 3.988, + 'max_i_pos': 2.0, + 'polarity': 'normal'}, + ] + """ + return await self._get_conf("output") + + async def get_pid(self): + """Retrieve PID control state + + Example:: + [{'channel': 0, + 'parameters': { + 'kp': 10.0, + 'ki': 0.02, + 'kd': 0.0, + 'output_min': 0.0, + 'output_max': 3.0}, + 'target': 37.0}, + {'channel': 1, + 'parameters': { + 'kp': 10.0, + 'ki': 0.02, + 'kd': 0.0, + 'output_min': 0.0, + 'output_max': 3.0}, + 'target': 36.5}] + """ + return await self._get_conf("pid") + + async def get_b_parameter(self): + """Retrieve B-Parameter equation parameters for resistance to temperature conversion + + Example:: + [{'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 0}, + {'params': {'b': 3800.0, 'r0': 10000.0, 't0': 298.15}, 'channel': 1}] + """ + return await self._get_conf("b-p") + + async def get_postfilter(self): + """Retrieve DAC postfilter configuration + + Example:: + [{'rate': None, 'channel': 0}, + {'rate': 21.25, 'channel': 1}] + """ + return await self._get_conf("postfilter") + + async def get_fan(self): + """Get Thermostat current fan settings""" + return await self._command("fan") + + async def report(self): + """Obtain one-time report on measurement values + + Example of yielded data: + {'channel': 0, + 'time': 2302524, + 'interval': 0.12 + 'adc': 0.6199188965423515, + 'sens': 6138.519310282602, + 'temperature': 36.87032392655527, + 'pid_engaged': True, + 'i_set': 2.0635816680889123, + 'dac_value': 2.527790834044456, + 'dac_feedback': 2.523, + 'i_tec': 2.331, + 'tec_i': 2.0925, + 'tec_u_meas': 2.5340000000000003, + 'pid_output': 2.067581958092247} + """ + return await self._command("report") + + async def set_param(self, topic, channel, field="", value=""): + """Set configuration parameters + + Examples:: + await tec.set_param("output", 0, "max_v", 2.0) + await tec.set_param("pid", 1, "output_max", 2.5) + await tec.set_param("s-h", 0, "t0", 20.0) + await tec.set_param("center", 0, "vref") + await tec.set_param("postfilter", 1, 21) + + See the firmware's README.md for a full list. + """ + if type(value) is float: + value = "{:f}".format(value) + if type(value) is not str: + value = str(value) + await self._command(topic, str(channel), field, value) + + async def set_fan(self, power="auto"): + """Set fan power""" + await self._command("fan", str(power)) + + async def set_fcurve(self, a=1.0, b=0.0, c=0.0): + """Set fan curve""" + await self._command("fcurve", str(a), str(b), str(c)) + + async def power_up(self, channel, target): + """Start closed-loop mode""" + await self.set_param("pid", channel, "target", value=target) + await self.set_param("output", channel, "pid") + + async def save_config(self, channel=""): + """Save current configuration to EEPROM""" + await self._command("save", str(channel)) + if channel == "": + await self._read_line() # Read the extra {} + + async def load_config(self, channel=""): + """Load current configuration from EEPROM""" + await self._command("load", str(channel)) + if channel == "": + await self._read_line() # Read the extra {} + + async def hw_rev(self): + """Get Thermostat hardware revision""" + return await self._command("hwrev") + + async def reset(self): + """Reset the Thermostat + + The client is disconnected as the TCP session is terminated. + """ + async with self._command_lock: + self._writer.write("reset\n".encode("utf-8")) + await self._writer.drain() + + await self.disconnect() + + async def dfu(self): + """Put the Thermostat in DFU mode + + The client is disconnected as the Thermostat stops responding to + TCP commands in DFU mode. To exit it, submit a DFU leave request + or power-cycle the Thermostat. + """ + async with self._command_lock: + self._writer.write("dfu\n".encode("utf-8")) + await self._writer.drain() + + await self.disconnect() + + async def ipv4(self): + """Get the IPv4 settings of the Thermostat""" + return await self._command("ipv4")