From 273ce8cd026290cbfafa6dda600374b72f8f3212 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sun, 15 Sep 2024 21:17:15 +0200 Subject: [PATCH] Update master (#77) * Add pyproject.toml * Add OpenThermMessageType class * Add OpenThermDataSource class * Move tox.ini to pyproject.toml * Add OpenThermCommand class to replace OTGW_CMD_* vars * Remove end-of-file-fixer and isort from pre-commit, ruff handles that * Add OpenThermMessageID type * Move ruff to the end of pre-commit order * Add OpenThermGatewayOpMode class * Split restart_gateway() from set_mode() * Update README.md * Update CHANGELOG.md with a `master` section * Add more type enums * Add reports.py to be used for converting report responses to status dict updates * Add OpenThermGateway.get_report() * Add tests for OpenThermGateway.get_report() * Rewrite gpio poll task to general poll tasks * Implement poll_gpio as generic poll task, update tests * Add ruff linter options to pyproject.toml * Make OpenThermPollTask usable for different task types, remove specific GPIO state poll task class * Let pyotgw.get_report() update status rather than updating it from the poll task * Do not wait for stopped tasks to stop in cleanup(), task.stop() takes care of that. * Add test for poll_tasks.py * Update and fix tests for pyotgw.py * Update CHANGELOG.md --- .pre-commit-config.yaml | 18 +- CHANGELOG.md | 22 ++ README.md | 14 +- pyotgw/commandprocessor.py | 15 +- pyotgw/connection.py | 50 +++-- pyotgw/messageprocessor.py | 48 +++-- pyotgw/messages.py | 123 +++++------ pyotgw/poll_task.py | 83 ++++++++ pyotgw/protocol.py | 8 +- pyotgw/pyotgw.py | 268 ++++++++++++++---------- pyotgw/reports.py | 160 ++++++++++++++ pyotgw/status.py | 21 +- pyotgw/types.py | 261 +++++++++++++++++++++++ pyotgw/vars.py | 122 +---------- pyproject.toml | 46 ++++ tests/data.py | 120 +++++++---- tests/helpers.py | 50 +++++ tests/test_commandprocessor.py | 47 +++-- tests/test_connection.py | 20 +- tests/test_messageprocessor.py | 84 ++++---- tests/test_messages.py | 2 +- tests/test_poll_task.py | 93 +++++++++ tests/test_pyotgw.py | 371 +++++++++++++++++---------------- tests/test_reports.py | 194 +++++++++++++++++ tests/test_status.py | 88 ++++---- tox.ini | 37 ---- 26 files changed, 1648 insertions(+), 717 deletions(-) create mode 100644 pyotgw/poll_task.py create mode 100644 pyotgw/reports.py create mode 100644 pyotgw/types.py create mode 100644 pyproject.toml create mode 100644 tests/test_poll_task.py create mode 100644 tests/test_reports.py delete mode 100644 tox.ini diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e72e746..c76b9fb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,27 +3,15 @@ repos: rev: v3.4.0 hooks: - id: trailing-whitespace - - id: end-of-file-fixer - id: check-docstring-first - id: check-yaml - id: debug-statements - id: check-ast -- repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.4 - hooks: - - id: ruff - args: [ --fix ] - - id: ruff-format - repo: https://github.com/asottile/pyupgrade rev: v3.16.0 hooks: - id: pyupgrade args: ['--py311-plus'] -- repo: https://github.com/pycqa/isort - rev: 5.13.2 - hooks: - - id: isort - name: isort (python) - repo: https://github.com/PyCQA/bandit rev: 1.7.9 hooks: @@ -32,3 +20,9 @@ repos: - --quiet - --recursive files: ^pyotgw/.+\.py +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.4 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format diff --git a/CHANGELOG.md b/CHANGELOG.md index 0805156..73f7551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ### master - Raise minimum python version to 3.11 +- Add pyproject.toml +- Move tox.ini to pyproject.toml +- Add OpenThermMessageType class +- Add OpenThermDataSource class +- Add OpenThermCommand class, replace OTGW_CMD_* vars +- Update and clean up pre-commit config +- Add OpenThermMessageID class, replace MSG_* vars +- Add OpenThermGatewayOpMode class +- Add OpenThermGPIOMode class +- Add OpenThermHotWaterOverrideMode class +- Add OpenThermLEDMode class +- Add OpenThermReport class +- Add OpenThermResetCause class +- Add OpenThermSetpointOverrideMode class +- Add OpenThermSmartPowerMode class +- Add OpenThermTemperatureSensorFunction class +- Add OpenThermThermostatDetection class +- Add OpenThermVoltageReferenceLevel class +- Make OpenThermGateway.restart_gateway() a separate method from OpenThermGateway.set_mode() +- Add reports.py to report report responses to status dict updates +- Add OpenThermGateway.get_report() +- Rewrite gpio poll task to generic task poller ### 2.2.0 - Split status line processing into functions (#65) diff --git a/README.md b/README.md index 50e6cda..300f4c8 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,14 @@ Returns the full updated status dict. This method is a coroutine. +--- +##### OpenThermGateway.restart_gateway(_self_, timeout=OTGW_DEFAULT_TIMEOUT) +Restart the OpenTherm Gateway. + +Return the status dict after re-initialization. + +This method is a coroutine. + --- ##### OpenThermGateway.set_ch_enable_bit(_self_, ch_bit, timeout=OTGW_DEFAULT_TIMEOUT) Set or unset the `Central Heating Enable` bit. @@ -288,12 +296,12 @@ This method is a coroutine. --- ##### OpenThermGateway.set_mode(_self_, mode, timeout=OTGW_DEFAULT_TIMEOUT) Set the operating mode of the gateway. -The operating mode can be either `gateway` or `monitor` mode. This method can also be used to reset the OpenTherm Gateway. +The operating mode can be either `gateway` or `monitor` mode. This method supports the following arguments: -- __mode__ The mode to be set on the gateway. Can be `0` or `OTGW_MODE_MONITOR` for `monitor` mode, `1` or `OTGW_MODE_GATEWAY` for `gateway mode, or `OTGW_MODE_RESET` to reset the gateway. +- __mode__ The mode to be set on the gateway. One of OpenThermGatewayOpMode.GATEWAY or OpenThermGatewayOpMode.MONITOR. - __timeout__ The timeout for the request. Defaults to OTGW_DEFAULT_TIMEOUT (3 seconds). -Return the newly activated mode, or the full renewed status dict after a reset. +Return the newly activated mode. This method is a coroutine. diff --git a/pyotgw/commandprocessor.py b/pyotgw/commandprocessor.py index 79eb9a7..96cc890 100644 --- a/pyotgw/commandprocessor.py +++ b/pyotgw/commandprocessor.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING from . import vars as v +from .types import OpenThermCommand if TYPE_CHECKING: from .status import StatusManager @@ -31,7 +32,7 @@ def __init__( self.status_manager = status_manager async def issue_cmd( - self, cmd: str, value: str | float | int, retry: int = 3 + self, cmd: OpenThermCommand, value: str | float | int, retry: int = 3 ) -> bool | str | list[str] | None: """ Issue a command, then await and return the return value. @@ -65,7 +66,7 @@ async def process(msg): raise v.OTGW_ERRS[msg] await send_again(msg) return - if cmd == v.OTGW_CMD_MODE and value == "R": + if cmd == OpenThermCommand.MODE and value == "R": # Device was reset, msg contains build info while not re.match(r"OpenTherm Gateway \d+(\.\d+)*", msg): msg = await self._cmdq.get() @@ -79,7 +80,7 @@ async def process(msg): await send_again(msg) return ret = match.group(1) - if cmd == v.OTGW_CMD_SUMMARY and ret == "1": + if cmd == OpenThermCommand.SUMMARY and ret == "1": # Expects a second line part2 = await self._cmdq.get() ret = [ret, part2] @@ -118,17 +119,17 @@ def submit_response(self, response: str) -> None: _LOGGER.error("Queue full, discarded message: %s", response) @staticmethod - def _get_expected_response(cmd: str, value: str | int) -> str: + def _get_expected_response(cmd: OpenThermCommand, value: str | int) -> str: """Return the expected response pattern""" - if cmd == v.OTGW_CMD_REPORT: + if cmd == OpenThermCommand.REPORT: return rf"^{cmd}:\s*([A-Z]{{2}}|{value}=[^$]+)$" # OTGW_CMD_CONTROL_HEATING_2 and OTGW_CMD_CONTROL_SETPOINT_2 do not adhere # to the standard response format (: ) at the moment, but report # only the value. This will likely be fixed in the future, so we support # both formats. if cmd in ( - v.OTGW_CMD_CONTROL_HEATING_2, - v.OTGW_CMD_CONTROL_SETPOINT_2, + OpenThermCommand.CONTROL_HEATING_2, + OpenThermCommand.CONTROL_SETPOINT_2, ): return rf"^(?:{cmd}:\s*)?(0|1|[0-9]+\.[0-9]{{2}}|[A-Z]{{2}})$" return rf"^{cmd}:\s*([^$]+)$" diff --git a/pyotgw/connection.py b/pyotgw/connection.py index 1514264..4358314 100644 --- a/pyotgw/connection.py +++ b/pyotgw/connection.py @@ -8,9 +8,10 @@ import asyncio import logging +from collections.abc import Awaitable, Callable from dataclasses import dataclass from functools import partial -from typing import Awaitable, Callable, Literal, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Literal import serial import serial_asyncio_fast @@ -34,24 +35,31 @@ class ConnectionConfig: """Config for the serial connection.""" - baudrate: Optional[int] - bytesize: Optional[ - Literal[serial.FIVEBITS, serial.SIXBITS, serial.SEVENBITS, serial.EIGHTBITS] - ] - parity: Optional[ - Literal[ - serial.PARITY_NONE, - serial.PARITY_EVEN, - serial.PARITY_ODD, - serial.PARITY_MARK, - serial.PARITY_SPACE, - ] - ] - stopbits: Optional[ - Literal[ - serial.STOPBITS_ONE, serial.STOPBITS_ONE_POINT_FIVE, serial.STOPBITS_TWO - ] - ] + baudrate: int | None + bytesize: ( + None + | (Literal[serial.FIVEBITS, serial.SIXBITS, serial.SEVENBITS, serial.EIGHTBITS]) + ) + parity: ( + None + | ( + Literal[ + serial.PARITY_NONE, + serial.PARITY_EVEN, + serial.PARITY_ODD, + serial.PARITY_MARK, + serial.PARITY_SPACE, + ] + ) + ) + stopbits: ( + None + | ( + Literal[ + serial.STOPBITS_ONE, serial.STOPBITS_ONE_POINT_FIVE, serial.STOPBITS_TWO + ] + ) + ) class ConnectionManager: # pylint: disable=too-many-instance-attributes @@ -247,7 +255,9 @@ async def inform(self) -> None: self._wd_task = self.loop.create_task(self._watchdog(self.timeout)) _LOGGER.debug("Watchdog timer reset!") - def start(self, callback: Callable[[], Awaitable[None]], timeout: asyncio.Timeout) -> bool: + def start( + self, callback: Callable[[], Awaitable[None]], timeout: asyncio.Timeout + ) -> bool: """Start the watchdog, return boolean indicating success""" if self.is_active: return False diff --git a/pyotgw/messageprocessor.py b/pyotgw/messageprocessor.py index 324c369..ef511c3 100644 --- a/pyotgw/messageprocessor.py +++ b/pyotgw/messageprocessor.py @@ -9,6 +9,12 @@ from . import messages as m from . import vars as v +from .types import ( + OpenThermCommand, + OpenThermDataSource, + OpenThermMessageID, + OpenThermMessageType, +) if TYPE_CHECKING: from .commandprocessor import CommandProcessor @@ -63,7 +69,10 @@ def submit_matched_message(self, match: re.match) -> None: def _dissect_msg( self, match: re.match - ) -> tuple[str, int, bytes, bytes, bytes] | tuple[None, None, None, None, None]: + ) -> ( + tuple[str, OpenThermMessageType, OpenThermMessageID, bytes, bytes] + | tuple[None, None, None, None, None] + ): """ Split messages into bytes and return a tuple of bytes. """ @@ -77,7 +86,12 @@ def _dissect_msg( ) return None, None, None, None, None msgtype = self._get_msgtype(frame[0]) - if msgtype in (v.READ_ACK, v.WRITE_ACK, v.READ_DATA, v.WRITE_DATA): + if msgtype in ( + OpenThermMessageType.READ_ACK, + OpenThermMessageType.WRITE_ACK, + OpenThermMessageType.READ_DATA, + OpenThermMessageType.WRITE_DATA, + ): # Some info is best read from the READ/WRITE_DATA messages # as the boiler may not support the data ID. # Slice syntax is used to prevent implicit cast to int. @@ -88,12 +102,12 @@ def _dissect_msg( return None, None, None, None, None @staticmethod - def _get_msgtype(byte: bytes) -> int: + def _get_msgtype(byte: bytes) -> OpenThermMessageType: """ Return the message type of Opentherm messages according to byte. """ - return (byte >> 4) & 0x7 + return OpenThermMessageType((byte >> 4) & 0x7) async def _process_msgs(self) -> None: """ @@ -110,7 +124,10 @@ async def _process_msgs(self) -> None: ) await self._process_msg(args) - async def _process_msg(self, message: tuple[str, int, bytes, bytes, bytes]) -> None: + async def _process_msg( + self, + message: tuple[str, OpenThermMessageType, OpenThermMessageID, bytes, bytes], + ) -> None: """ Process message and update status variables where necessary. Add status to queue if it was changed in the process. @@ -126,9 +143,9 @@ async def _process_msg(self, message: tuple[str, int, bytes, bytes, bytes]) -> N return if src in "TA": - part = v.THERMOSTAT + part = OpenThermDataSource.THERMOSTAT else: # src in "BR" - part = v.BOILER + part = OpenThermDataSource.BOILER update = {} for action in m.REGISTRY[msgid][m.MSG_TYPE[mtype]]: @@ -159,7 +176,9 @@ async def _get_dict_update_for_action(self, action: dict, env: dict) -> dict: update.update({var: val}) return update - async def _quirk_trovrd(self, part: str, src: str, msb: bytes, lsb: bytes) -> None: + async def _quirk_trovrd( + self, part: OpenThermDataSource, src: str, msb: bytes, lsb: bytes + ) -> None: """Handle MSG_TROVRD with iSense quirk""" update = {} ovrd_value = self._get_f8_8(msb, lsb) @@ -167,11 +186,14 @@ async def _quirk_trovrd(self, part: str, src: str, msb: bytes, lsb: bytes) -> No # iSense quirk: the gateway keeps sending override value # even if the thermostat has cancelled the override. if ( - self.status_manager.status[v.OTGW].get(v.OTGW_THRM_DETECT) == "I" + self.status_manager.status[OpenThermDataSource.GATEWAY].get( + v.OTGW_THRM_DETECT + ) + == "I" and src == "A" ): ovrd = await self.command_processor.issue_cmd( - v.OTGW_CMD_REPORT, v.OTGW_REPORT_SETPOINT_OVRD + OpenThermCommand.REPORT, v.OTGW_REPORT_SETPOINT_OVRD ) match = re.match(r"^O=(N|[CT]([0-9]+.[0-9]+))$", ovrd, re.IGNORECASE) if not match: @@ -186,11 +208,13 @@ async def _quirk_trovrd(self, part: str, src: str, msb: bytes, lsb: bytes) -> No else: self.status_manager.delete_value(part, v.DATA_ROOM_SETPOINT_OVRD) - async def _quirk_trset_s2m(self, part: str, msb: bytes, lsb: bytes) -> None: + async def _quirk_trset_s2m( + self, part: OpenThermDataSource, msb: bytes, lsb: bytes + ) -> None: """Handle MSG_TRSET with gateway quirk""" # Ignore s2m messages on thermostat side as they are ALWAYS WriteAcks # but may contain invalid data. - if part == v.THERMOSTAT: + if part == OpenThermDataSource.THERMOSTAT: return self.status_manager.submit_partial_update( diff --git a/pyotgw/messages.py b/pyotgw/messages.py index 554aac5..8947b6b 100644 --- a/pyotgw/messages.py +++ b/pyotgw/messages.py @@ -1,6 +1,7 @@ """Data related to message processing""" from . import vars as v +from .types import OpenThermMessageID, OpenThermMessageType _GET_FLAG8 = "_get_flag8" _GET_FLOAT = "_get_f8_8" @@ -18,10 +19,10 @@ S2M = "s2m" MSG_TYPE = { - v.READ_DATA: M2S, - v.WRITE_DATA: M2S, - v.READ_ACK: S2M, - v.WRITE_ACK: S2M, + OpenThermMessageType.READ_DATA: M2S, + OpenThermMessageType.WRITE_DATA: M2S, + OpenThermMessageType.READ_ACK: S2M, + OpenThermMessageType.WRITE_ACK: S2M, } REGISTRY = { @@ -34,7 +35,7 @@ # }, # ], # } - v.MSG_STATUS: { + OpenThermMessageID.STATUS: { M2S: [ { FUNC: _GET_FLAG8, @@ -64,7 +65,7 @@ }, ], }, - v.MSG_TSET: { + OpenThermMessageID.TSET: { M2S: [], S2M: [ { @@ -77,11 +78,11 @@ }, ], }, - v.MSG_MCONFIG: { + OpenThermMessageID.MCONFIG: { M2S: [{FUNC: _GET_U8, ARGS: (_LSB,), RETURNS: (v.DATA_MASTER_MEMBERID,)}], S2M: [], }, - v.MSG_SCONFIG: { + OpenThermMessageID.SCONFIG: { M2S: [], S2M: [ { @@ -99,8 +100,8 @@ {FUNC: _GET_U8, ARGS: (_LSB,), RETURNS: (v.DATA_SLAVE_MEMBERID,)}, ], }, - v.MSG_COMMAND: {M2S: [], S2M: []}, - v.MSG_ASFFLAGS: { + OpenThermMessageID.COMMAND: {M2S: [], S2M: []}, + OpenThermMessageID.ASFFLAGS: { M2S: [], S2M: [ { @@ -118,7 +119,7 @@ {FUNC: _GET_U8, ARGS: (_LSB,), RETURNS: (v.DATA_SLAVE_OEM_FAULT,)}, ], }, - v.MSG_RBPFLAGS: { + OpenThermMessageID.RBPFLAGS: { M2S: [], S2M: [ { @@ -139,7 +140,7 @@ }, ], }, - v.MSG_COOLING: { + OpenThermMessageID.COOLING: { M2S: [], S2M: [ { @@ -152,7 +153,7 @@ }, ], }, - v.MSG_TSETC2: { + OpenThermMessageID.TSETC2: { M2S: [], S2M: [ { @@ -165,7 +166,7 @@ }, ], }, - v.MSG_TROVRD: { + OpenThermMessageID.TROVRD: { M2S: [], S2M: [ { @@ -180,11 +181,11 @@ }, ], }, - v.MSG_TSP: {M2S: [], S2M: []}, - v.MSG_TSPIDX: {M2S: [], S2M: []}, - v.MSG_FHBSIZE: {M2S: [], S2M: []}, - v.MSG_FHBIDX: {M2S: [], S2M: []}, - v.MSG_MAXRMOD: { + OpenThermMessageID.TSP: {M2S: [], S2M: []}, + OpenThermMessageID.TSPIDX: {M2S: [], S2M: []}, + OpenThermMessageID.FHBSIZE: {M2S: [], S2M: []}, + OpenThermMessageID.FHBIDX: {M2S: [], S2M: []}, + OpenThermMessageID.MAXRMOD: { M2S: [], S2M: [ { @@ -197,14 +198,14 @@ }, ], }, - v.MSG_MAXCAPMINMOD: { + OpenThermMessageID.MAXCAPMINMOD: { M2S: [], S2M: [ {FUNC: _GET_U8, ARGS: (_MSB,), RETURNS: (v.DATA_SLAVE_MAX_CAPACITY,)}, {FUNC: _GET_U8, ARGS: (_LSB,), RETURNS: (v.DATA_SLAVE_MIN_MOD_LEVEL,)}, ], }, - v.MSG_TRSET: { + OpenThermMessageID.TRSET: { M2S: [ { FUNC: _GET_FLOAT, @@ -227,7 +228,7 @@ }, ], }, - v.MSG_RELMOD: { + OpenThermMessageID.RELMOD: { M2S: [], S2M: [ { @@ -240,7 +241,7 @@ }, ], }, - v.MSG_CHPRESS: { + OpenThermMessageID.CHPRESS: { M2S: [], S2M: [ { @@ -253,7 +254,7 @@ }, ], }, - v.MSG_DHWFLOW: { + OpenThermMessageID.DHWFLOW: { M2S: [], S2M: [ { @@ -266,10 +267,10 @@ }, ], }, - v.MSG_TIME: {M2S: [], S2M: []}, - v.MSG_DATE: {M2S: [], S2M: []}, - v.MSG_YEAR: {M2S: [], S2M: []}, - v.MSG_TRSET2: { + OpenThermMessageID.TIME: {M2S: [], S2M: []}, + OpenThermMessageID.DATE: {M2S: [], S2M: []}, + OpenThermMessageID.YEAR: {M2S: [], S2M: []}, + OpenThermMessageID.TRSET2: { M2S: [ { FUNC: _GET_FLOAT, @@ -282,7 +283,7 @@ ], S2M: [], }, - v.MSG_TROOM: { + OpenThermMessageID.TROOM: { M2S: [ { FUNC: _GET_FLOAT, @@ -295,7 +296,7 @@ ], S2M: [], }, - v.MSG_TBOILER: { + OpenThermMessageID.TBOILER: { M2S: [], S2M: [ { @@ -308,7 +309,7 @@ }, ], }, - v.MSG_TDHW: { + OpenThermMessageID.TDHW: { M2S: [], S2M: [ { @@ -321,7 +322,7 @@ } ], }, - v.MSG_TOUTSIDE: { + OpenThermMessageID.TOUTSIDE: { M2S: [], S2M: [ { @@ -334,7 +335,7 @@ }, ], }, - v.MSG_TRET: { + OpenThermMessageID.TRET: { M2S: [], S2M: [ { @@ -347,7 +348,7 @@ }, ], }, - v.MSG_TSTOR: { + OpenThermMessageID.TSTOR: { M2S: [], S2M: [ { @@ -360,7 +361,7 @@ }, ], }, - v.MSG_TCOLL: { + OpenThermMessageID.TCOLL: { M2S: [], S2M: [ { @@ -373,7 +374,7 @@ }, ], }, - v.MSG_TFLOWCH2: { + OpenThermMessageID.TFLOWCH2: { M2S: [], S2M: [ { @@ -386,7 +387,7 @@ }, ], }, - v.MSG_TDHW2: { + OpenThermMessageID.TDHW2: { M2S: [], S2M: [ { @@ -399,7 +400,7 @@ } ], }, - v.MSG_TEXHAUST: { + OpenThermMessageID.TEXHAUST: { M2S: [], S2M: [ { @@ -412,22 +413,22 @@ } ], }, - v.MSG_TDHWSETUL: { + OpenThermMessageID.TDHWSETUL: { M2S: [], S2M: [ {FUNC: _GET_S8, ARGS: (_MSB,), RETURNS: (v.DATA_SLAVE_DHW_MAX_SETP,)}, {FUNC: _GET_S8, ARGS: (_LSB,), RETURNS: (v.DATA_SLAVE_DHW_MIN_SETP,)}, ], }, - v.MSG_TCHSETUL: { + OpenThermMessageID.TCHSETUL: { M2S: [], S2M: [ {FUNC: _GET_S8, ARGS: (_MSB,), RETURNS: (v.DATA_SLAVE_CH_MAX_SETP,)}, {FUNC: _GET_S8, ARGS: (_LSB,), RETURNS: (v.DATA_SLAVE_CH_MIN_SETP,)}, ], }, - v.MSG_OTCCURVEUL: {M2S: [], S2M: []}, - v.MSG_TDHWSET: { + OpenThermMessageID.OTCCURVEUL: {M2S: [], S2M: []}, + OpenThermMessageID.TDHWSET: { M2S: [], S2M: [ { @@ -440,7 +441,7 @@ }, ], }, - v.MSG_MAXTSET: { + OpenThermMessageID.MAXTSET: { M2S: [], S2M: [ { @@ -453,8 +454,8 @@ }, ], }, - v.MSG_OTCCURVE: {M2S: [], S2M: []}, - v.MSG_STATUSVH: { + OpenThermMessageID.OTCCURVE: {M2S: [], S2M: []}, + OpenThermMessageID.STATUSVH: { M2S: [ { FUNC: _GET_FLAG8, @@ -483,15 +484,15 @@ }, ], }, - v.MSG_RELVENTPOS: { + OpenThermMessageID.RELVENTPOS: { M2S: [{FUNC: _GET_U8, ARGS: (_MSB,), RETURNS: (v.DATA_VH_CONTROL_SETPOINT,)}], S2M: [], }, - v.MSG_RELVENT: { + OpenThermMessageID.RELVENT: { M2S: [], S2M: [{FUNC: _GET_U8, ARGS: (_MSB,), RETURNS: (v.DATA_VH_RELATIVE_VENT,)}], }, - v.MSG_ROVRD: { + OpenThermMessageID.ROVRD: { M2S: [], S2M: [ { @@ -504,7 +505,7 @@ }, ], }, - v.MSG_OEMDIAG: { + OpenThermMessageID.OEMDIAG: { M2S: [], S2M: [ { @@ -517,7 +518,7 @@ } ], }, - v.MSG_BURNSTARTS: { + OpenThermMessageID.BURNSTARTS: { M2S: [], S2M: [ { @@ -530,7 +531,7 @@ }, ], }, - v.MSG_CHPUMPSTARTS: { + OpenThermMessageID.CHPUMPSTARTS: { M2S: [], S2M: [ { @@ -543,7 +544,7 @@ }, ], }, - v.MSG_DHWPUMPSTARTS: { + OpenThermMessageID.DHWPUMPSTARTS: { M2S: [], S2M: [ { @@ -556,7 +557,7 @@ }, ], }, - v.MSG_DHWBURNSTARTS: { + OpenThermMessageID.DHWBURNSTARTS: { M2S: [], S2M: [ { @@ -569,7 +570,7 @@ }, ], }, - v.MSG_BURNHRS: { + OpenThermMessageID.BURNHRS: { M2S: [], S2M: [ { @@ -582,7 +583,7 @@ }, ], }, - v.MSG_CHPUMPHRS: { + OpenThermMessageID.CHPUMPHRS: { M2S: [], S2M: [ { @@ -595,7 +596,7 @@ } ], }, - v.MSG_DHWPUMPHRS: { + OpenThermMessageID.DHWPUMPHRS: { M2S: [], S2M: [ { @@ -608,7 +609,7 @@ }, ], }, - v.MSG_DHWBURNHRS: { + OpenThermMessageID.DHWBURNHRS: { M2S: [], S2M: [ { @@ -621,7 +622,7 @@ }, ], }, - v.MSG_OTVERM: { + OpenThermMessageID.OTVERM: { M2S: [ { FUNC: _GET_FLOAT, @@ -634,7 +635,7 @@ ], S2M: [], }, - v.MSG_OTVERS: { + OpenThermMessageID.OTVERS: { M2S: [], S2M: [ { @@ -647,14 +648,14 @@ }, ], }, - v.MSG_MVER: { + OpenThermMessageID.MVER: { M2S: [ {FUNC: _GET_U8, ARGS: (_MSB,), RETURNS: (v.DATA_MASTER_PRODUCT_TYPE,)}, {FUNC: _GET_U8, ARGS: (_LSB,), RETURNS: (v.DATA_MASTER_PRODUCT_VERSION,)}, ], S2M: [], }, - v.MSG_SVER: { + OpenThermMessageID.SVER: { M2S: [], S2M: [ {FUNC: _GET_U8, ARGS: (_MSB,), RETURNS: (v.DATA_SLAVE_PRODUCT_TYPE,)}, diff --git a/pyotgw/poll_task.py b/pyotgw/poll_task.py new file mode 100644 index 0000000..4bcf7f4 --- /dev/null +++ b/pyotgw/poll_task.py @@ -0,0 +1,83 @@ +"""Describes a task that polls a specific value.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .pyotgw import OpenThermGateway +from .types import OpenThermDataSource, OpenThermReport + +_LOGGER = logging.getLogger(__name__) + + +class OpenThermPollTask: + """ + Describes a task that polls the gateway for certain reports. + Some states aren't being pushed by the gateway, we need to poll + the report method if we want updates. + """ + + _task: asyncio.Task | None = None + + def __init__( + self, + name: str, + gateway: OpenThermGateway, + report_type: OpenThermReport, + default_values: dict[OpenThermDataSource, dict], + run_condition: Callable[[], bool], + interval: float = 10, + ) -> None: + """Initialize the object.""" + self._gateway = gateway + self._interval = interval + self._report_type = report_type + self._run_condition = run_condition + self.default_values = default_values + self.name = name + + def start(self) -> None: + """Start polling if necessary.""" + if self.should_run: + self._task = asyncio.get_running_loop().create_task(self._polling_routine()) + + async def stop(self) -> None: + """Stop polling if we are active.""" + if self.is_running: + _LOGGER.debug(f"Stopping {self.name} polling routine") + self._task.cancel() + + try: + await self._task + except asyncio.CancelledError: + self._gateway.status.submit_full_update(self.default_values) + _LOGGER.debug(f"{self.name} polling routine stopped") + self._task = None + + async def start_or_stop_as_needed(self) -> None: + """Start or stop the task as needed.""" + if self.should_run and not self.is_running: + self.start() + elif self.is_running and not self.should_run: + await self.stop() + + @property + def should_run(self) -> bool: + """Return whether or not we should be actively polling.""" + return self._run_condition() + + @property + def is_running(self) -> bool: + """Return whether or not we are actively polling.""" + return self._task is not None + + async def _polling_routine(self) -> None: + """The polling mechanism.""" + _LOGGER.debug(f"{self.name} polling routine started") + while True: + await self._gateway.get_report(self._report_type) + await asyncio.sleep(self._interval) diff --git a/pyotgw/protocol.py b/pyotgw/protocol.py index 74789f5..653b97d 100644 --- a/pyotgw/protocol.py +++ b/pyotgw/protocol.py @@ -5,14 +5,16 @@ import asyncio import logging import re -from typing import Awaitable, Callable, TYPE_CHECKING +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING -from . import vars as v from .commandprocessor import CommandProcessor from .messageprocessor import MessageProcessor +from .types import OpenThermCommand if TYPE_CHECKING: from serial import SerialException + from .status import StatusManager _LOGGER = logging.getLogger(__name__) @@ -136,6 +138,6 @@ def active(self) -> bool: async def init_and_wait_for_activity(self) -> None: """Wait for activity on the serial connection.""" - await self.command_processor.issue_cmd(v.OTGW_CMD_SUMMARY, 0, retry=1) + await self.command_processor.issue_cmd(OpenThermCommand.SUMMARY, 0, retry=1) while not self.active: await asyncio.sleep(0) diff --git a/pyotgw/pyotgw.py b/pyotgw/pyotgw.py index 3a13e25..66760bb 100644 --- a/pyotgw/pyotgw.py +++ b/pyotgw/pyotgw.py @@ -4,12 +4,21 @@ import asyncio import logging +from collections.abc import Awaitable, Callable from datetime import datetime -from typing import Awaitable, Callable, Literal, TYPE_CHECKING +from typing import TYPE_CHECKING, Final, Literal from . import vars as v from .connection import ConnectionManager +from .poll_task import OpenThermPollTask +from .reports import convert_report_response_to_status_update from .status import StatusManager +from .types import ( + OpenThermCommand, + OpenThermDataSource, + OpenThermGatewayOpMode, + OpenThermReport, +) if TYPE_CHECKING: from .connection import ConnectionConfig @@ -17,14 +26,40 @@ _LOGGER = logging.getLogger(__name__) +GPIO_POLL_TASK_NAME: Final = "gpio" + + class OpenThermGateway: # pylint: disable=too-many-public-methods """Main OpenThermGateway object abstraction""" def __init__(self) -> None: """Create an OpenThermGateway object.""" self._transport = None + self._poll_tasks = { + GPIO_POLL_TASK_NAME: OpenThermPollTask( + GPIO_POLL_TASK_NAME, + self, + OpenThermReport.GPIO_STATES, + { + OpenThermDataSource.GATEWAY: { + v.OTGW_GPIO_A_STATE: 0, + v.OTGW_GPIO_B_STATE: 0, + }, + }, + ( + lambda: 0 + in ( + self.status.status[OpenThermDataSource.GATEWAY].get( + v.OTGW_GPIO_A + ), + self.status.status[OpenThermDataSource.GATEWAY].get( + v.OTGW_GPIO_B + ), + ) + ), + ) + } self._protocol = None - self._gpio_task = None self._skip_init = False self.status = StatusManager() self.connection = ConnectionManager(self) @@ -33,9 +68,8 @@ async def cleanup(self) -> None: """Clean up tasks.""" await self.connection.disconnect() await self.status.cleanup() - if self._gpio_task: - self._gpio_task.cancel() - await self._gpio_task + for task in self._poll_tasks.values(): + await task.stop() async def connect( self, @@ -62,7 +96,8 @@ async def connect( if not self._skip_init: await self.get_reports() await self.get_status() - await self._poll_gpio() + for task in self._poll_tasks.values(): + await task.start_or_stop_as_needed() return self.status.status async def disconnect(self) -> None: @@ -88,7 +123,11 @@ async def set_target_temp( This method is a coroutine """ - cmd = v.OTGW_CMD_TARGET_TEMP if temporary else v.OTGW_CMD_TARGET_TEMP_CONST + cmd = ( + OpenThermCommand.TARGET_TEMP + if temporary + else OpenThermCommand.TARGET_TEMP_CONST + ) value = f"{temp:2.1f}" ret = await self._wait_for_cmd(cmd, value, timeout) if ret is None: @@ -97,8 +136,10 @@ async def set_target_temp( if 0 <= ret <= 30: if ret == 0: status_update = { - v.OTGW: {v.OTGW_SETP_OVRD_MODE: v.OTGW_SETP_OVRD_DISABLED}, - v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: None}, + OpenThermDataSource.GATEWAY: { + v.OTGW_SETP_OVRD_MODE: v.OTGW_SETP_OVRD_DISABLED + }, + OpenThermDataSource.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: None}, } else: if temporary: @@ -106,8 +147,8 @@ async def set_target_temp( else: ovrd_mode = v.OTGW_SETP_OVRD_PERMANENT status_update = { - v.OTGW: {v.OTGW_SETP_OVRD_MODE: ovrd_mode}, - v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: ret}, + OpenThermDataSource.GATEWAY: {v.OTGW_SETP_OVRD_MODE: ovrd_mode}, + OpenThermDataSource.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: ret}, } self.status.submit_full_update(status_update) return ret @@ -122,14 +163,14 @@ async def set_temp_sensor_function( This method is a coroutine """ - cmd = v.OTGW_CMD_TEMP_SENSOR + cmd = OpenThermCommand.TEMP_SENSOR if func not in "OR": return None ret = await self._wait_for_cmd(cmd, func, timeout) if ret is None: return None status_otgw = {v.OTGW_TEMP_SENSOR: ret} - self.status.submit_partial_update(v.OTGW, status_otgw) + self.status.submit_partial_update(OpenThermDataSource.GATEWAY, status_otgw) return ret async def set_outside_temp( @@ -146,7 +187,7 @@ async def set_outside_temp( This method is a coroutine """ - cmd = v.OTGW_CMD_OUTSIDE_TEMP + cmd = OpenThermCommand.OUTSIDE_TEMP status_thermostat = {} if temp < -40: return None @@ -159,7 +200,9 @@ async def set_outside_temp( else: ret = float(ret) status_thermostat[v.DATA_OUTSIDE_TEMP] = ret - self.status.submit_partial_update(v.THERMOSTAT, status_thermostat) + self.status.submit_partial_update( + OpenThermDataSource.THERMOSTAT, status_thermostat + ) return ret async def set_clock( @@ -177,7 +220,7 @@ async def set_clock( This method is a coroutine """ - cmd = v.OTGW_CMD_SET_CLOCK + cmd = OpenThermCommand.SET_CLOCK value = f"{date.strftime('%H:%M')}/{date.isoweekday()}" return await self._wait_for_cmd(cmd, value, timeout) @@ -188,7 +231,7 @@ async def get_reports(self) -> dict[str, dict]: This method is a coroutine """ - cmd = v.OTGW_CMD_REPORT + cmd = OpenThermCommand.REPORT reports = {} # Get version info first ret = await self._wait_for_cmd(cmd, v.OTGW_REPORT_ABOUT) @@ -261,7 +304,10 @@ async def get_reports(self) -> dict[str, dict]: ) } self.status.submit_full_update( - {v.THERMOSTAT: status_thermostat, v.OTGW: status_otgw} + { + OpenThermDataSource.THERMOSTAT: status_thermostat, + OpenThermDataSource.GATEWAY: status_otgw, + } ) return self.status.status @@ -272,7 +318,7 @@ async def get_status(self) -> dict[str, dict] | None: This method is a coroutine """ - cmd = v.OTGW_CMD_SUMMARY + cmd = OpenThermCommand.SUMMARY ret = await self._wait_for_cmd(cmd, 1) # Return to 'reporting' mode if ret is None: @@ -285,10 +331,47 @@ async def get_status(self) -> dict[str, dict] | None: else: boiler_status, thermostat_status = process_statusfields_v4(fields) self.status.submit_full_update( - {v.BOILER: boiler_status, v.THERMOSTAT: thermostat_status} + { + OpenThermDataSource.BOILER: boiler_status, + OpenThermDataSource.THERMOSTAT: thermostat_status, + } ) return self.status.status + async def get_report( + self, + report_type: OpenThermReport, + timeout: asyncio.Timeout = v.OTGW_DEFAULT_TIMEOUT, + ) -> dict[OpenThermDataSource, dict] | None: + """Get the report, update status dict accordingly. Return updated status dict.""" + ret = await self._wait_for_cmd(OpenThermCommand.REPORT, report_type, timeout) + if ( + ret is None + or (update := convert_report_response_to_status_update(report_type, ret)) + is None + ): + return + self.status.submit_full_update(update) + return self.status.status + + async def restart_gateway( + self, timeout: asyncio.Timeout = v.OTGW_DEFAULT_TIMEOUT + ) -> dict[str, dict] | None: + """ + Restart the OpenTherm Gateway. + Return the full renewed status dict. + + This method is a coroutine + """ + cmd = OpenThermCommand.MODE + ret = await self._wait_for_cmd(cmd, "R", timeout) + if ret is None: + return + self.status.reset() + await self.get_reports() + await self.get_status() + return self.status.status + async def set_hot_water_ovrd( self, state: int | str, timeout: asyncio.Timeout = v.OTGW_DEFAULT_TIMEOUT ) -> int | str | None: @@ -310,7 +393,7 @@ async def set_hot_water_ovrd( This method is a coroutine """ - cmd = v.OTGW_CMD_HOT_WATER + cmd = OpenThermCommand.HOT_WATER status_otgw = {} ret = await self._wait_for_cmd(cmd, state, timeout) if ret is None: @@ -319,35 +402,41 @@ async def set_hot_water_ovrd( ret = int(ret) if ret != "P": status_otgw[v.OTGW_DHW_OVRD] = ret - self.status.submit_partial_update(v.OTGW, status_otgw) + self.status.submit_partial_update(OpenThermDataSource.GATEWAY, status_otgw) return ret async def set_mode( - self, mode: int | str, timeout: asyncio.Timeout = v.OTGW_DEFAULT_TIMEOUT + self, + mode: OpenThermGatewayOpMode, + timeout: asyncio.Timeout = v.OTGW_DEFAULT_TIMEOUT, ) -> int | dict[str, dict] | None: """ Set the operating mode to either "Gateway" mode (:mode: = - OTGW_MODE_GATEWAY or 1) or "Monitor" mode (:mode: = - OTGW_MODE_MONITOR or 0), or use this method to reset the device - (:mode: = OTGW_MODE_RESET). - Return the newly activated mode, or the full renewed status - dict after a reset. + OpenThermGatewayOpMode.GATEWAY) or "Monitor" mode (:mode: = + OpenThermGatewayOpMode.MONITOR). + Return the newly activated mode. This method is a coroutine """ - cmd = v.OTGW_CMD_MODE + cmd = OpenThermCommand.MODE + if mode == OpenThermGatewayOpMode.MONITOR: + value = 0 + elif mode == OpenThermGatewayOpMode.GATEWAY: + value = 1 + else: + return status_otgw = {} - ret = await self._wait_for_cmd(cmd, mode, timeout) - if ret is None: + ret = await self._wait_for_cmd(cmd, value, timeout) + + if ret == 0: + new_mode = OpenThermGatewayOpMode.MONITOR + elif ret == 1: + new_mode = OpenThermGatewayOpMode.GATEWAY + else: return - if mode is v.OTGW_MODE_RESET: - self.status.reset() - await self.get_reports() - await self.get_status() - return self.status.status - status_otgw[v.OTGW_MODE] = ret - self.status.submit_partial_update(v.OTGW, status_otgw) - return ret + status_otgw[v.OTGW_MODE] = new_mode + self.status.submit_partial_update(OpenThermDataSource.GATEWAY, status_otgw) + return new_mode async def set_led_mode( self, led_id: str, mode: str, timeout: asyncio.Timeout = v.OTGW_DEFAULT_TIMEOUT @@ -376,14 +465,14 @@ async def set_led_mode( This method is a coroutine """ if led_id in "ABCDEF" and mode in "RXTBOFHWCEMP": - cmd = getattr(v, f"OTGW_CMD_LED_{led_id}") + cmd = getattr(OpenThermCommand, f"LED_{led_id}") status_otgw = {} ret = await self._wait_for_cmd(cmd, mode, timeout) if ret is None: return var = getattr(v, f"OTGW_LED_{led_id}") status_otgw[var] = ret - self.status.submit_partial_update(v.OTGW, status_otgw) + self.status.submit_partial_update(OpenThermDataSource.GATEWAY, status_otgw) return ret async def set_gpio_mode( @@ -421,7 +510,7 @@ async def set_gpio_mode( if gpio_id in "AB" and mode in range(8): if mode == 7 and gpio_id != "B": return - cmd = getattr(v, f"OTGW_CMD_GPIO_{gpio_id}") + cmd = getattr(OpenThermCommand, f"GPIO_{gpio_id}") status_otgw = {} ret = await self._wait_for_cmd(cmd, mode, timeout) if ret is None: @@ -429,8 +518,8 @@ async def set_gpio_mode( ret = int(ret) var = getattr(v, f"OTGW_GPIO_{gpio_id}") status_otgw[var] = ret - self.status.submit_partial_update(v.OTGW, status_otgw) - asyncio.ensure_future(self._poll_gpio()) + self.status.submit_partial_update(OpenThermDataSource.GATEWAY, status_otgw) + await self._poll_tasks[GPIO_POLL_TASK_NAME].start_or_stop_as_needed() return ret async def set_setback_temp( @@ -443,14 +532,14 @@ async def set_setback_temp( This method is a coroutine """ - cmd = v.OTGW_CMD_SETBACK + cmd = OpenThermCommand.SETBACK status_otgw = {} ret = await self._wait_for_cmd(cmd, sb_temp, timeout) if ret is None: return ret = float(ret) status_otgw[v.OTGW_SB_TEMP] = ret - self.status.submit_partial_update(v.OTGW, status_otgw) + self.status.submit_partial_update(OpenThermDataSource.GATEWAY, status_otgw) return ret async def add_alternative( @@ -469,7 +558,7 @@ async def add_alternative( This method is a coroutine """ - cmd = v.OTGW_CMD_ADD_ALT + cmd = OpenThermCommand.ADD_ALT alt = int(alt) if alt < 1 or alt > 255: return @@ -493,7 +582,7 @@ async def del_alternative( This method is a coroutine """ - cmd = v.OTGW_CMD_DEL_ALT + cmd = OpenThermCommand.DEL_ALT alt = int(alt) if alt < 1 or alt > 255: return @@ -514,7 +603,7 @@ async def add_unknown_id( This method is a coroutine """ - cmd = v.OTGW_CMD_UNKNOWN_ID + cmd = OpenThermCommand.UNKNOWN_ID unknown_id = int(unknown_id) if unknown_id < 1 or unknown_id > 255: return @@ -533,7 +622,7 @@ async def del_unknown_id( This method is a coroutine """ - cmd = v.OTGW_CMD_KNOWN_ID + cmd = OpenThermCommand.KNOWN_ID unknown_id = int(unknown_id) if unknown_id < 1 or unknown_id > 255: return @@ -551,14 +640,14 @@ async def set_max_ch_setpoint( This method is a coroutine """ - cmd = v.OTGW_CMD_SET_MAX + cmd = OpenThermCommand.SET_MAX status_boiler = {} ret = await self._wait_for_cmd(cmd, temperature, timeout) if ret is None: return ret = float(ret) status_boiler[v.DATA_MAX_CH_SETPOINT] = ret - self.status.submit_partial_update(v.BOILER, status_boiler) + self.status.submit_partial_update(OpenThermDataSource.BOILER, status_boiler) return ret async def set_dhw_setpoint( @@ -571,14 +660,14 @@ async def set_dhw_setpoint( This method is a coroutine """ - cmd = v.OTGW_CMD_SET_WATER + cmd = OpenThermCommand.SET_WATER status_boiler = {} ret = await self._wait_for_cmd(cmd, temperature, timeout) if ret is None: return ret = float(ret) status_boiler[v.DATA_DHW_SETPOINT] = ret - self.status.submit_partial_update(v.BOILER, status_boiler) + self.status.submit_partial_update(OpenThermDataSource.BOILER, status_boiler) return ret async def set_max_relative_mod( @@ -595,7 +684,7 @@ async def set_max_relative_mod( """ if isinstance(max_mod, int) and not 0 <= max_mod <= 100: return - cmd = v.OTGW_CMD_MAX_MOD + cmd = OpenThermCommand.MAX_MOD status_boiler = {} ret = await self._wait_for_cmd(cmd, max_mod, timeout) if ret is None: @@ -603,7 +692,7 @@ async def set_max_relative_mod( if ret != "-": ret = int(ret) status_boiler[v.DATA_SLAVE_MAX_RELATIVE_MOD] = ret - self.status.submit_partial_update(v.BOILER, status_boiler) + self.status.submit_partial_update(OpenThermDataSource.BOILER, status_boiler) return ret async def set_control_setpoint( @@ -616,14 +705,14 @@ async def set_control_setpoint( This method is a coroutine """ - cmd = v.OTGW_CMD_CONTROL_SETPOINT + cmd = OpenThermCommand.CONTROL_SETPOINT status_boiler = {} ret = await self._wait_for_cmd(cmd, setpoint, timeout) if ret is None: return ret = float(ret) status_boiler[v.DATA_CONTROL_SETPOINT] = ret - self.status.submit_partial_update(v.BOILER, status_boiler) + self.status.submit_partial_update(OpenThermDataSource.BOILER, status_boiler) return ret async def set_control_setpoint_2( @@ -636,14 +725,14 @@ async def set_control_setpoint_2( This method is a coroutine """ - cmd = v.OTGW_CMD_CONTROL_SETPOINT_2 + cmd = OpenThermCommand.CONTROL_SETPOINT_2 status_boiler = {} ret = await self._wait_for_cmd(cmd, setpoint, timeout) if ret is None: return ret = float(ret) status_boiler[v.DATA_CONTROL_SETPOINT_2] = ret - self.status.submit_partial_update(v.BOILER, status_boiler) + self.status.submit_partial_update(OpenThermDataSource.BOILER, status_boiler) return ret async def set_ch_enable_bit( @@ -661,14 +750,14 @@ async def set_ch_enable_bit( """ if ch_bit not in (0, 1): return - cmd = v.OTGW_CMD_CONTROL_HEATING + cmd = OpenThermCommand.CONTROL_HEATING status_boiler = {} ret = await self._wait_for_cmd(cmd, ch_bit, timeout) if ret is None: return ret = int(ret) status_boiler[v.DATA_MASTER_CH_ENABLED] = ret - self.status.submit_partial_update(v.BOILER, status_boiler) + self.status.submit_partial_update(OpenThermDataSource.BOILER, status_boiler) return ret async def set_ch2_enable_bit( @@ -686,14 +775,14 @@ async def set_ch2_enable_bit( """ if ch_bit not in (0, 1): return - cmd = v.OTGW_CMD_CONTROL_HEATING_2 + cmd = OpenThermCommand.CONTROL_HEATING_2 status_boiler = {} ret = await self._wait_for_cmd(cmd, ch_bit, timeout) if ret is None: return ret = int(ret) status_boiler[v.DATA_MASTER_CH2_ENABLED] = ret - self.status.submit_partial_update(v.BOILER, status_boiler) + self.status.submit_partial_update(OpenThermDataSource.BOILER, status_boiler) return ret async def set_ventilation( @@ -708,19 +797,19 @@ async def set_ventilation( """ if not 0 <= pct <= 100: return - cmd = v.OTGW_CMD_VENT + cmd = OpenThermCommand.VENT status_boiler = {} ret = await self._wait_for_cmd(cmd, pct, timeout) if ret is None: return ret = int(ret) status_boiler[v.DATA_COOLING_CONTROL] = ret - self.status.submit_partial_update(v.BOILER, status_boiler) + self.status.submit_partial_update(OpenThermDataSource.BOILER, status_boiler) return ret async def send_transparent_command( self, - cmd: str, + cmd: OpenThermCommand, state: str | float | int, timeout: asyncio.Timeout = v.OTGW_DEFAULT_TIMEOUT, ) -> bool | str | list[str] | None: @@ -760,7 +849,7 @@ def unsubscribe(self, coro: Callable[[dict[str, dict]], Awaitable[None]]) -> boo async def _wait_for_cmd( self, - cmd: str, + cmd: OpenThermCommand, value: str | float | int, timeout: asyncio.Timeout = v.OTGW_DEFAULT_TIMEOUT, ) -> bool | str | list[str] | None: @@ -784,49 +873,6 @@ async def _wait_for_cmd( "Command %s with value %s raised exception: %s", cmd, value, exc ) - async def _poll_gpio(self, interval: int = 10) -> None: - """ - Start or stop polling GPIO states. - - GPIO states aren't being pushed by the gateway, we need to poll - if we want updates. - """ - poll = 0 in ( - self.status.status[v.OTGW].get(v.OTGW_GPIO_A), - self.status.status[v.OTGW].get(v.OTGW_GPIO_B), - ) - if poll and self._gpio_task is None: - - async def polling_routine() -> None: - """Poll GPIO state every @interval seconds.""" - try: - while True: - ret = await self._wait_for_cmd( - v.OTGW_CMD_REPORT, v.OTGW_REPORT_GPIO_STATES - ) - if ret: - pios = ret[2:] - status_otgw = { - v.OTGW_GPIO_A_STATE: int(pios[0]), - v.OTGW_GPIO_B_STATE: int(pios[1]), - } - self.status.submit_partial_update(v.OTGW, status_otgw) - await asyncio.sleep(interval) - except asyncio.CancelledError: - status_otgw = { - v.OTGW_GPIO_A_STATE: 0, - v.OTGW_GPIO_B_STATE: 0, - } - self.status.submit_partial_update(v.OTGW, status_otgw) - self._gpio_task = None - _LOGGER.debug("GPIO polling routine stopped") - - _LOGGER.debug("Starting GPIO polling routine") - self._gpio_task = asyncio.get_running_loop().create_task(polling_routine()) - elif not poll and self._gpio_task is not None: - _LOGGER.debug("Stopping GPIO polling routine") - self._gpio_task.cancel() - def process_statusfields_v4(status_fields: list[str]) -> tuple[dict]: """ diff --git a/pyotgw/reports.py b/pyotgw/reports.py new file mode 100644 index 0000000..c6c3d5c --- /dev/null +++ b/pyotgw/reports.py @@ -0,0 +1,160 @@ +"""Define how report responses should be turned into status dict updates.""" + +from collections.abc import Callable + +from . import vars as v +from .types import ( + OpenThermDataSource, + OpenThermGatewayOpMode, + OpenThermGPIOMode, + OpenThermLEDMode, + OpenThermReport, + OpenThermResetCause, + OpenThermSetpointOverrideMode, + OpenThermSmartPowerMode, + OpenThermTemperatureSensorFunction, + OpenThermThermostatDetection, + OpenThermVoltageReferenceLevel, +) + +_CONVERSIONS: dict[OpenThermReport, Callable] = { + OpenThermReport.ABOUT: ( + lambda response: { + OpenThermDataSource.GATEWAY: { + v.OTGW_ABOUT: response, + }, + } + ), + OpenThermReport.BUILD: ( + lambda response: { + OpenThermDataSource.GATEWAY: { + v.OTGW_BUILD: response, + }, + } + ), + OpenThermReport.CLOCK_SPEED: ( + lambda response: { + OpenThermDataSource.GATEWAY: { + v.OTGW_CLOCKMHZ: response, + } + } + ), + OpenThermReport.TEMP_SENSOR_FUNCTION: ( + lambda response: { + OpenThermDataSource.GATEWAY: { + v.OTGW_TEMP_SENSOR: OpenThermTemperatureSensorFunction(response), + } + } + ), + OpenThermReport.GPIO_MODES: ( + lambda response: { + OpenThermDataSource.GATEWAY: { + v.OTGW_GPIO_A: OpenThermGPIOMode(int(response[0])), + v.OTGW_GPIO_B: OpenThermGPIOMode(int(response[1])), + }, + } + ), + OpenThermReport.GPIO_STATES: ( + lambda response: { + OpenThermDataSource.GATEWAY: { + v.OTGW_GPIO_A_STATE: int(response[0]), + v.OTGW_GPIO_B_STATE: int(response[1]), + } + } + ), + OpenThermReport.LED_MODES: ( + lambda response: { + OpenThermDataSource.GATEWAY: { + v.OTGW_LED_A: OpenThermLEDMode(response[0]), + v.OTGW_LED_B: OpenThermLEDMode(response[1]), + v.OTGW_LED_C: OpenThermLEDMode(response[2]), + v.OTGW_LED_D: OpenThermLEDMode(response[3]), + v.OTGW_LED_E: OpenThermLEDMode(response[4]), + v.OTGW_LED_F: OpenThermLEDMode(response[5]), + }, + } + ), + OpenThermReport.OP_MODE: ( + lambda response: { + OpenThermDataSource.GATEWAY: { + v.OTGW_MODE: OpenThermGatewayOpMode(response), + }, + } + ), + OpenThermReport.SETPOINT_OVERRIDE: ( + lambda response: { + OpenThermDataSource.GATEWAY: { + v.OTGW_SETP_OVRD_MODE: OpenThermSetpointOverrideMode( + response[0].upper() + ), + }, + OpenThermDataSource.THERMOSTAT: { + v.DATA_ROOM_SETPOINT_OVRD: None + if response[0].upper() == OpenThermSetpointOverrideMode.NOT_ACTIVE + else float(response[1:]), + }, + } + ), + OpenThermReport.SMART_PWR_MODE: ( + lambda response: { + OpenThermDataSource.GATEWAY: { + v.OTGW_SMART_PWR: OpenThermSmartPowerMode(response.lower()), + }, + } + ), + OpenThermReport.RESET_CAUSE: ( + lambda response: { + OpenThermDataSource.GATEWAY: { + v.OTGW_RST_CAUSE: OpenThermResetCause(response), + }, + } + ), + OpenThermReport.THERMOSTAT_DETECTION_STATE: ( + lambda response: { + OpenThermDataSource.GATEWAY: { + v.OTGW_THRM_DETECT: OpenThermThermostatDetection(response), + }, + } + ), + OpenThermReport.SETBACK_TEMPERATURE: ( + lambda response: { + OpenThermDataSource.GATEWAY: { + v.OTGW_SB_TEMP: float(response), + }, + } + ), + OpenThermReport.TWEAKS: ( + lambda response: { + OpenThermDataSource.GATEWAY: { + v.OTGW_IGNORE_TRANSITIONS: int(response[0]), + v.OTGW_OVRD_HB: int(response[1]), + }, + } + ), + OpenThermReport.VREF: ( + lambda response: { + OpenThermDataSource.GATEWAY: { + v.OTGW_VREF: OpenThermVoltageReferenceLevel(int(response)), + }, + } + ), + OpenThermReport.DHW: ( + lambda response: { + OpenThermDataSource.GATEWAY: { + v.OTGW_DHW_OVRD: response, + }, + } + ), +} + + +def convert_report_response_to_status_update( + report_type: OpenThermReport, response: str +) -> dict[OpenThermDataSource, dict] | None: + """Convert a report response to a status update dict.""" + if report_type not in _CONVERSIONS: + return + try: + return _CONVERSIONS[report_type](response[2:]) + except ValueError: + return diff --git a/pyotgw/status.py b/pyotgw/status.py index ef5f978..eaf370e 100644 --- a/pyotgw/status.py +++ b/pyotgw/status.py @@ -4,10 +4,11 @@ import asyncio import logging +from collections.abc import Awaitable, Callable from copy import deepcopy -from typing import Awaitable, Callable from . import vars as v +from .types import OpenThermDataSource _LOGGER = logging.getLogger(__name__) @@ -30,11 +31,11 @@ def reset(self) -> None: self._status = deepcopy(v.DEFAULT_STATUS) @property - def status(self) -> dict[str, dict]: + def status(self) -> dict[OpenThermDataSource, dict]: """Return the full status dict""" return deepcopy(self._status) - def delete_value(self, part: str, key: str) -> bool: + def delete_value(self, part: OpenThermDataSource, key: str) -> bool: """Delete key from status part.""" try: del self._status[part][key] @@ -43,7 +44,7 @@ def delete_value(self, part: str, key: str) -> bool: self._updateq.put_nowait(self.status) return True - def submit_partial_update(self, part: str, update: dict) -> bool: + def submit_partial_update(self, part: OpenThermDataSource, update: dict) -> bool: """ Submit an update for part of the status dict to the queue. Return a boolean indicating success. @@ -58,14 +59,14 @@ def submit_partial_update(self, part: str, update: dict) -> bool: self._updateq.put_nowait(self.status) return True - def submit_full_update(self, update: dict[str, dict]) -> bool: + def submit_full_update(self, update: dict[OpenThermDataSource, dict]) -> bool: """ Submit an update for multiple parts of the status dict to the queue. Return a boolean indicating success. """ for part, values in update.items(): # First we verify all data - if part not in self.status: + if not isinstance(part, OpenThermDataSource): _LOGGER.error("Invalid status part for update: %s", part) return False if not isinstance(values, dict): @@ -77,7 +78,9 @@ def submit_full_update(self, update: dict[str, dict]) -> bool: self._updateq.put_nowait(self.status) return True - def subscribe(self, callback: Callable[[dict[str, dict]], Awaitable[None]]) -> bool: + def subscribe( + self, callback: Callable[[dict[OpenThermDataSource, dict]], Awaitable[None]] + ) -> bool: """ Subscribe callback for future status updates. Return boolean indicating success. @@ -87,7 +90,9 @@ def subscribe(self, callback: Callable[[dict[str, dict]], Awaitable[None]]) -> b self._notify.append(callback) return True - def unsubscribe(self, callback: Callable[[dict[str, dict]], Awaitable[None]]) -> bool: + def unsubscribe( + self, callback: Callable[[dict[OpenThermDataSource, dict]], Awaitable[None]] + ) -> bool: """ Unsubscribe callback from future status updates. Return boolean indicating success. diff --git a/pyotgw/types.py b/pyotgw/types.py new file mode 100644 index 0000000..fd74507 --- /dev/null +++ b/pyotgw/types.py @@ -0,0 +1,261 @@ +"""pyotgw types.""" + +from enum import Enum, IntEnum, StrEnum + + +class OpenThermCommand(StrEnum): + """OpenTherm commands.""" + + TARGET_TEMP = "TT" + TARGET_TEMP_CONST = "TC" + OUTSIDE_TEMP = "OT" + SET_CLOCK = "SC" + HOT_WATER = "HW" + REPORT = "PR" + SUMMARY = "PS" + MODE = "GW" + LED_A = "LA" + LED_B = "LB" + LED_C = "LC" + LED_D = "LD" + LED_E = "LE" + LED_F = "LF" + GPIO_A = "GA" + GPIO_B = "GB" + SETBACK = "SB" + TEMP_SENSOR = "TS" + ADD_ALT = "AA" + DEL_ALT = "DA" + UNKNOWN_ID = "UI" + KNOWN_ID = "KI" + PRIO_MSG = "PM" + SET_RESP = "SR" + CLR_RESP = "CR" + SET_MAX = "SH" + SET_WATER = "SW" + MAX_MOD = "MM" + CONTROL_SETPOINT = "CS" + CONTROL_SETPOINT_2 = "C2" + CONTROL_HEATING = "CH" + CONTROL_HEATING_2 = "H2" + VENT = "VS" + RST_CNT = "RS" + IGNORE_TRANS = "IT" + OVRD_HIGH = "OH" + OVRD_THRMST = "FT" + VREF = "VR" + + +class OpenThermDataSource(StrEnum): + """OpenTherm data sources.""" + + BOILER = "boiler" + GATEWAY = "gateway" + THERMOSTAT = "thermostat" + + +class OpenThermGatewayOpMode(StrEnum): + """OpenTherm Gateway operating modes.""" + + GATEWAY = "G" + MONITOR = "M" + + +class OpenThermGPIOMode(IntEnum): + """OpenTherm Gateway GPIO modes.""" + + INPUT = 0 + GROUND = 1 + VCC = 2 + LED_E = 3 + LED_F = 4 + HOME = 5 + AWAY = 6 + DS1820 = 7 + DHW_BLOCK = 8 + + +class OpenThermHotWaterOverrideMode(StrEnum): + """Hot water override modes.""" + + FORCE_OFF = "0" + FORCE_ON = "1" + THERMOSTAT_CONTROLLED = "A" + + +class OpenThermLEDMode(StrEnum): + """OpenTherm Gateway LED modes.""" + + RX_ANY = "R" + TX_ANY = "X" + THERMOSTAT_TRAFFIC = "T" + BOILER_TRAFFIC = "B" + SETPOINT_OVERRIDE_ACTIVE = "O" + FLAME_ON = "F" + CENTRAL_HEATING_ON = "H" + HOT_WATER_ON = "W" + COMFORT_MODE_ON = "C" + TX_ERROR_DETECTED = "E" + BOILER_MAINTENANCE_REQUIRED = "M" + RAISED_POWER_MODE_ACTIVE = "P" + + +class OpenThermMessageID(Enum): + """OpenTherm message IDs.""" + + STATUS = b"\x00" + TSET = b"\x01" + MCONFIG = b"\x02" + SCONFIG = b"\x03" + COMMAND = b"\x04" + ASFFLAGS = b"\x05" + RBPFLAGS = b"\x06" + COOLING = b"\x07" + TSETC2 = b"\x08" + TROVRD = b"\x09" + TSP = b"\x0a" + TSPIDX = b"\x0b" + FHBSIZE = b"\x0c" + FHBIDX = b"\x0d" + MAXRMOD = b"\x0e" + MAXCAPMINMOD = b"\x0f" + TRSET = b"\x10" + RELMOD = b"\x11" + CHPRESS = b"\x12" + DHWFLOW = b"\x13" + TIME = b"\x14" + DATE = b"\x15" + YEAR = b"\x16" + TRSET2 = b"\x17" + TROOM = b"\x18" + TBOILER = b"\x19" + TDHW = b"\x1a" + TOUTSIDE = b"\x1b" + TRET = b"\x1c" + TSTOR = b"\x1d" + TCOLL = b"\x1e" + TFLOWCH2 = b"\x1f" + TDHW2 = b"\x20" + TEXHAUST = b"\x21" + TDHWSETUL = b"\x30" + TCHSETUL = b"\x31" + OTCCURVEUL = b"\x32" + TDHWSET = b"\x38" + MAXTSET = b"\x39" + OTCCURVE = b"\x3a" + STATUSVH = b"\x46" + RELVENTPOS = b"\x47" + RELVENT = b"\x4d" + ROVRD = b"\x64" + OEMDIAG = b"\x73" + BURNSTARTS = b"\x74" + CHPUMPSTARTS = b"\x75" + DHWPUMPSTARTS = b"\x76" + DHWBURNSTARTS = b"\x77" + BURNHRS = b"\x78" + CHPUMPHRS = b"\x79" + DHWPUMPHRS = b"\x7a" + DHWBURNHRS = b"\x7b" + OTVERM = b"\x7c" + OTVERS = b"\x7d" + MVER = b"\x7e" + SVER = b"\x7f" + + def __int__(self) -> int: + """Return value as int.""" + return int.from_bytes(self.value, "big") + + +class OpenThermMessageType(IntEnum): + """OpenTherm message types.""" + + READ_DATA = 0 + WRITE_DATA = 1 + INVALID_DATA = 2 + RESERVED = 3 + READ_ACK = 4 + WRITE_ACK = 5 + DATA_INVALID = 6 + UNKNOWN_DATAID = 7 + + +class OpenThermReport(StrEnum): + """OpenTherm reports.""" + + ABOUT = "A" + BUILD = "B" + CLOCK_SPEED = "C" + TEMP_SENSOR_FUNCTION = "D" + GPIO_MODES = "G" + GPIO_STATES = "I" + LED_MODES = "L" + OP_MODE = "M" + SETPOINT_OVERRIDE = "O" + SMART_PWR_MODE = "P" + RESET_CAUSE = "Q" + THERMOSTAT_DETECTION_STATE = "R" + SETBACK_TEMPERATURE = "S" + TWEAKS = "T" + VREF = "V" + DHW = "W" + + +class OpenThermResetCause(StrEnum): + """Gateway reset causes.""" + + BROWNOUT = "B" + SERIAL_COMMAND = "C" + RESET_BUTTON = "E" + STUCK_IN_LOOP = "L" + STACK_OVERFLOW = "O" + POWER_ON = "P" + SERIAL_BREAK = "S" + STACK_UNDERFLOW = "U" + WATCHDOG = "W" + + +class OpenThermSetpointOverrideMode(StrEnum): + """Setpoint override modes.""" + + CONSTANT = "C" + NOT_ACTIVE = "N" + TEMPORARY = "T" + + +class OpenThermSmartPowerMode(StrEnum): + """Smart power modes.""" + + LOW = "low power" + MEDIUM = "medium power" + HIGH = "high power" + + +class OpenThermTemperatureSensorFunction(StrEnum): + """Temperature sensor functions.""" + + OUTSIDE_TEMPERATURE = "O" + RETURN_WATER_TEMPERATURE = "R" + + +class OpenThermThermostatDetection(StrEnum): + """Thermostat detection modes.""" + + AUTO_DETECT = "D" + CELCIA_20 = "C" + ISENSE = "I" + STANDARD = "S" + + +class OpenThermVoltageReferenceLevel(IntEnum): + """Voltage reference levels.""" + + LEVEL_0 = 0 + LEVEL_1 = 1 + LEVEL_2 = 2 + LEVEL_3 = 3 + LEVEL_4 = 4 + LEVEL_5 = 5 + LEVEL_6 = 6 + LEVEL_7 = 7 + LEVEL_8 = 8 + LEVEL_9 = 9 diff --git a/pyotgw/vars.py b/pyotgw/vars.py index 7297af4..8480986 100644 --- a/pyotgw/vars.py +++ b/pyotgw/vars.py @@ -1,68 +1,12 @@ """Global pyotgw values""" -MSG_STATUS = b"\x00" -MSG_TSET = b"\x01" -MSG_MCONFIG = b"\x02" -MSG_SCONFIG = b"\x03" -MSG_COMMAND = b"\x04" -MSG_ASFFLAGS = b"\x05" -MSG_RBPFLAGS = b"\x06" -MSG_COOLING = b"\x07" -MSG_TSETC2 = b"\x08" -MSG_TROVRD = b"\x09" -MSG_TSP = b"\x0a" -MSG_TSPIDX = b"\x0b" -MSG_FHBSIZE = b"\x0c" -MSG_FHBIDX = b"\x0d" -MSG_MAXRMOD = b"\x0e" -MSG_MAXCAPMINMOD = b"\x0f" -MSG_TRSET = b"\x10" -MSG_RELMOD = b"\x11" -MSG_CHPRESS = b"\x12" -MSG_DHWFLOW = b"\x13" -MSG_TIME = b"\x14" -MSG_DATE = b"\x15" -MSG_YEAR = b"\x16" -MSG_TRSET2 = b"\x17" -MSG_TROOM = b"\x18" -MSG_TBOILER = b"\x19" -MSG_TDHW = b"\x1a" -MSG_TOUTSIDE = b"\x1b" -MSG_TRET = b"\x1c" -MSG_TSTOR = b"\x1d" -MSG_TCOLL = b"\x1e" -MSG_TFLOWCH2 = b"\x1f" -MSG_TDHW2 = b"\x20" -MSG_TEXHAUST = b"\x21" -MSG_TDHWSETUL = b"\x30" -MSG_TCHSETUL = b"\x31" -MSG_OTCCURVEUL = b"\x32" -MSG_TDHWSET = b"\x38" -MSG_MAXTSET = b"\x39" -MSG_OTCCURVE = b"\x3a" -MSG_STATUSVH = b"\x46" -MSG_RELVENTPOS = b"\x47" -MSG_RELVENT = b"\x4d" -MSG_ROVRD = b"\x64" -MSG_OEMDIAG = b"\x73" -MSG_BURNSTARTS = b"\x74" -MSG_CHPUMPSTARTS = b"\x75" -MSG_DHWPUMPSTARTS = b"\x76" -MSG_DHWBURNSTARTS = b"\x77" -MSG_BURNHRS = b"\x78" -MSG_CHPUMPHRS = b"\x79" -MSG_DHWPUMPHRS = b"\x7a" -MSG_DHWBURNHRS = b"\x7b" -MSG_OTVERM = b"\x7c" -MSG_OTVERS = b"\x7d" -MSG_MVER = b"\x7e" -MSG_SVER = b"\x7f" - -BOILER = "boiler" -OTGW = "gateway" -THERMOSTAT = "thermostat" - -DEFAULT_STATUS = {BOILER: {}, OTGW: {}, THERMOSTAT: {}} +from .types import OpenThermDataSource + +DEFAULT_STATUS = { + OpenThermDataSource.BOILER: {}, + OpenThermDataSource.GATEWAY: {}, + OpenThermDataSource.THERMOSTAT: {}, +} # MSG_STATUS DATA_MASTER_CH_ENABLED = "master_ch_enabled" @@ -266,57 +210,8 @@ DATA_SLAVE_PRODUCT_TYPE = "slave_product_type" DATA_SLAVE_PRODUCT_VERSION = "slave_product_version" - -READ_DATA = 0x0 -WRITE_DATA = 0x1 -INVALID_DATA = 0x2 -RESERVED = 0x3 -READ_ACK = 0x4 -WRITE_ACK = 0x5 -DATA_INVALID = 0x6 -UNKNOWN_DATAID = 0x7 - OTGW_DEFAULT_TIMEOUT = 3 -OTGW_CMD_TARGET_TEMP = "TT" -OTGW_CMD_TARGET_TEMP_CONST = "TC" -OTGW_CMD_OUTSIDE_TEMP = "OT" -OTGW_CMD_SET_CLOCK = "SC" -OTGW_CMD_HOT_WATER = "HW" -OTGW_CMD_REPORT = "PR" -OTGW_CMD_SUMMARY = "PS" -OTGW_CMD_MODE = "GW" -OTGW_CMD_LED_A = "LA" -OTGW_CMD_LED_B = "LB" -OTGW_CMD_LED_C = "LC" -OTGW_CMD_LED_D = "LD" -OTGW_CMD_LED_E = "LE" -OTGW_CMD_LED_F = "LF" -OTGW_CMD_GPIO_A = "GA" -OTGW_CMD_GPIO_B = "GB" -OTGW_CMD_SETBACK = "SB" -OTGW_CMD_TEMP_SENSOR = "TS" -OTGW_CMD_ADD_ALT = "AA" -OTGW_CMD_DEL_ALT = "DA" -OTGW_CMD_UNKNOWN_ID = "UI" -OTGW_CMD_KNOWN_ID = "KI" -OTGW_CMD_PRIO_MSG = "PM" -OTGW_CMD_SET_RESP = "SR" -OTGW_CMD_CLR_RESP = "CR" -OTGW_CMD_SET_MAX = "SH" -OTGW_CMD_SET_WATER = "SW" -OTGW_CMD_MAX_MOD = "MM" -OTGW_CMD_CONTROL_SETPOINT = "CS" -OTGW_CMD_CONTROL_SETPOINT_2 = "C2" -OTGW_CMD_CONTROL_HEATING = "CH" -OTGW_CMD_CONTROL_HEATING_2 = "H2" -OTGW_CMD_VENT = "VS" -OTGW_CMD_RST_CNT = "RS" -OTGW_CMD_IGNORE_TRANS = "IT" -OTGW_CMD_OVRD_HIGH = "OH" -OTGW_CMD_OVRD_THRMST = "FT" -OTGW_CMD_VREF = "VR" - OTGW_MODE = "otgw_mode" OTGW_DHW_OVRD = "otgw_dhw_ovrd" OTGW_ABOUT = "otgw_about" @@ -345,9 +240,6 @@ OTGW_SETP_OVRD_TEMPORARY = "T" OTGW_SETP_OVRD_PERMANENT = "C" OTGW_SETP_OVRD_DISABLED = "N" -OTGW_MODE_MONITOR = "M" -OTGW_MODE_GATEWAY = "G" -OTGW_MODE_RESET = "R" OTGW_REPORT_ABOUT = "A" OTGW_REPORT_BUILDDATE = "B" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3309853 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "function" + +[tool.ruff.lint.isort] +force-sort-within-sections = true +known-first-party = [ + "pyotgw", +] +combine-as-imports = true +split-on-trailing-comma = false + +[tool.tox] +legacy_tox_ini = """ + [tox] + envlist = clean, ruff-lint, py310, py311, py312 + skip_missing_interpreters = True + + [testenv] + commands = + pytest --cov --cov-append --cov-report=term-missing {posargs} + deps = + -rrequirements_test.txt + + [testenv:clean] + deps = coverage + skip_install = True + commands = coverage erase + + [testenv:precommit] + deps = + -rrequirements_test.txt + commands = + pre-commit run {posargs: --all-files} + + [testenv:ruff-lint] + deps = + -rrequirements_test.txt + commands = + ruff check pyotgw/ tests/ + + [testenv:ruff-format] + deps = + -rrequirements_test.txt + commands = + ruff format pyotgw/ tests/ +""" diff --git a/tests/data.py b/tests/data.py index 18939a6..1a91306 100644 --- a/tests/data.py +++ b/tests/data.py @@ -3,6 +3,7 @@ from types import SimpleNamespace import pyotgw.vars as v +from pyotgw.types import OpenThermDataSource, OpenThermMessageID, OpenThermMessageType _report_responses_51 = { v.OTGW_REPORT_ABOUT: "A=OpenTherm Gateway 5.1", @@ -42,8 +43,8 @@ } _report_expect_51 = { - v.BOILER: {}, - v.OTGW: { + OpenThermDataSource.BOILER: {}, + OpenThermDataSource.GATEWAY: { v.OTGW_ABOUT: "OpenTherm Gateway 5.1", v.OTGW_BUILD: "17:44 11-02-2021", v.OTGW_CLOCKMHZ: "4 MHz", @@ -67,12 +68,12 @@ v.OTGW_SB_TEMP: 16.5, v.OTGW_VREF: 3, }, - v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 20.5}, + OpenThermDataSource.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 20.5}, } _report_expect_42 = { - v.BOILER: {}, - v.OTGW: { + OpenThermDataSource.BOILER: {}, + OpenThermDataSource.GATEWAY: { v.OTGW_ABOUT: "OpenTherm Gateway 4.2.5", v.OTGW_BUILD: "17:59 20-10-2015", v.OTGW_CLOCKMHZ: None, @@ -96,7 +97,7 @@ v.OTGW_SB_TEMP: 16.5, v.OTGW_VREF: 3, }, - v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 20.5}, + OpenThermDataSource.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 20.5}, } pygw_reports = SimpleNamespace( @@ -113,7 +114,7 @@ "7654,6543,54321,43210,32101,21012,10123,99" ) _status_expect_5 = { - v.BOILER: { + OpenThermDataSource.BOILER: { v.DATA_SLAVE_FAULT_IND: 1, v.DATA_SLAVE_CH_ACTIVE: 0, v.DATA_SLAVE_DHW_ACTIVE: 1, @@ -159,8 +160,8 @@ v.DATA_DHW_PUMP_HOURS: 10123, v.DATA_DHW_BURNER_HOURS: 99, }, - v.OTGW: {}, - v.THERMOSTAT: { + OpenThermDataSource.GATEWAY: {}, + OpenThermDataSource.THERMOSTAT: { v.DATA_MASTER_CH_ENABLED: 0, v.DATA_MASTER_DHW_ENABLED: 1, v.DATA_MASTER_COOLING_ENABLED: 0, @@ -185,7 +186,7 @@ "9.09,0.98,12/34,56/78,9.87,8.76,1234,2345,3456,4567,5678,6789,7890,8909" ) _status_expect_4 = { - v.BOILER: { + OpenThermDataSource.BOILER: { v.DATA_SLAVE_FAULT_IND: 1, v.DATA_SLAVE_CH_ACTIVE: 0, v.DATA_SLAVE_DHW_ACTIVE: 1, @@ -221,8 +222,8 @@ v.DATA_DHW_PUMP_HOURS: 7890, v.DATA_DHW_BURNER_HOURS: 8909, }, - v.OTGW: {}, - v.THERMOSTAT: { + OpenThermDataSource.GATEWAY: {}, + OpenThermDataSource.THERMOSTAT: { v.DATA_MASTER_CH_ENABLED: 0, v.DATA_MASTER_DHW_ENABLED: 1, v.DATA_MASTER_COOLING_ENABLED: 0, @@ -250,11 +251,17 @@ ), # _get_flag8 ( - ("T", v.READ_DATA, v.MSG_STATUS, b"\x43", b"\x00"), + ( + "T", + OpenThermMessageType.READ_DATA, + OpenThermMessageID.STATUS, + b"\x43", + b"\x00", + ), { - v.BOILER: {}, - v.OTGW: {}, - v.THERMOSTAT: { + OpenThermDataSource.BOILER: {}, + OpenThermDataSource.GATEWAY: {}, + OpenThermDataSource.THERMOSTAT: { v.DATA_MASTER_CH_ENABLED: 1, v.DATA_MASTER_DHW_ENABLED: 1, v.DATA_MASTER_COOLING_ENABLED: 0, @@ -265,20 +272,30 @@ ), # _get_f8_8 ( - ("B", v.WRITE_ACK, v.MSG_TDHWSET, b"\x14", b"\x80"), - {v.BOILER: {v.DATA_DHW_SETPOINT: 20.5}, v.OTGW: {}, v.THERMOSTAT: {}}, + ( + "B", + OpenThermMessageType.WRITE_ACK, + OpenThermMessageID.TDHWSET, + b"\x14", + b"\x80", + ), + { + OpenThermDataSource.BOILER: {v.DATA_DHW_SETPOINT: 20.5}, + OpenThermDataSource.GATEWAY: {}, + OpenThermDataSource.THERMOSTAT: {}, + }, ), # _get_flag8 with skipped bits ( ( "R", - v.READ_ACK, - v.MSG_STATUSVH, + OpenThermMessageType.READ_ACK, + OpenThermMessageID.STATUSVH, b"\00", int("01010101", 2).to_bytes(1, "big"), ), { - v.BOILER: { + OpenThermDataSource.BOILER: { v.DATA_VH_SLAVE_FAULT_INDICATE: 1, v.DATA_VH_SLAVE_VENT_MODE: 0, v.DATA_VH_SLAVE_BYPASS_STATUS: 1, @@ -286,21 +303,21 @@ v.DATA_VH_SLAVE_FREE_VENT_STATUS: 1, v.DATA_VH_SLAVE_DIAG_INDICATE: 1, }, - v.OTGW: {}, - v.THERMOSTAT: {}, + OpenThermDataSource.GATEWAY: {}, + OpenThermDataSource.THERMOSTAT: {}, }, ), # Combined _get_flag8 and _get_u8 ( ( "R", - v.WRITE_ACK, - v.MSG_SCONFIG, + OpenThermMessageType.WRITE_ACK, + OpenThermMessageID.SCONFIG, int("10101010", 2).to_bytes(1, "big"), - b"\xFF", + b"\xff", ), { - v.BOILER: { + OpenThermDataSource.BOILER: { v.DATA_SLAVE_DHW_PRESENT: 0, v.DATA_SLAVE_CONTROL_TYPE: 1, v.DATA_SLAVE_COOLING_SUPPORTED: 0, @@ -309,27 +326,56 @@ v.DATA_SLAVE_CH2_PRESENT: 1, v.DATA_SLAVE_MEMBERID: 255, }, - v.OTGW: {}, - v.THERMOSTAT: {}, + OpenThermDataSource.GATEWAY: {}, + OpenThermDataSource.THERMOSTAT: {}, }, ), # _get_u16 ( - ("A", v.READ_ACK, v.MSG_BURNSTARTS, b"\x12", b"\xAA"), - {v.BOILER: {}, v.OTGW: {}, v.THERMOSTAT: {v.DATA_TOTAL_BURNER_STARTS: 4778}}, + ( + "A", + OpenThermMessageType.READ_ACK, + OpenThermMessageID.BURNSTARTS, + b"\x12", + b"\xaa", + ), + { + OpenThermDataSource.BOILER: {}, + OpenThermDataSource.GATEWAY: {}, + OpenThermDataSource.THERMOSTAT: {v.DATA_TOTAL_BURNER_STARTS: 4778}, + }, ), # _get_s8 ( - ("R", v.WRITE_ACK, v.MSG_TCHSETUL, b"\x50", b"\x1E"), + ( + "R", + OpenThermMessageType.WRITE_ACK, + OpenThermMessageID.TCHSETUL, + b"\x50", + b"\x1e", + ), { - v.BOILER: {v.DATA_SLAVE_CH_MAX_SETP: 80, v.DATA_SLAVE_CH_MIN_SETP: 30}, - v.OTGW: {}, - v.THERMOSTAT: {}, + OpenThermDataSource.BOILER: { + v.DATA_SLAVE_CH_MAX_SETP: 80, + v.DATA_SLAVE_CH_MIN_SETP: 30, + }, + OpenThermDataSource.GATEWAY: {}, + OpenThermDataSource.THERMOSTAT: {}, }, ), # _get_s16 ( - ("B", v.READ_ACK, v.MSG_TEXHAUST, b"\xFF", b"\x83"), - {v.BOILER: {v.DATA_EXHAUST_TEMP: -125}, v.OTGW: {}, v.THERMOSTAT: {}}, + ( + "B", + OpenThermMessageType.READ_ACK, + OpenThermMessageID.TEXHAUST, + b"\xff", + b"\x83", + ), + { + OpenThermDataSource.BOILER: {v.DATA_EXHAUST_TEMP: -125}, + OpenThermDataSource.GATEWAY: {}, + OpenThermDataSource.THERMOSTAT: {}, + }, ), ) diff --git a/tests/helpers.py b/tests/helpers.py index d1126d9..03853ef 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,6 +1,9 @@ """Helper functions for tests""" import asyncio +from collections.abc import Callable + +from pyotgw.types import OpenThermCommand, OpenThermReport async def called_x_times(mocked, x, timeout=10): @@ -26,3 +29,50 @@ async def _wait(): await asyncio.sleep(0) await asyncio.wait_for(_wait(), timeout) + + +def respond_to_reports( + cmds: list[OpenThermReport] = [], responses: list[str] = [] +) -> Callable[[OpenThermCommand, str, float | None], str]: + """ + Respond to PR= commands with test values. + Override response values by specifying cmds and responses in order. + """ + + default_responses = { + OpenThermReport.ABOUT: "A=OpenTherm Gateway 5.8", + OpenThermReport.BUILD: "B=17:52 12-03-2023", + OpenThermReport.CLOCK_SPEED: "C=4 MHz", + OpenThermReport.TEMP_SENSOR_FUNCTION: "D=R", + OpenThermReport.GPIO_MODES: "G=46", + OpenThermReport.GPIO_STATES: "I=10", + OpenThermReport.LED_MODES: "L=HWCEMP", + OpenThermReport.OP_MODE: "M=G", + OpenThermReport.SETPOINT_OVERRIDE: "O=c17.25", + OpenThermReport.SMART_PWR_MODE: "P=Medium power", + OpenThermReport.RESET_CAUSE: "Q=B", + OpenThermReport.THERMOSTAT_DETECTION_STATE: "R=C", + OpenThermReport.SETBACK_TEMPERATURE: "S=15.1", + OpenThermReport.TWEAKS: "T=10", + OpenThermReport.VREF: "V=6", + OpenThermReport.DHW: "W=1", + } + + if len(cmds) != len(responses): + raise ValueError( + "There should be an equal amount of provided cmds and responses" + ) + + for cmd, response in zip(cmds, responses): + if cmd not in default_responses: + raise ValueError(f"Command {cmd} not found in default responses.") + + default_responses[cmd] = response + + def responder(cmd: OpenThermCommand, value: str, timeout: float = 1) -> str: + """Respond to command requests""" + if cmd != OpenThermCommand.REPORT: + return + return default_responses[value[0]] + + return responder diff --git a/tests/test_commandprocessor.py b/tests/test_commandprocessor.py index 8770ce8..b946faa 100644 --- a/tests/test_commandprocessor.py +++ b/tests/test_commandprocessor.py @@ -6,7 +6,7 @@ import pytest -import pyotgw.vars as v +from pyotgw.types import OpenThermCommand from tests.helpers import called_once, let_queue_drain @@ -36,13 +36,16 @@ async def test_issue_cmd(caplog, pygw_proto): """Test OpenThermProtocol.issue_cmd()""" pygw_proto._connected = False with caplog.at_level(logging.DEBUG): - assert await pygw_proto.command_processor.issue_cmd("PS", 1, 0) is None + assert ( + await pygw_proto.command_processor.issue_cmd(OpenThermCommand.SUMMARY, 1, 0) + is None + ) assert caplog.record_tuples == [ ( "pyotgw.commandprocessor", logging.DEBUG, - "Serial transport closed, not sending command PS", + f"Serial transport closed, not sending command {OpenThermCommand.SUMMARY}", ), ] caplog.clear() @@ -55,7 +58,7 @@ async def test_issue_cmd(caplog, pygw_proto): with caplog.at_level(logging.DEBUG): task = loop.create_task( pygw_proto.command_processor.issue_cmd( - v.OTGW_CMD_REPORT, + OpenThermCommand.REPORT, "I", 1, ) @@ -72,7 +75,7 @@ async def test_issue_cmd(caplog, pygw_proto): ( "pyotgw.commandprocessor", logging.DEBUG, - "Sending command: PR with value I", + f"Sending command: {OpenThermCommand.REPORT} with value I", ), ] caplog.clear() @@ -101,17 +104,17 @@ async def test_issue_cmd(caplog, pygw_proto): ( "pyotgw.commandprocessor", logging.DEBUG, - "Got possible response for command PR: SE", + f"Got possible response for command {OpenThermCommand.REPORT}: SE", ), ( "pyotgw.commandprocessor", logging.WARNING, - "Command PR failed with SE, retrying...", + f"Command {OpenThermCommand.REPORT} failed with SE, retrying...", ), ( "pyotgw.commandprocessor", logging.DEBUG, - "Got possible response for command PR: SE", + f"Got possible response for command {OpenThermCommand.REPORT}: SE", ), ] caplog.clear() @@ -120,7 +123,7 @@ async def test_issue_cmd(caplog, pygw_proto): with caplog.at_level(logging.WARNING): task = loop.create_task( pygw_proto.command_processor.issue_cmd( - v.OTGW_CMD_CONTROL_SETPOINT_2, + OpenThermCommand.CONTROL_SETPOINT_2, 20.501, 1, ) @@ -128,7 +131,9 @@ async def test_issue_cmd(caplog, pygw_proto): await called_once(pygw_proto.transport.write) pygw_proto.transport.write.assert_called_once_with(b"C2=20.50\r\n") pygw_proto.command_processor.submit_response("InvalidCommand") - pygw_proto.command_processor.submit_response("C2: 20.50") + pygw_proto.command_processor.submit_response( + f"{OpenThermCommand.CONTROL_SETPOINT_2}: 20.50" + ) assert await task == "20.50" assert pygw_proto.transport.write.call_args_list == [ @@ -144,7 +149,7 @@ async def test_issue_cmd(caplog, pygw_proto): ( "pyotgw.commandprocessor", logging.WARNING, - "Command C2 failed with InvalidCommand, retrying...", + f"Command {OpenThermCommand.CONTROL_SETPOINT_2} failed with InvalidCommand, retrying...", ), ] caplog.clear() @@ -153,7 +158,7 @@ async def test_issue_cmd(caplog, pygw_proto): with caplog.at_level(logging.WARNING): task = loop.create_task( pygw_proto.command_processor.issue_cmd( - v.OTGW_CMD_CONTROL_HEATING_2, + OpenThermCommand.CONTROL_HEATING_2, -1, 2, ) @@ -161,8 +166,12 @@ async def test_issue_cmd(caplog, pygw_proto): await called_once(pygw_proto.transport.write) pygw_proto.transport.write.assert_called_once_with(b"H2=-1\r\n") pygw_proto.command_processor.submit_response("Error 03") - pygw_proto.command_processor.submit_response("H2: BV") - pygw_proto.command_processor.submit_response("H2: BV") + pygw_proto.command_processor.submit_response( + f"{OpenThermCommand.CONTROL_HEATING_2}: BV" + ) + pygw_proto.command_processor.submit_response( + f"{OpenThermCommand.CONTROL_HEATING_2}: BV" + ) with pytest.raises(ValueError): await task @@ -176,19 +185,19 @@ async def test_issue_cmd(caplog, pygw_proto): ( "pyotgw.commandprocessor", logging.WARNING, - "Command H2 failed with Error 03, retrying...", + f"Command {OpenThermCommand.CONTROL_HEATING_2} failed with Error 03, retrying...", ), ( "pyotgw.commandprocessor", logging.WARNING, - "Command H2 failed with H2: BV, retrying...", + f"Command {OpenThermCommand.CONTROL_HEATING_2} failed with {OpenThermCommand.CONTROL_HEATING_2}: BV, retrying...", ), ] pygw_proto.transport.write = MagicMock() task = loop.create_task( pygw_proto.command_processor.issue_cmd( - v.OTGW_CMD_MODE, + OpenThermCommand.MODE, "R", 0, ) @@ -202,13 +211,13 @@ async def test_issue_cmd(caplog, pygw_proto): pygw_proto.transport.write = MagicMock() task = loop.create_task( pygw_proto.command_processor.issue_cmd( - v.OTGW_CMD_SUMMARY, + OpenThermCommand.SUMMARY, 1, 0, ) ) await called_once(pygw_proto.transport.write) - pygw_proto.command_processor.submit_response("PS: 1") + pygw_proto.command_processor.submit_response(f"{OpenThermCommand.SUMMARY}: 1") pygw_proto.command_processor.submit_response( "part_2_will_normally_be_parsed_by_get_status", ) diff --git a/tests/test_connection.py b/tests/test_connection.py index 9015402..4e099fc 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -173,9 +173,7 @@ async def test_reconnect_after_connection_loss(caplog, pygw_conn, pygw_proto): pygw_conn.watchdog, "start", side_effect=pygw_conn.watchdog.start, - ) as wd_start, caplog.at_level( - logging.DEBUG - ): + ) as wd_start, caplog.at_level(logging.DEBUG): assert await pygw_conn.connect("loop://", timeout=0.001) caplog.clear() @@ -303,9 +301,7 @@ async def test_attempt_connect_serialexception(caplog, pygw_conn): pygw_conn, "_get_retry_timeout", return_value=0, - ) as retry_timeout, caplog.at_level( - logging.ERROR - ): + ) as retry_timeout, caplog.at_level(logging.ERROR): task = loop.create_task(pygw_conn._attempt_connect()) await called_x_times(retry_timeout, 2) @@ -340,9 +336,7 @@ async def test_attempt_connect_timeouterror(caplog, pygw_conn, pygw_proto): pygw_conn, "_get_retry_timeout", return_value=0, - ) as retry_timeout, caplog.at_level( - logging.ERROR - ): + ) as retry_timeout, caplog.at_level(logging.ERROR): task = loop.create_task(pygw_conn._attempt_connect()) await called_x_times(retry_timeout, 2) @@ -377,9 +371,7 @@ async def test_attempt_connect_syntaxerror(caplog, pygw_conn, pygw_proto): pygw_conn, "_get_retry_timeout", return_value=0, - ) as retry_timeout, caplog.at_level( - logging.ERROR - ): + ) as retry_timeout, caplog.at_level(logging.ERROR): task = loop.create_task(pygw_conn._attempt_connect()) with pytest.raises(SyntaxError): await task @@ -428,9 +420,7 @@ async def empty_coroutine(): ) as task_cancel, patch.object( pygw_watchdog, "_watchdog", - ) as watchdog, caplog.at_level( - logging.DEBUG - ): + ) as watchdog, caplog.at_level(logging.DEBUG): await pygw_watchdog.inform() task_cancel.assert_called_once() diff --git a/tests/test_messageprocessor.py b/tests/test_messageprocessor.py index 96029e8..3a73a83 100644 --- a/tests/test_messageprocessor.py +++ b/tests/test_messageprocessor.py @@ -1,4 +1,5 @@ """Test for pyotgw/messageprocessor.py""" + import asyncio import logging import re @@ -7,6 +8,7 @@ import pytest from pyotgw import vars as v +from pyotgw.types import OpenThermDataSource, OpenThermMessageID, OpenThermMessageType from tests.data import pygw_proto_messages from tests.helpers import called_once @@ -50,7 +52,7 @@ def test_submit_matched_message(caplog, pygw_message_processor): ] assert pygw_message_processor._msgq.get_nowait() == ( "A", - v.READ_DATA, + OpenThermMessageType.READ_DATA, b"\x02", b"\x03", b"\x04", @@ -69,7 +71,7 @@ def test_dissect_msg(caplog, pygw_message_processor): assert pygw_message_processor._dissect_msg(test_matches[0]) == ( "A", - v.WRITE_DATA, + OpenThermMessageType.WRITE_DATA, b"\x20", b"\x30", b"\x40", @@ -101,9 +103,9 @@ async def test_process_msgs(caplog, pygw_message_processor): """Test MessageProcessor._process_msgs()""" test_case = ( "B", - v.READ_ACK, + OpenThermMessageType.READ_ACK, b"\x23", - b"\x0A", + b"\x0a", b"\x01", ) with patch.object( @@ -129,8 +131,8 @@ async def test_process_msg(pygw_message_processor): # Test quirks test_case = ( "B", - v.READ_ACK, - v.MSG_TROVRD, + OpenThermMessageType.READ_ACK, + OpenThermMessageID.TROVRD, b"\x10", b"\x80", ) @@ -141,7 +143,7 @@ async def test_process_msg(pygw_message_processor): await pygw_message_processor._process_msg(test_case) quirk_trovrd.assert_called_once_with( - v.BOILER, + OpenThermDataSource.BOILER, "B", b"\x10", b"\x80", @@ -178,7 +180,7 @@ async def empty_coroutine(stat): status_callback = MagicMock(side_effect=empty_coroutine) pygw_message_processor.status_manager.subscribe(status_callback) pygw_message_processor.status_manager.submit_partial_update( - v.OTGW, + OpenThermDataSource.GATEWAY, {v.OTGW_THRM_DETECT: "I"}, ) await called_once(status_callback) @@ -190,7 +192,7 @@ async def empty_coroutine(stat): return_value="O=c19.5", ): await pygw_message_processor._quirk_trovrd( - v.THERMOSTAT, + OpenThermDataSource.THERMOSTAT, "A", b"\x15", b"\x40", @@ -199,9 +201,9 @@ async def empty_coroutine(stat): await called_once(status_callback) status_callback.assert_called_once_with( { - v.BOILER: {}, - v.OTGW: {v.OTGW_THRM_DETECT: "I"}, - v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 19.5}, + OpenThermDataSource.BOILER: {}, + OpenThermDataSource.GATEWAY: {v.OTGW_THRM_DETECT: "I"}, + OpenThermDataSource.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 19.5}, } ) @@ -217,7 +219,7 @@ async def empty_coroutine(stat): "delete_value", ) as delete_value: await pygw_message_processor._quirk_trovrd( - v.THERMOSTAT, + OpenThermDataSource.THERMOSTAT, "A", b"\x15", b"\x40", @@ -227,12 +229,12 @@ async def empty_coroutine(stat): delete_value.assert_not_called() assert ( v.DATA_ROOM_SETPOINT_OVRD - in pygw_message_processor.status_manager.status[v.THERMOSTAT] + in pygw_message_processor.status_manager.status[OpenThermDataSource.THERMOSTAT] ) status_callback.reset_mock() await pygw_message_processor._quirk_trovrd( - v.THERMOSTAT, + OpenThermDataSource.THERMOSTAT, "A", b"\x00", b"\x00", @@ -240,21 +242,21 @@ async def empty_coroutine(stat): await called_once(status_callback) status_callback.assert_called_once_with( { - v.BOILER: {}, - v.OTGW: {v.OTGW_THRM_DETECT: "I"}, - v.THERMOSTAT: {}, + OpenThermDataSource.BOILER: {}, + OpenThermDataSource.GATEWAY: {v.OTGW_THRM_DETECT: "I"}, + OpenThermDataSource.THERMOSTAT: {}, } ) status_callback.reset_mock() pygw_message_processor.status_manager.submit_partial_update( - v.OTGW, {v.OTGW_THRM_DETECT: "D"} + OpenThermDataSource.GATEWAY, {v.OTGW_THRM_DETECT: "D"} ) await called_once(status_callback) status_callback.reset_mock() await pygw_message_processor._quirk_trovrd( - v.THERMOSTAT, + OpenThermDataSource.THERMOSTAT, "A", b"\x15", b"\x40", @@ -262,15 +264,15 @@ async def empty_coroutine(stat): await called_once(status_callback) status_callback.assert_called_once_with( { - v.BOILER: {}, - v.OTGW: {v.OTGW_THRM_DETECT: "D"}, - v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 21.25}, + OpenThermDataSource.BOILER: {}, + OpenThermDataSource.GATEWAY: {v.OTGW_THRM_DETECT: "D"}, + OpenThermDataSource.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 21.25}, } ) status_callback.reset_mock() pygw_message_processor.status_manager.submit_partial_update( - v.OTGW, {v.OTGW_THRM_DETECT: "I"} + OpenThermDataSource.GATEWAY, {v.OTGW_THRM_DETECT: "I"} ) await called_once(status_callback) status_callback.reset_mock() @@ -281,7 +283,7 @@ async def empty_coroutine(stat): return_value="O=N", ): await pygw_message_processor._quirk_trovrd( - v.THERMOSTAT, + OpenThermDataSource.THERMOSTAT, "A", b"\x15", b"\x40", @@ -289,9 +291,9 @@ async def empty_coroutine(stat): await called_once(status_callback) status_callback.assert_called_once_with( { - v.BOILER: {}, - v.OTGW: {v.OTGW_THRM_DETECT: "I"}, - v.THERMOSTAT: {}, + OpenThermDataSource.BOILER: {}, + OpenThermDataSource.GATEWAY: {v.OTGW_THRM_DETECT: "I"}, + OpenThermDataSource.THERMOSTAT: {}, } ) @@ -304,23 +306,19 @@ async def empty_coroutine(stat): return with patch.object( - pygw_message_processor.status_manager, - "submit_partial_update" + pygw_message_processor.status_manager, "submit_partial_update" ) as partial_update: await pygw_message_processor._quirk_trset_s2m( - v.THERMOSTAT, + OpenThermDataSource.THERMOSTAT, b"\x01", b"\x02", ) await pygw_message_processor._quirk_trset_s2m( - v.BOILER, - b"\x14", - b"\x80" + OpenThermDataSource.BOILER, b"\x14", b"\x80" ) partial_update.assert_called_once_with( - v.BOILER, - {v.DATA_ROOM_SETPOINT: 20.5} + OpenThermDataSource.BOILER, {v.DATA_ROOM_SETPOINT: 20.5} ) @@ -373,7 +371,7 @@ def test_get_u8(pygw_message_processor): 0, ), ( - b"\xFF", + b"\xff", 255, ), ) @@ -390,7 +388,7 @@ def test_get_s8(pygw_message_processor): 0, ), ( - b"\xFF", + b"\xff", -1, ), ) @@ -411,7 +409,7 @@ def test_get_f8_8(pygw_message_processor): ), ( ( - b"\xFF", + b"\xff", b"\x80", ), -0.5, @@ -434,8 +432,8 @@ def test_get_u16(pygw_message_processor): ), ( ( - b"\xFF", - b"\xFF", + b"\xff", + b"\xff", ), 65535, ), @@ -457,8 +455,8 @@ def test_get_s16(pygw_message_processor): ), ( ( - b"\xFF", - b"\xFF", + b"\xff", + b"\xff", ), -1, ), diff --git a/tests/test_messages.py b/tests/test_messages.py index c4486bd..ace1eab 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -7,7 +7,7 @@ def test_message_registry(): """Test message registry values.""" for msgid, processing in m.REGISTRY.items(): - assert 0 <= int.from_bytes(msgid, "big") < 128 + assert 0 <= int(msgid) < 128 assert isinstance(processing[m.M2S], list) assert isinstance(processing[m.S2M], list) diff --git a/tests/test_poll_task.py b/tests/test_poll_task.py new file mode 100644 index 0000000..d6c2b90 --- /dev/null +++ b/tests/test_poll_task.py @@ -0,0 +1,93 @@ +"""Tests for pyotgw/poll_tasks.py""" + +import pytest +from unittest.mock import call, AsyncMock + +from pyotgw.poll_task import OpenThermPollTask +from pyotgw.pyotgw import OpenThermGateway, GPIO_POLL_TASK_NAME +from pyotgw.types import OpenThermDataSource, OpenThermReport +import pyotgw.vars as v + +from .helpers import called_x_times + +TASK_TEST_PARAMETERS = ("task_name",) +TASK_TEST_VALUES = [ + (GPIO_POLL_TASK_NAME,), +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + TASK_TEST_PARAMETERS, + TASK_TEST_VALUES, +) +async def test_init(pygw: OpenThermGateway, task_name: str) -> None: + """Test object initialization.""" + task = pygw._poll_tasks[task_name] + assert isinstance(task, OpenThermPollTask) + assert task.should_run == task.is_running + + +@pytest.mark.asyncio +async def test_gpio_start_stop(pygw: OpenThermGateway) -> None: + """Test task.start() and task.stop()""" + task = pygw._poll_tasks[GPIO_POLL_TASK_NAME] + assert not task.is_running + pygw.status.submit_partial_update(OpenThermDataSource.GATEWAY, {v.OTGW_GPIO_A: 0}) + task.start() + assert task.is_running + await task.stop() + assert not task.is_running + + +@pytest.mark.asyncio +async def test_gpio_start_or_stop_as_needed(pygw: OpenThermGateway) -> None: + """Test task.start_or_stop_as_needed()""" + task = pygw._poll_tasks[GPIO_POLL_TASK_NAME] + assert task.is_running is False + await task.start_or_stop_as_needed() + assert task.is_running is False + pygw.status.submit_partial_update(OpenThermDataSource.GATEWAY, {v.OTGW_GPIO_A: 0}) + await task.start_or_stop_as_needed() + assert task.is_running is True + await task.start_or_stop_as_needed() + assert task.is_running is True + pygw.status.submit_partial_update(OpenThermDataSource.GATEWAY, {v.OTGW_GPIO_A: 1}) + await task.start_or_stop_as_needed() + assert task.is_running is False + + +def test_gpio_should_run(pygw: OpenThermGateway) -> None: + """Test task.should_run()""" + task = pygw._poll_tasks[GPIO_POLL_TASK_NAME] + assert task.should_run is False + pygw.status.submit_partial_update(OpenThermDataSource.GATEWAY, {v.OTGW_GPIO_A: 0}) + assert task.should_run is True + + +@pytest.mark.asyncio +async def test_gpio_is_running(pygw: OpenThermGateway) -> None: + """Test task.should_run()""" + task = pygw._poll_tasks[GPIO_POLL_TASK_NAME] + assert task.is_running is False + pygw.status.submit_partial_update(OpenThermDataSource.GATEWAY, {v.OTGW_GPIO_A: 0}) + task.start() + assert task.should_run is True + + +@pytest.mark.asyncio +async def test_gpio_polling_routing(pygw: OpenThermGateway) -> None: + """Test task._polling_routing()""" + pygw.get_report = AsyncMock() + task = pygw._poll_tasks[GPIO_POLL_TASK_NAME] + task._interval = 0.01 + pygw.status.submit_partial_update(OpenThermDataSource.GATEWAY, {v.OTGW_GPIO_A: 0}) + task.start() + await called_x_times(pygw.get_report, 2) + pygw.get_report.assert_has_awaits( + [ + call(OpenThermReport.GPIO_STATES), + call(OpenThermReport.GPIO_STATES), + ] + ) + await task.stop() diff --git a/tests/test_pyotgw.py b/tests/test_pyotgw.py index aef737d..41fd0f1 100644 --- a/tests/test_pyotgw.py +++ b/tests/test_pyotgw.py @@ -3,50 +3,60 @@ import asyncio import logging from datetime import datetime -from unittest.mock import MagicMock, call, patch +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest import serial import pyotgw.vars as v +from pyotgw.pyotgw import GPIO_POLL_TASK_NAME +from pyotgw.types import ( + OpenThermCommand, + OpenThermDataSource, + OpenThermGatewayOpMode, + OpenThermReport, +) from tests.data import pygw_reports, pygw_status -from tests.helpers import called_once, called_x_times +from tests.helpers import called_x_times, respond_to_reports + +from .test_reports import REPORT_TEST_PARAMETERS, REPORT_TEST_VALUES @pytest.mark.asyncio async def test_cleanup(pygw): """Test pyotgw.cleanup()""" - pygw.status.submit_partial_update(v.OTGW, {v.OTGW_GPIO_A: 0}) + pygw.status.submit_partial_update(OpenThermDataSource.GATEWAY, {v.OTGW_GPIO_A: 0}) pygw.loop = asyncio.get_running_loop() - with patch.object(pygw, "_wait_for_cmd"): - await pygw._poll_gpio() - assert pygw._gpio_task - await pygw.cleanup() - assert not pygw._gpio_task + pygw._poll_tasks[GPIO_POLL_TASK_NAME].start() + + assert pygw._poll_tasks[GPIO_POLL_TASK_NAME].is_running + await pygw.cleanup() + assert not pygw._poll_tasks[GPIO_POLL_TASK_NAME].is_running @pytest.mark.asyncio async def test_connect_success_and_reconnect_with_gpio(caplog, pygw, pygw_proto): """Test pyotgw.connect()""" - with patch.object(pygw, "get_reports", return_value={}), patch.object( + pygw._wait_for_cmd = AsyncMock( + side_effect=respond_to_reports([OpenThermReport.GPIO_MODES], ["G=10"]) + ) + + with patch.object( pygw, "get_status", return_value={}, - ), patch.object(pygw, "_poll_gpio") as poll_gpio, patch.object( + ), patch.object( pygw_proto, "init_and_wait_for_activity", ) as init_and_wait, patch( "serial_asyncio_fast.create_serial_connection", return_value=(pygw_proto.transport, pygw_proto), - ), caplog.at_level( - logging.DEBUG - ): - status = await pygw.connect("loop://") + ), caplog.at_level(logging.DEBUG): + await pygw.connect("loop://") - assert status == v.DEFAULT_STATUS init_and_wait.assert_called_once() - poll_gpio.assert_called_once() + assert pygw._poll_tasks[GPIO_POLL_TASK_NAME].is_running await pygw.connection.watchdog.stop() await pygw.connection.watchdog._callback() @@ -70,21 +80,16 @@ async def test_connect_skip_init(caplog, pygw, pygw_proto): "get_status", return_value={}, ) as get_status, patch.object( - pygw, "_poll_gpio" - ) as poll_gpio, patch.object( pygw_proto, "init_and_wait_for_activity", ) as init_and_wait, patch( "serial_asyncio_fast.create_serial_connection", return_value=(pygw_proto.transport, pygw_proto), - ), caplog.at_level( - logging.DEBUG - ): + ), caplog.at_level(logging.DEBUG): status = await pygw.connect("loop://", skip_init=True) assert status == v.DEFAULT_STATUS init_and_wait.assert_called_once() - poll_gpio.assert_called_once() get_reports.assert_not_awaited() get_status.assert_not_awaited() @@ -163,9 +168,7 @@ async def test_connect_timeouterror(caplog, pygw, pygw_proto): ) as loops_done, patch( "serial_asyncio_fast.create_serial_connection", return_value=(pygw_proto.transport, pygw_proto), - ), caplog.at_level( - logging.DEBUG - ): + ), caplog.at_level(logging.DEBUG): task = loop.create_task(pygw.connect("loop://")) await called_x_times(loops_done, 2) @@ -223,7 +226,7 @@ async def test_set_target_temp(pygw): assert await pygw.set_target_temp(12.3) is None wait_for_cmd.assert_called_once_with( - v.OTGW_CMD_TARGET_TEMP, + OpenThermCommand.TARGET_TEMP, "12.3", v.OTGW_DEFAULT_TIMEOUT, ) @@ -241,14 +244,16 @@ async def test_set_target_temp(pygw): assert isinstance(temp, float) assert temp == 0 wait_for_cmd.assert_called_once_with( - v.OTGW_CMD_TARGET_TEMP, + OpenThermCommand.TARGET_TEMP, "0.0", 5, ) update_full_status.assert_called_once_with( { - v.OTGW: {v.OTGW_SETP_OVRD_MODE: v.OTGW_SETP_OVRD_DISABLED}, - v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: None}, + OpenThermDataSource.GATEWAY: { + v.OTGW_SETP_OVRD_MODE: v.OTGW_SETP_OVRD_DISABLED + }, + OpenThermDataSource.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: None}, } ) @@ -263,11 +268,13 @@ async def test_set_target_temp(pygw): temp = await pygw.set_target_temp(15.5) assert temp == 15.5 - wait_for_cmd.assert_called_once_with(v.OTGW_CMD_TARGET_TEMP, "15.5", 3) + wait_for_cmd.assert_called_once_with(OpenThermCommand.TARGET_TEMP, "15.5", 3) update_full_status.assert_called_once_with( { - v.OTGW: {v.OTGW_SETP_OVRD_MODE: v.OTGW_SETP_OVRD_TEMPORARY}, - v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 15.5}, + OpenThermDataSource.GATEWAY: { + v.OTGW_SETP_OVRD_MODE: v.OTGW_SETP_OVRD_TEMPORARY + }, + OpenThermDataSource.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 15.5}, } ) @@ -282,11 +289,13 @@ async def test_set_target_temp(pygw): temp = await pygw.set_target_temp(20.5, temporary=False) assert temp == 20.5 - wait_for_cmd.assert_called_once_with(v.OTGW_CMD_TARGET_TEMP_CONST, "20.5", 3) + wait_for_cmd.assert_called_once_with(OpenThermCommand.TARGET_TEMP_CONST, "20.5", 3) update_full_status.assert_called_once_with( { - v.OTGW: {v.OTGW_SETP_OVRD_MODE: v.OTGW_SETP_OVRD_PERMANENT}, - v.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 20.5}, + OpenThermDataSource.GATEWAY: { + v.OTGW_SETP_OVRD_MODE: v.OTGW_SETP_OVRD_PERMANENT + }, + OpenThermDataSource.THERMOSTAT: {v.DATA_ROOM_SETPOINT_OVRD: 20.5}, } ) @@ -300,7 +309,7 @@ async def test_set_temp_sensor_function(pygw): assert await pygw.set_temp_sensor_function("O") is None wait_for_cmd.assert_called_once_with( - v.OTGW_CMD_TEMP_SENSOR, + OpenThermCommand.TEMP_SENSOR, "O", v.OTGW_DEFAULT_TIMEOUT, ) @@ -314,8 +323,10 @@ async def test_set_temp_sensor_function(pygw): ) as update_status: assert await pygw.set_temp_sensor_function("R", timeout=5) == "R" - wait_for_cmd.assert_called_once_with(v.OTGW_CMD_TEMP_SENSOR, "R", 5) - update_status.assert_called_once_with(v.OTGW, {v.OTGW_TEMP_SENSOR: "R"}) + wait_for_cmd.assert_called_once_with(OpenThermCommand.TEMP_SENSOR, "R", 5) + update_status.assert_called_once_with( + OpenThermDataSource.GATEWAY, {v.OTGW_TEMP_SENSOR: "R"} + ) @pytest.mark.asyncio @@ -329,7 +340,7 @@ async def test_set_outside_temp(pygw): with patch.object(pygw, "_wait_for_cmd", return_value=None) as wait_for_cmd: assert await pygw.set_outside_temp(0, timeout=5) is None - wait_for_cmd.assert_called_once_with(v.OTGW_CMD_OUTSIDE_TEMP, "0.0", 5) + wait_for_cmd.assert_called_once_with(OpenThermCommand.OUTSIDE_TEMP, "0.0", 5) with patch.object( pygw, @@ -341,9 +352,11 @@ async def test_set_outside_temp(pygw): assert await pygw.set_outside_temp(23.5) == 23.5 wait_for_cmd.assert_called_once_with( - v.OTGW_CMD_OUTSIDE_TEMP, "23.5", v.OTGW_DEFAULT_TIMEOUT + OpenThermCommand.OUTSIDE_TEMP, "23.5", v.OTGW_DEFAULT_TIMEOUT + ) + update_status.assert_called_once_with( + OpenThermDataSource.THERMOSTAT, {v.DATA_OUTSIDE_TEMP: 23.5} ) - update_status.assert_called_once_with(v.THERMOSTAT, {v.DATA_OUTSIDE_TEMP: 23.5}) with patch.object( pygw, @@ -356,9 +369,11 @@ async def test_set_outside_temp(pygw): assert await pygw.set_outside_temp(99) == "-" wait_for_cmd.assert_called_once_with( - v.OTGW_CMD_OUTSIDE_TEMP, "99.0", v.OTGW_DEFAULT_TIMEOUT + OpenThermCommand.OUTSIDE_TEMP, "99.0", v.OTGW_DEFAULT_TIMEOUT + ) + update_status.assert_called_once_with( + OpenThermDataSource.THERMOSTAT, {v.DATA_OUTSIDE_TEMP: 0.0} ) - update_status.assert_called_once_with(v.THERMOSTAT, {v.DATA_OUTSIDE_TEMP: 0.0}) @pytest.mark.asyncio @@ -370,13 +385,13 @@ async def test_set_clock(pygw): assert await pygw.set_clock(dt) == "12:34/5" wait_for_cmd.assert_called_once_with( - v.OTGW_CMD_SET_CLOCK, "12:34/5", v.OTGW_DEFAULT_TIMEOUT + OpenThermCommand.SET_CLOCK, "12:34/5", v.OTGW_DEFAULT_TIMEOUT ) with patch.object(pygw, "_wait_for_cmd", return_value="12:34/5") as wait_for_cmd: assert await pygw.set_clock(dt, timeout=5) == "12:34/5" - wait_for_cmd.assert_called_once_with(v.OTGW_CMD_SET_CLOCK, "12:34/5", 5) + wait_for_cmd.assert_called_once_with(OpenThermCommand.SET_CLOCK, "12:34/5", 5) @pytest.mark.asyncio @@ -428,6 +443,43 @@ async def test_get_status(pygw): assert await pygw.get_status() == pygw_status.expect_4 +@pytest.mark.asyncio +@pytest.mark.parametrize( + REPORT_TEST_PARAMETERS, + REPORT_TEST_VALUES, +) +async def test_get_report( + pygw, + report: OpenThermReport, + response: str, + expected_dict: dict[OpenThermDataSource, dict], +) -> None: + """Test pyotgw.get_report()""" + pygw._wait_for_cmd = AsyncMock(return_value=response) + pygw.status.submit_full_update = MagicMock() + + assert await pygw.get_report(report) is not None + pygw._wait_for_cmd.assert_called_once_with( + OpenThermCommand.REPORT, report, v.OTGW_DEFAULT_TIMEOUT + ) + pygw.status.submit_full_update.assert_called_once_with(expected_dict) + + +@pytest.mark.asyncio +async def test_restart_gateway(pygw): + """Test pyotgw.restart_gateway()""" + with patch.object(pygw, "_wait_for_cmd", side_effect=[True, None]), patch.object( + pygw, "get_reports" + ) as get_reports, patch.object( + pygw, + "get_status", + ) as get_status: + assert await pygw.restart_gateway() == v.DEFAULT_STATUS + assert await pygw.restart_gateway() is None + get_reports.assert_called_once() + get_status.assert_called_once() + + @pytest.mark.asyncio async def test_set_hot_water_ovrd(pygw): """Test pyotgw.set_hot_water_ovrd()""" @@ -447,10 +499,10 @@ async def test_set_hot_water_ovrd(pygw): assert wait_for_cmd.call_count == 4 wait_for_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_HOT_WATER, 0, v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_HOT_WATER, "A", 5), - call(v.OTGW_CMD_HOT_WATER, 1, v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_HOT_WATER, "P", v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.HOT_WATER, 0, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.HOT_WATER, "A", 5), + call(OpenThermCommand.HOT_WATER, 1, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.HOT_WATER, "P", v.OTGW_DEFAULT_TIMEOUT), ], any_order=False, ) @@ -458,31 +510,33 @@ async def test_set_hot_water_ovrd(pygw): assert update_status.call_count == 2 update_status.assert_has_calls( [ - call(v.OTGW, {v.OTGW_DHW_OVRD: "A"}), - call(v.OTGW, {v.OTGW_DHW_OVRD: 1}), + call(OpenThermDataSource.GATEWAY, {v.OTGW_DHW_OVRD: "A"}), + call(OpenThermDataSource.GATEWAY, {v.OTGW_DHW_OVRD: 1}), ], any_order=False, ) @pytest.mark.asyncio -async def test_set_mode(pygw): +@pytest.mark.parametrize( + ("mode", "response"), + [ + (OpenThermGatewayOpMode.MONITOR, 0), + (OpenThermGatewayOpMode.GATEWAY, 1), + ], +) +async def test_set_mode_valid(pygw, mode, response): """Test pyotgw.set_mode()""" - with patch.object(pygw, "_wait_for_cmd", side_effect=[None, v.OTGW_MODE_MONITOR]): - assert await pygw.set_mode(v.OTGW_MODE_GATEWAY) is None - assert await pygw.set_mode(v.OTGW_MODE_MONITOR) == v.OTGW_MODE_MONITOR + with patch.object(pygw, "_wait_for_cmd", return_value=response): + assert await pygw.set_mode(mode) == mode - with patch.object( - pygw, - "_wait_for_cmd", - return_value=v.OTGW_MODE_RESET, - ), patch.object(pygw, "get_reports") as get_reports, patch.object( - pygw, - "get_status", - ) as get_status: - assert await pygw.set_mode(v.OTGW_MODE_RESET) == v.DEFAULT_STATUS - get_reports.assert_called_once() - get_status.assert_called_once() + +@pytest.mark.asyncio +async def test_set_mode_invalid(pygw): + """Test pyotgw.set_mode()""" + with patch.object(pygw, "_wait_for_cmd", return_value=None): + assert await pygw.set_mode(OpenThermGatewayOpMode.GATEWAY) is None + assert await pygw.set_mode("invalid") is None @pytest.mark.asyncio @@ -503,13 +557,15 @@ async def test_set_led_mode(pygw): assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_LED_B, "H", v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_LED_A, "X", 5), + call(OpenThermCommand.LED_B, "H", v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.LED_A, "X", 5), ], any_order=False, ) - update_status.assert_called_once_with(v.OTGW, {v.OTGW_LED_A: "X"}) + update_status.assert_called_once_with( + OpenThermDataSource.GATEWAY, {v.OTGW_LED_A: "X"} + ) @pytest.mark.asyncio @@ -531,13 +587,15 @@ async def test_set_gpio_mode(pygw): assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_GPIO_A, 6, v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_GPIO_B, 3, 5), + call(OpenThermCommand.GPIO_A, 6, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.GPIO_B, 3, 5), ], any_order=False, ) - update_status.assert_called_once_with(v.OTGW, {v.OTGW_GPIO_B: 3}) + update_status.assert_called_once_with( + OpenThermDataSource.GATEWAY, {v.OTGW_GPIO_B: 3} + ) @pytest.mark.asyncio @@ -556,13 +614,15 @@ async def test_set_setback_temp(pygw): assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_SETBACK, 17.5, v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_SETBACK, 16.5, 5), + call(OpenThermCommand.SETBACK, 17.5, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.SETBACK, 16.5, 5), ], any_order=False, ) - update_status.assert_called_once_with(v.OTGW, {v.OTGW_SB_TEMP: 16.5}) + update_status.assert_called_once_with( + OpenThermDataSource.GATEWAY, {v.OTGW_SB_TEMP: 16.5} + ) @pytest.mark.asyncio @@ -577,8 +637,8 @@ async def test_add_alternative(pygw): assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_ADD_ALT, 20, v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_ADD_ALT, 23, 5), + call(OpenThermCommand.ADD_ALT, 20, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.ADD_ALT, 23, 5), ], any_order=False, ) @@ -596,8 +656,8 @@ async def test_del_alternative(pygw): assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_DEL_ALT, 20, v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_DEL_ALT, 23, 5), + call(OpenThermCommand.DEL_ALT, 20, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.DEL_ALT, 23, 5), ], any_order=False, ) @@ -615,8 +675,8 @@ async def test_add_unknown_id(pygw): assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_UNKNOWN_ID, 20, v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_UNKNOWN_ID, 23, 5), + call(OpenThermCommand.UNKNOWN_ID, 20, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.UNKNOWN_ID, 23, 5), ], any_order=False, ) @@ -634,8 +694,8 @@ async def test_del_unknown_id(pygw): assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_KNOWN_ID, 20, v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_KNOWN_ID, 23, 5), + call(OpenThermCommand.KNOWN_ID, 20, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.KNOWN_ID, 23, 5), ], any_order=False, ) @@ -658,13 +718,15 @@ async def test_set_max_ch_setpoint(pygw): assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_SET_MAX, 75.5, v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_SET_MAX, 74.5, 5), + call(OpenThermCommand.SET_MAX, 75.5, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.SET_MAX, 74.5, 5), ], any_order=False, ) - update_status.assert_called_once_with(v.BOILER, {v.DATA_MAX_CH_SETPOINT: 74.5}) + update_status.assert_called_once_with( + OpenThermDataSource.BOILER, {v.DATA_MAX_CH_SETPOINT: 74.5} + ) @pytest.mark.asyncio @@ -684,13 +746,15 @@ async def test_set_dhw_setpoint(pygw): assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_SET_WATER, 55.5, v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_SET_WATER, 54.5, 5), + call(OpenThermCommand.SET_WATER, 55.5, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.SET_WATER, 54.5, 5), ], any_order=False, ) - update_status.assert_called_once_with(v.BOILER, {v.DATA_DHW_SETPOINT: 54.5}) + update_status.assert_called_once_with( + OpenThermDataSource.BOILER, {v.DATA_DHW_SETPOINT: 54.5} + ) @pytest.mark.asyncio @@ -715,10 +779,10 @@ async def test_set_max_relative_mod(pygw): assert wait_for_cmd.call_count == 4 wait_for_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_MAX_MOD, "R", v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_MAX_MOD, 56, v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_MAX_MOD, 54, 5), - call(v.OTGW_CMD_MAX_MOD, 55, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.MAX_MOD, "R", v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.MAX_MOD, 56, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.MAX_MOD, 54, 5), + call(OpenThermCommand.MAX_MOD, 55, v.OTGW_DEFAULT_TIMEOUT), ], any_order=False, ) @@ -726,7 +790,7 @@ async def test_set_max_relative_mod(pygw): assert update_status.call_count == 1 update_status.assert_has_calls( [ - call(v.BOILER, {v.DATA_SLAVE_MAX_RELATIVE_MOD: 55}), + call(OpenThermDataSource.BOILER, {v.DATA_SLAVE_MAX_RELATIVE_MOD: 55}), ], any_order=False, ) @@ -749,13 +813,15 @@ async def test_set_control_setpoint(pygw): assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_CONTROL_SETPOINT, 21.5, v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_CONTROL_SETPOINT, 19.5, 5), + call(OpenThermCommand.CONTROL_SETPOINT, 21.5, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.CONTROL_SETPOINT, 19.5, 5), ], any_order=False, ) - update_status.assert_called_once_with(v.BOILER, {v.DATA_CONTROL_SETPOINT: 19.5}) + update_status.assert_called_once_with( + OpenThermDataSource.BOILER, {v.DATA_CONTROL_SETPOINT: 19.5} + ) @pytest.mark.asyncio @@ -775,13 +841,15 @@ async def test_set_control_setpoint_2(pygw): assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_CONTROL_SETPOINT_2, 21.5, v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_CONTROL_SETPOINT_2, 19.5, 5), + call(OpenThermCommand.CONTROL_SETPOINT_2, 21.5, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.CONTROL_SETPOINT_2, 19.5, 5), ], any_order=False, ) - update_status.assert_called_once_with(v.BOILER, {v.DATA_CONTROL_SETPOINT_2: 19.5}) + update_status.assert_called_once_with( + OpenThermDataSource.BOILER, {v.DATA_CONTROL_SETPOINT_2: 19.5} + ) @pytest.mark.asyncio @@ -802,13 +870,15 @@ async def test_set_ch_enable_bit(pygw): assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_CONTROL_HEATING, 0, v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_CONTROL_HEATING, 1, 5), + call(OpenThermCommand.CONTROL_HEATING, 0, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.CONTROL_HEATING, 1, 5), ], any_order=False, ) - update_status.assert_called_once_with(v.BOILER, {v.DATA_MASTER_CH_ENABLED: 1}) + update_status.assert_called_once_with( + OpenThermDataSource.BOILER, {v.DATA_MASTER_CH_ENABLED: 1} + ) @pytest.mark.asyncio @@ -830,13 +900,15 @@ async def test_set_ch2_enable_bit(pygw): assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_CONTROL_HEATING_2, 0, v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_CONTROL_HEATING_2, 1, 5), + call(OpenThermCommand.CONTROL_HEATING_2, 0, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.CONTROL_HEATING_2, 1, 5), ], any_order=False, ) - update_status.assert_called_once_with(v.BOILER, {v.DATA_MASTER_CH2_ENABLED: 1}) + update_status.assert_called_once_with( + OpenThermDataSource.BOILER, {v.DATA_MASTER_CH2_ENABLED: 1} + ) @pytest.mark.asyncio @@ -858,13 +930,15 @@ async def test_set_ventilation(pygw): assert wait_for_cmd.call_count == 2 wait_for_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_VENT, 25, v.OTGW_DEFAULT_TIMEOUT), - call(v.OTGW_CMD_VENT, 75, 5), + call(OpenThermCommand.VENT, 25, v.OTGW_DEFAULT_TIMEOUT), + call(OpenThermCommand.VENT, 75, 5), ], any_order=False, ) - update_status.assert_called_once_with(v.BOILER, {v.DATA_COOLING_CONTROL: 75}) + update_status.assert_called_once_with( + OpenThermDataSource.BOILER, {v.DATA_COOLING_CONTROL: 75} + ) @pytest.mark.asyncio @@ -918,21 +992,19 @@ async def test_wait_for_cmd(caplog, pygw, pygw_proto): pygw_proto.command_processor, "issue_cmd", side_effect=[None, "0", asyncio.TimeoutError, ValueError], - ) as issue_cmd, caplog.at_level( - logging.ERROR - ): - assert await pygw._wait_for_cmd(v.OTGW_CMD_MODE, "G") is None - assert await pygw._wait_for_cmd(v.OTGW_CMD_SUMMARY, 0) == "0" - assert await pygw._wait_for_cmd(v.OTGW_CMD_REPORT, "I", 1) is None - assert await pygw._wait_for_cmd(v.OTGW_CMD_MAX_MOD, -1) is None + ) as issue_cmd, caplog.at_level(logging.ERROR): + assert await pygw._wait_for_cmd(OpenThermCommand.MODE, "G") is None + assert await pygw._wait_for_cmd(OpenThermCommand.SUMMARY, 0) == "0" + assert await pygw._wait_for_cmd(OpenThermCommand.REPORT, "I", 1) is None + assert await pygw._wait_for_cmd(OpenThermCommand.MAX_MOD, -1) is None assert issue_cmd.await_count == 4 issue_cmd.assert_has_awaits( [ - call(v.OTGW_CMD_MODE, "G"), - call(v.OTGW_CMD_SUMMARY, 0), - call(v.OTGW_CMD_REPORT, "I"), - call(v.OTGW_CMD_MAX_MOD, -1), + call(OpenThermCommand.MODE, "G"), + call(OpenThermCommand.SUMMARY, 0), + call(OpenThermCommand.REPORT, "I"), + call(OpenThermCommand.MAX_MOD, -1), ], any_order=False, ) @@ -941,66 +1013,11 @@ async def test_wait_for_cmd(caplog, pygw, pygw_proto): ( "pyotgw.pyotgw", logging.ERROR, - f"Timed out waiting for command: {v.OTGW_CMD_REPORT}, value: I.", + f"Timed out waiting for command: {OpenThermCommand.REPORT}, value: I.", ), ( "pyotgw.pyotgw", logging.ERROR, - f"Command {v.OTGW_CMD_MAX_MOD} with value -1 raised exception: ", + f"Command {OpenThermCommand.MAX_MOD} with value -1 raised exception: ", ), ] - - -@pytest.mark.asyncio -async def test_poll_gpio(caplog, pygw): - """Test pyotgw._poll_gpio()""" - pygw._gpio_task = None - pygw.loop = asyncio.get_running_loop() - pygw.status.submit_partial_update(v.OTGW, {v.OTGW_GPIO_A: 4, v.OTGW_GPIO_B: 1}) - - with caplog.at_level(logging.DEBUG): - await pygw._poll_gpio() - assert len(caplog.records) == 0 - - pygw.status.submit_partial_update(v.OTGW, {v.OTGW_GPIO_B: 0}) - with patch.object( - pygw, - "_wait_for_cmd", - return_value="I=10", - ) as wait_for_cmd, patch.object( - pygw.status, - "submit_partial_update", - ) as update_status, caplog.at_level( - logging.DEBUG - ): - await pygw._poll_gpio() - await called_once(update_status) - - assert isinstance(pygw._gpio_task, asyncio.Task) - wait_for_cmd.assert_awaited_once_with(v.OTGW_CMD_REPORT, v.OTGW_REPORT_GPIO_STATES) - update_status.assert_called_once_with( - v.OTGW, - {v.OTGW_GPIO_A_STATE: 1, v.OTGW_GPIO_B_STATE: 0}, - ) - assert caplog.record_tuples == [ - ("pyotgw.pyotgw", logging.DEBUG, "Starting GPIO polling routine"), - ] - - caplog.clear() - pygw.status.submit_partial_update(v.OTGW, {v.OTGW_GPIO_B: 1}) - with patch.object( - pygw.status, - "submit_partial_update", - ) as update_status, caplog.at_level(logging.DEBUG): - await pygw._poll_gpio() - await called_once(update_status) - - assert pygw._gpio_task is None - update_status.assert_called_once_with( - v.OTGW, - {v.OTGW_GPIO_A_STATE: 0, v.OTGW_GPIO_B_STATE: 0}, - ) - assert caplog.record_tuples == [ - ("pyotgw.pyotgw", logging.DEBUG, "Stopping GPIO polling routine"), - ("pyotgw.pyotgw", logging.DEBUG, "GPIO polling routine stopped"), - ] diff --git a/tests/test_reports.py b/tests/test_reports.py new file mode 100644 index 0000000..26af697 --- /dev/null +++ b/tests/test_reports.py @@ -0,0 +1,194 @@ +"""Tests for pyotgw/reports.py.""" + +from collections.abc import Callable + +import pytest + +import pyotgw.vars as v +from pyotgw.reports import _CONVERSIONS, convert_report_response_to_status_update +from pyotgw.types import ( + OpenThermDataSource, + OpenThermGatewayOpMode, + OpenThermGPIOMode, + OpenThermHotWaterOverrideMode, + OpenThermLEDMode, + OpenThermReport, + OpenThermResetCause, + OpenThermSetpointOverrideMode, + OpenThermSmartPowerMode, + OpenThermTemperatureSensorFunction, + OpenThermThermostatDetection, + OpenThermVoltageReferenceLevel, +) + +REPORT_TEST_PARAMETERS = ("report", "response", "expected_dict") + +REPORT_TEST_VALUES = [ + ( + OpenThermReport.ABOUT, + "A=Test version 1.0", + {OpenThermDataSource.GATEWAY: {v.OTGW_ABOUT: "Test version 1.0"}}, + ), + ( + OpenThermReport.BUILD, + "B=17:52 12-03-2023", + {OpenThermDataSource.GATEWAY: {v.OTGW_BUILD: "17:52 12-03-2023"}}, + ), + ( + OpenThermReport.CLOCK_SPEED, + "C=4 MHz", + {OpenThermDataSource.GATEWAY: {v.OTGW_CLOCKMHZ: "4 MHz"}}, + ), + ( + OpenThermReport.TEMP_SENSOR_FUNCTION, + "D=R", + { + OpenThermDataSource.GATEWAY: { + v.OTGW_TEMP_SENSOR: OpenThermTemperatureSensorFunction.RETURN_WATER_TEMPERATURE + } + }, + ), + ( + OpenThermReport.GPIO_MODES, + "G=46", + { + OpenThermDataSource.GATEWAY: { + v.OTGW_GPIO_A: OpenThermGPIOMode.LED_F, + v.OTGW_GPIO_B: OpenThermGPIOMode.AWAY, + }, + }, + ), + ( + OpenThermReport.GPIO_STATES, + "I=10", + { + OpenThermDataSource.GATEWAY: { + v.OTGW_GPIO_A_STATE: 1, + v.OTGW_GPIO_B_STATE: 0, + } + }, + ), + ( + OpenThermReport.LED_MODES, + "L=HWCEMP", + { + OpenThermDataSource.GATEWAY: { + v.OTGW_LED_A: OpenThermLEDMode.CENTRAL_HEATING_ON, + v.OTGW_LED_B: OpenThermLEDMode.HOT_WATER_ON, + v.OTGW_LED_C: OpenThermLEDMode.COMFORT_MODE_ON, + v.OTGW_LED_D: OpenThermLEDMode.TX_ERROR_DETECTED, + v.OTGW_LED_E: OpenThermLEDMode.BOILER_MAINTENANCE_REQUIRED, + v.OTGW_LED_F: OpenThermLEDMode.RAISED_POWER_MODE_ACTIVE, + }, + }, + ), + ( + OpenThermReport.OP_MODE, + "M=G", + {OpenThermDataSource.GATEWAY: {v.OTGW_MODE: OpenThermGatewayOpMode.GATEWAY}}, + ), + ( + OpenThermReport.SETPOINT_OVERRIDE, + "O=c17.25", + { + OpenThermDataSource.GATEWAY: { + v.OTGW_SETP_OVRD_MODE: OpenThermSetpointOverrideMode.CONSTANT, + }, + OpenThermDataSource.THERMOSTAT: { + v.DATA_ROOM_SETPOINT_OVRD: 17.25, + }, + }, + ), + ( + OpenThermReport.SMART_PWR_MODE, + "P=Medium power", + { + OpenThermDataSource.GATEWAY: { + v.OTGW_SMART_PWR: OpenThermSmartPowerMode.MEDIUM + } + }, + ), + ( + OpenThermReport.RESET_CAUSE, + "Q=B", + {OpenThermDataSource.GATEWAY: {v.OTGW_RST_CAUSE: OpenThermResetCause.BROWNOUT}}, + ), + ( + OpenThermReport.THERMOSTAT_DETECTION_STATE, + "R=C", + { + OpenThermDataSource.GATEWAY: { + v.OTGW_THRM_DETECT: OpenThermThermostatDetection.CELCIA_20 + } + }, + ), + ( + OpenThermReport.SETBACK_TEMPERATURE, + "S=15.1", + {OpenThermDataSource.GATEWAY: {v.OTGW_SB_TEMP: 15.1}}, + ), + ( + OpenThermReport.TWEAKS, + "T=10", + { + OpenThermDataSource.GATEWAY: { + v.OTGW_IGNORE_TRANSITIONS: 1, + v.OTGW_OVRD_HB: 0, + } + }, + ), + ( + OpenThermReport.VREF, + "V=6", + { + OpenThermDataSource.GATEWAY: { + v.OTGW_VREF: OpenThermVoltageReferenceLevel.LEVEL_6 + } + }, + ), + ( + OpenThermReport.DHW, + "W=1", + { + OpenThermDataSource.GATEWAY: { + v.OTGW_DHW_OVRD: OpenThermHotWaterOverrideMode.FORCE_ON + } + }, + ), +] + + +def test_conversion_dict() -> None: + """Test the structure of the _CONVERSIONS dict.""" + for key, value in _CONVERSIONS.items(): + assert isinstance(key, OpenThermReport) + assert isinstance(value, Callable) + + +@pytest.mark.parametrize( + REPORT_TEST_PARAMETERS, + REPORT_TEST_VALUES, +) +def test_command_conversion_ok( + report: OpenThermReport, + response: str, + expected_dict: dict[OpenThermDataSource, dict], +) -> None: + """Test command conversions when all goes well.""" + assert convert_report_response_to_status_update(report, response) == expected_dict + + +@pytest.mark.parametrize( + ("report", "response"), + [ + (OpenThermReport.GPIO_STATES, "cant_be_cast_to_int"), + (OpenThermReport.RESET_CAUSE, "not_a_valid_cause"), + ("not_a_command", None), + ], +) +def test_command_conversion_not_ok( + report: OpenThermReport, + response: str, +) -> None: + """Test command conversion with invalid input.""" + assert convert_report_response_to_status_update(report, response) is None diff --git a/tests/test_status.py b/tests/test_status.py index 83dcb86..40d1494 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -1,4 +1,5 @@ """Tests for pyotgw/status.py""" + import asyncio import logging from unittest.mock import MagicMock @@ -6,6 +7,7 @@ import pytest import pyotgw.vars as v +from pyotgw.types import OpenThermDataSource from tests.helpers import called_once @@ -13,7 +15,7 @@ def test_reset(pygw_status): """Test StatusManager.reset()""" assert pygw_status.status == v.DEFAULT_STATUS - pygw_status.submit_partial_update(v.OTGW, {"Test": "value"}) + pygw_status.submit_partial_update(OpenThermDataSource.GATEWAY, {"Test": "value"}) assert pygw_status.status != v.DEFAULT_STATUS assert not pygw_status._updateq.empty() @@ -33,12 +35,16 @@ def test_status(pygw_status): def test_delete_value(pygw_status): """Test StatusManager.delete_value()""" assert not pygw_status.delete_value("Invalid", v.OTGW_MODE) - assert not pygw_status.delete_value(v.OTGW, v.OTGW_MODE) + assert not pygw_status.delete_value(OpenThermDataSource.GATEWAY, v.OTGW_MODE) - pygw_status.submit_partial_update(v.THERMOSTAT, {v.DATA_ROOM_SETPOINT: 20.5}) + pygw_status.submit_partial_update( + OpenThermDataSource.THERMOSTAT, {v.DATA_ROOM_SETPOINT: 20.5} + ) pygw_status._updateq.get_nowait() - assert pygw_status.delete_value(v.THERMOSTAT, v.DATA_ROOM_SETPOINT) + assert pygw_status.delete_value( + OpenThermDataSource.THERMOSTAT, v.DATA_ROOM_SETPOINT + ) assert pygw_status._updateq.get_nowait() == v.DEFAULT_STATUS @@ -58,42 +64,50 @@ def test_submit_partial_update(caplog, pygw_status): caplog.clear() with caplog.at_level(logging.ERROR): - assert not pygw_status.submit_partial_update(v.OTGW, "Invalid") + assert not pygw_status.submit_partial_update( + OpenThermDataSource.GATEWAY, "Invalid" + ) assert pygw_status._updateq.empty() assert caplog.record_tuples == [ ( "pyotgw.status", logging.ERROR, - f"Update for {v.OTGW} is not a dict: Invalid", + f"Update for {OpenThermDataSource.GATEWAY} is not a dict: Invalid", ), ] caplog.clear() - pygw_status.submit_partial_update(v.BOILER, {v.DATA_CONTROL_SETPOINT: 1.5}) - pygw_status.submit_partial_update(v.OTGW, {v.OTGW_ABOUT: "test value"}) - pygw_status.submit_partial_update(v.THERMOSTAT, {v.DATA_ROOM_SETPOINT: 20}) + pygw_status.submit_partial_update( + OpenThermDataSource.BOILER, {v.DATA_CONTROL_SETPOINT: 1.5} + ) + pygw_status.submit_partial_update( + OpenThermDataSource.GATEWAY, {v.OTGW_ABOUT: "test value"} + ) + pygw_status.submit_partial_update( + OpenThermDataSource.THERMOSTAT, {v.DATA_ROOM_SETPOINT: 20} + ) assert pygw_status.status == { - v.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, - v.OTGW: {v.OTGW_ABOUT: "test value"}, - v.THERMOSTAT: {v.DATA_ROOM_SETPOINT: 20}, + OpenThermDataSource.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, + OpenThermDataSource.GATEWAY: {v.OTGW_ABOUT: "test value"}, + OpenThermDataSource.THERMOSTAT: {v.DATA_ROOM_SETPOINT: 20}, } assert pygw_status._updateq.qsize() == 3 assert pygw_status._updateq.get_nowait() == { - v.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, - v.OTGW: {}, - v.THERMOSTAT: {}, + OpenThermDataSource.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, + OpenThermDataSource.GATEWAY: {}, + OpenThermDataSource.THERMOSTAT: {}, } assert pygw_status._updateq.get_nowait() == { - v.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, - v.OTGW: {v.OTGW_ABOUT: "test value"}, - v.THERMOSTAT: {}, + OpenThermDataSource.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, + OpenThermDataSource.GATEWAY: {v.OTGW_ABOUT: "test value"}, + OpenThermDataSource.THERMOSTAT: {}, } assert pygw_status._updateq.get_nowait() == { - v.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, - v.OTGW: {v.OTGW_ABOUT: "test value"}, - v.THERMOSTAT: {v.DATA_ROOM_SETPOINT: 20}, + OpenThermDataSource.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, + OpenThermDataSource.GATEWAY: {v.OTGW_ABOUT: "test value"}, + OpenThermDataSource.THERMOSTAT: {v.DATA_ROOM_SETPOINT: 20}, } @@ -117,36 +131,36 @@ def test_submit_full_update(caplog, pygw_status): caplog.clear() with caplog.at_level(logging.ERROR): - pygw_status.submit_full_update({v.OTGW: "Invalid"}) + pygw_status.submit_full_update({OpenThermDataSource.GATEWAY: "Invalid"}) assert pygw_status._updateq.empty() assert caplog.record_tuples == [ ( "pyotgw.status", logging.ERROR, - f"Update for {v.OTGW} is not a dict: Invalid", + f"Update for {OpenThermDataSource.GATEWAY} is not a dict: Invalid", ), ] caplog.clear() pygw_status.submit_full_update( { - v.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, - v.OTGW: {v.OTGW_ABOUT: "test value"}, - v.THERMOSTAT: {v.DATA_ROOM_SETPOINT: 20}, + OpenThermDataSource.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, + OpenThermDataSource.GATEWAY: {v.OTGW_ABOUT: "test value"}, + OpenThermDataSource.THERMOSTAT: {v.DATA_ROOM_SETPOINT: 20}, } ) assert pygw_status.status == { - v.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, - v.OTGW: {v.OTGW_ABOUT: "test value"}, - v.THERMOSTAT: {v.DATA_ROOM_SETPOINT: 20}, + OpenThermDataSource.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, + OpenThermDataSource.GATEWAY: {v.OTGW_ABOUT: "test value"}, + OpenThermDataSource.THERMOSTAT: {v.DATA_ROOM_SETPOINT: 20}, } assert pygw_status._updateq.qsize() == 1 assert pygw_status._updateq.get_nowait() == { - v.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, - v.OTGW: {v.OTGW_ABOUT: "test value"}, - v.THERMOSTAT: {v.DATA_ROOM_SETPOINT: 20}, + OpenThermDataSource.BOILER: {v.DATA_CONTROL_SETPOINT: 1.5}, + OpenThermDataSource.GATEWAY: {v.OTGW_ABOUT: "test value"}, + OpenThermDataSource.THERMOSTAT: {v.DATA_ROOM_SETPOINT: 20}, } @@ -212,14 +226,16 @@ async def empty_callback_2(status): pygw_status.subscribe(mock_callback_1) pygw_status.subscribe(mock_callback_2) - pygw_status.submit_partial_update(v.OTGW, {v.OTGW_ABOUT: "Test Value"}) + pygw_status.submit_partial_update( + OpenThermDataSource.GATEWAY, {v.OTGW_ABOUT: "Test Value"} + ) await asyncio.gather(called_once(mock_callback_1), called_once(mock_callback_2)) for mock in (mock_callback_1, mock_callback_2): mock.assert_called_once_with( { - v.BOILER: {}, - v.OTGW: {v.OTGW_ABOUT: "Test Value"}, - v.THERMOSTAT: {}, + OpenThermDataSource.BOILER: {}, + OpenThermDataSource.GATEWAY: {v.OTGW_ABOUT: "Test Value"}, + OpenThermDataSource.THERMOSTAT: {}, } ) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 46b3dd7..0000000 --- a/tox.ini +++ /dev/null @@ -1,37 +0,0 @@ -# tox (https://tox.readthedocs.io/) is a tool for running tests -# in multiple virtualenvs. This configuration file will run the -# test suite on all supported python versions. To use it, "pip install tox" -# and then run "tox" from this directory. - -[tox] -envlist = clean, ruff-lint, py310, py311, py312 -skip_missing_interpreters = True - -[testenv] -commands = - pytest --cov --cov-append --cov-report=term-missing {posargs} -deps = - -rrequirements_test.txt - -[testenv:clean] -deps = coverage -skip_install = True -commands = coverage erase - -[testenv:precommit] -deps = - -rrequirements_test.txt -commands = - pre-commit run {posargs: --all-files} - -[testenv:ruff-lint] -deps = - -rrequirements_test.txt -commands = - ruff check pyotgw/ tests/ - -[testenv:ruff-format] -deps = - -rrequirements_test.txt -commands = - ruff format pyotgw/ tests/