Skip to content

Commit

Permalink
Update master (#77)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
mvn23 authored Sep 15, 2024
1 parent 1e73946 commit 273ce8c
Show file tree
Hide file tree
Showing 26 changed files with 1,648 additions and 717 deletions.
18 changes: 6 additions & 12 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
15 changes: 8 additions & 7 deletions pyotgw/commandprocessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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()
Expand All @@ -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]
Expand Down Expand Up @@ -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 (<cmd>: <value>) 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*([^$]+)$"
50 changes: 30 additions & 20 deletions pyotgw/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
48 changes: 36 additions & 12 deletions pyotgw/messageprocessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
"""
Expand All @@ -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.
Expand All @@ -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:
"""
Expand All @@ -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.
Expand All @@ -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]]:
Expand Down Expand Up @@ -159,19 +176,24 @@ 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)
if ovrd_value > 0:
# 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:
Expand All @@ -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(
Expand Down
Loading

0 comments on commit 273ce8c

Please sign in to comment.