diff --git a/custom_components/hunterdouglas_powerview_ble/__init__.py b/custom_components/hunterdouglas_powerview_ble/__init__.py index abbdce4..307c1b1 100644 --- a/custom_components/hunterdouglas_powerview_ble/__init__.py +++ b/custom_components/hunterdouglas_powerview_ble/__init__.py @@ -5,7 +5,6 @@ """ from bleak.exc import BleakError - from homeassistant.components.bluetooth import async_ble_device_from_address from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py index e45fca2..3567b3c 100644 --- a/custom_components/hunterdouglas_powerview_ble/api.py +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -1,28 +1,27 @@ """Hunter Douglas PowerView BLE API.""" import asyncio +import time from dataclasses import dataclass from enum import Enum -import time from typing import Final from bleak import BleakClient from bleak.backends.device import BLEDevice from bleak.exc import BleakError from bleak.uuids import normalize_uuid_str -from bleak_retry_connector import close_stale_connections, establish_connection +from bleak_retry_connector import establish_connection from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - from homeassistant.components.cover import ATTR_CURRENT_POSITION from .const import LOGGER, TIMEOUT -UUID_COV_SERVICE: Final = normalize_uuid_str("fdc1") -UUID_TX: Final = "cafe1001-c0ff-ee01-8000-a110ca7ab1e0" -UUID_DEV_SERVICE: Final = normalize_uuid_str("180a") -UUID_BAT_SERVICE: Final = normalize_uuid_str("180f") +UUID_COV_SERVICE: Final[str] = normalize_uuid_str("fdc1") +UUID_TX: Final[str] = "cafe1001-c0ff-ee01-8000-a110ca7ab1e0" +UUID_DEV_SERVICE: Final[str] = normalize_uuid_str("180a") +UUID_BAT_SERVICE: Final[str] = normalize_uuid_str("180f") -ATTR_ACTIVITY: Final = "activity" +ATTR_ACTIVITY: Final[str] = "activity" SHADE_TYPE: Final[dict[int, str]] = { @@ -82,7 +81,15 @@ class PowerViewBLE: self._ble_device: Final[BLEDevice] = ble_device self.name: Final[str] = self._ble_device.name or "unknown" self._seqcnt: int = 1 - self._client: BleakClient | None = None + self._client: BleakClient = BleakClient( + self._ble_device, + disconnected_callback=self._on_disconnect, + services=[ + UUID_COV_SERVICE, + # self.UUID_DEV_SERVICE, + # self.UUID_BAT_SERVICE, + ], + ) self._data_event = asyncio.Event() self._data: bytearray self._info: PVDeviceInfo = PVDeviceInfo() @@ -106,7 +113,7 @@ class PowerViewBLE: @property def is_connected(self) -> bool: """Return whether remote device is connected.""" - return self._client is not None and self._client.is_connected + return self._client.is_connected # general cmd: uint16_t cmd, uint8_t seqID, uint8_t data_len async def _cmd( @@ -120,7 +127,6 @@ class PowerViewBLE: async with self._cmd_lock: try: await self._connect() - assert self._client is not None, "missing BT client" cmd_run = self._cmd_next tx_data = ( bytearray( @@ -156,7 +162,7 @@ class PowerViewBLE: LOGGER.debug("not a V2 record!") return [] pos = int.from_bytes(data[3:5], byteorder="little") - pos2 = ((int(data[5]) << 4) + (int(data[4]) >> 4)) + pos2 = (int(data[5]) << 4) + (int(data[4]) >> 4) return [ (ATTR_CURRENT_POSITION, ((pos >> 2) / 10)), ("position2", pos2 >> 2), @@ -215,12 +221,12 @@ class PowerViewBLE: ), ) - def _verify_response(self, input: bytearray, seq_nr: int, cmd: ShadeCmd) -> bool: + def _verify_response(self, din: bytearray, seq_nr: int, cmd: ShadeCmd) -> bool: """Verify shade response data.""" - data = input + data: bytearray = din if self._cipher is not None: dec = self._cipher.decryptor() - data = dec.update(input) + dec.finalize() + data = bytearray(dec.update(din) + dec.finalize()) if len(data) < 4: LOGGER.error("Reponse message too short") return False @@ -229,14 +235,14 @@ class PowerViewBLE: return False if int(data[2]) != seq_nr: LOGGER.warning( - f"Response sequence id {int(data[2])} wrong, expected {seq_nr}" + "Response sequence id %i wrong, expected %d", int(data[2]), seq_nr ) return False if int(data[3]) != 1: LOGGER.error("Wrong response data length") return False if int(data[4] != 0): - LOGGER.error(f"Command {cmd.value} returned error #{int(data[4])}") + LOGGER.error("Command %d returned error #%d", cmd.value, int(data[4])) return False return True @@ -255,7 +261,6 @@ class PowerViewBLE: async with self._cmd_lock: try: await self._connect() - assert self._client is not None for key, uuid in uuids.items(): LOGGER.debug("querying %s(%s)", key, uuid) @@ -265,7 +270,7 @@ class PowerViewBLE: .decode("UTF-8") ) finally: - await self._disconnect() + await self.disconnect() LOGGER.debug("%s device data: %s", self.name, data) return data.copy() @@ -289,8 +294,6 @@ class PowerViewBLE: return start = time.time() - await close_stale_connections(self._ble_device) - self._client = await establish_connection( BleakClient, self._ble_device, @@ -308,10 +311,10 @@ class PowerViewBLE: # await self._query_dev_info() - async def _disconnect(self) -> None: + async def disconnect(self) -> None: """Disconnect the device and stop notifications.""" - if self._client is not None and self.is_connected: + if self.is_connected: LOGGER.debug("Disconnecting device %s", self.name) try: self._data_event.clear() diff --git a/custom_components/hunterdouglas_powerview_ble/config_flow.py b/custom_components/hunterdouglas_powerview_ble/config_flow.py index 5879d97..24c250a 100644 --- a/custom_components/hunterdouglas_powerview_ble/config_flow.py +++ b/custom_components/hunterdouglas_powerview_ble/config_flow.py @@ -4,7 +4,6 @@ from dataclasses import dataclass from typing import Any import voluptuous as vol - from homeassistant import config_entries from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py index 59198ce..35a526d 100644 --- a/custom_components/hunterdouglas_powerview_ble/coordinator.py +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -3,9 +3,8 @@ from typing import Any from bleak.backends.device import BLEDevice - from homeassistant.components import bluetooth -from homeassistant.components.bluetooth import DOMAIN as BLUETOOTH_DOMAIN +from homeassistant.components.bluetooth.const import DOMAIN as BLUETOOTH_DOMAIN from homeassistant.components.bluetooth.passive_update_coordinator import ( PassiveBluetoothDataUpdateCoordinator, ) @@ -79,6 +78,12 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator): """Check if a device is present.""" return bluetooth.async_address_present(self.hass, self._mac, connectable=True) + def _async_stop(self) -> None: + """Shutdown coordinator and any connection.""" + LOGGER.debug("%s: shuting down BMS device", self.name) + self.hass.async_create_task(self.api.disconnect()) + super()._async_stop() + @callback def _async_handle_bluetooth_event( self, diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py index a35abee..7134592 100644 --- a/custom_components/hunterdouglas_powerview_ble/cover.py +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -3,7 +3,6 @@ from typing import Any, Final from bleak.exc import BleakError - from homeassistant.components.bluetooth.passive_update_coordinator import ( PassiveBluetoothCoordinatorEntity, ) @@ -127,7 +126,10 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti self.async_write_ha_state() except BleakError as err: LOGGER.error( - f"Failed to move cover '{self.name}' to {target_position}%: {err}" + "Failed to move cover '%s' to %f%%: %s", + self.name, + target_position, + err, ) def _reset_target_position(self) -> None: @@ -143,7 +145,7 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti await self._coord.api.open() self.async_write_ha_state() except BleakError as err: - LOGGER.error(f"Failed to open cover '{self.name}': {err}") + LOGGER.error("Failed to open cover '%s': %s", self.name, err) self._reset_target_position() async def async_close_cover(self, **kwargs: Any) -> None: @@ -156,7 +158,7 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti await self._coord.api.close() self.async_write_ha_state() except BleakError as err: - LOGGER.error(f"Failed to close cover '{self.name}': {err}") + LOGGER.error("Failed to close cover '%s': %s", self.name, err) self._reset_target_position() async def async_stop_cover(self, **kwargs: Any) -> None: @@ -167,4 +169,4 @@ class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEnti self._reset_target_position() self.async_write_ha_state() except BleakError as err: - LOGGER.error(f"Failed to stop cover '{self.name}': {err}") + LOGGER.error("Failed to stop cover '%s': %s", self.name, err) diff --git a/custom_components/hunterdouglas_powerview_ble/sensor.py b/custom_components/hunterdouglas_powerview_ble/sensor.py index bb9ea8c..ce275ba 100644 --- a/custom_components/hunterdouglas_powerview_ble/sensor.py +++ b/custom_components/hunterdouglas_powerview_ble/sensor.py @@ -4,9 +4,11 @@ from homeassistant.components.bluetooth.passive_update_coordinator import ( PassiveBluetoothCoordinatorEntity, ) from homeassistant.components.sensor import ( - SensorDeviceClass, SensorEntity, SensorEntityDescription, +) +from homeassistant.components.sensor.const import ( + SensorDeviceClass, SensorStateClass, ) from homeassistant.const import ( diff --git a/pyproject.toml b/pyproject.toml index 404daa0..fd44020 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,11 @@ # pyproject.toml -#[tool.setuptools.packages.find] -#where = ["custom_components/"] -#include = ["bms_ble"] +[project] +requires-python = ">=3.12.0" [tool.pytest.ini_options] minversion = "8.0" -addopts="--cov=custom_components.bms_ble --cov-report=term-missing --cov-fail-under=100" +addopts="--cov=custom_components.hunterdouglas_powerview_ble --cov-report=term-missing --cov-fail-under=100" pythonpath = [ "custom_components.hunterdouglas_powerview_ble", ] @@ -14,3 +13,151 @@ testpaths = [ "tests", ] asyncio_mode = "auto" + +# ruff configuration taken from HA 2024.11.2 (less ignores) +[tool.ruff] +required-version = ">=0.6.8" + +[tool.ruff.lint] +select = [ + "A001", # Variable {name} is shadowing a Python builtin + "ASYNC210", # Async functions should not call blocking HTTP methods + "ASYNC220", # Async functions should not create subprocesses with blocking methods + "ASYNC221", # Async functions should not run processes with blocking methods + "ASYNC222", # Async functions should not wait on processes with blocking methods + "ASYNC230", # Async functions should not open files with blocking methods like open + "ASYNC251", # Async functions should not call time.sleep + "B002", # Python does not support the unary prefix increment + "B005", # Using .strip() with multi-character strings is misleading + "B007", # Loop control variable {name} not used within loop body + "B014", # Exception handler with duplicate exception + "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it. + "B017", # pytest.raises(BaseException) should be considered evil + "B018", # Found useless attribute access. Either assign it to a variable or remove it. + "B023", # Function definition does not bind loop variable {name} + "B026", # Star-arg unpacking after a keyword argument is strongly discouraged + "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? + "B904", # Use raise from to specify exception cause + "B905", # zip() without an explicit strict= parameter + "BLE", + "C", # complexity + "COM818", # Trailing comma on bare tuple prohibited + "D", # docstrings + "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow() + "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) + "E", # pycodestyle + "F", # pyflakes/autoflake + "F541", # f-string without any placeholders + "FLY", # flynt + "FURB", # refurb + "G", # flake8-logging-format + "I", # isort + "INP", # flake8-no-pep420 + "ISC", # flake8-implicit-str-concat + "ICN001", # import concentions; {name} should be imported as {asname} + "LOG", # flake8-logging + "N804", # First argument of a class method should be named cls + "N805", # First argument of a method should be named self + "N815", # Variable {name} in class scope should not be mixedCase + "PERF", # Perflint + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PTH", # flake8-pathlib + "PYI", # flake8-pyi + "RET", # flake8-return + "RSE", # flake8-raise + "RUF005", # Consider iterable unpacking instead of concatenation + "RUF006", # Store a reference to the return value of asyncio.create_task + "RUF010", # Use explicit conversion flag + "RUF013", # PEP 484 prohibits implicit Optional + "RUF017", # Avoid quadratic list summation + "RUF018", # Avoid assignment expressions in assert statements + "RUF019", # Unnecessary key check before dictionary access + # "RUF100", # Unused `noqa` directive; temporarily every now and then to clean them up + "S102", # Use of exec detected + "S103", # bad-file-permissions + "S108", # hardcoded-temp-file + "S306", # suspicious-mktemp-usage + "S307", # suspicious-eval-usage + "S313", # suspicious-xmlc-element-tree-usage + "S314", # suspicious-xml-element-tree-usage + "S315", # suspicious-xml-expat-reader-usage + "S316", # suspicious-xml-expat-builder-usage + "S317", # suspicious-xml-sax-usage + "S318", # suspicious-xml-mini-dom-usage + "S319", # suspicious-xml-pull-dom-usage + "S320", # suspicious-xmle-tree-usage + "S601", # paramiko-call + "S602", # subprocess-popen-with-shell-equals-true + "S604", # call-with-shell-equals-true + "S608", # hardcoded-sql-expression + "S609", # unix-command-wildcard-injection + "SIM", # flake8-simplify + "SLF", # flake8-self + "SLOT", # flake8-slots + "T100", # Trace found: {name} used + "T20", # flake8-print + "TCH", # flake8-type-checking + "TID", # Tidy imports + "TRY", # tryceratops + "UP", # pyupgrade + "UP031", # Use format specifiers instead of percent format + "UP032", # Use f-string instead of `format` call + "W", # pycodestyle +] + +ignore = [ + "D202", # No blank lines allowed after function docstring + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line + "D406", # Section name should end with a newline + "D407", # Section name underlining + "E501", # line too long + +# "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives +# "PLR0911", # Too many return statements ({returns} > {max_returns}) +# "PLR0912", # Too many branches ({branches} > {max_branches}) +# "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) +# "PLR0915", # Too many statements ({statements} > {max_statements}) + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable +# "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target +# "PT004", # Fixture {fixture} does not return anything, add leading underscore +# "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception +# "PT018", # Assertion should be broken down into multiple parts +# "RUF001", # String contains ambiguous unicode character. +# "RUF002", # Docstring contains ambiguous unicode character. +# "RUF003", # Comment contains ambiguous unicode character. +# "RUF015", # Prefer next(...) over single element slice +# "SIM102", # Use a single if statement instead of nested if statements +# "SIM103", # Return the condition {condition} directly +# "SIM108", # Use ternary operator {contents} instead of if-else-block +# "SIM115", # Use context handler for opening files + + # Moving imports into type-checking blocks can mess with pytest.patch() + "TCH001", # Move application import {} into a type-checking block + "TCH002", # Move third-party import {} into a type-checking block + "TCH003", # Move standard library import {} into a type-checking block + + "TRY003", # Avoid specifying long messages outside the exception class + "TRY400", # Use `logging.exception` instead of `logging.error` + # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + + # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q", + "COM812", + "COM819", + "ISC001", + + # Disabled because ruff does not understand type of __all__ generated by a function + "PLE0605" +] + diff --git a/requirements.txt b/requirements.txt index 952be3c..fd1987d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ homeassistant==2024.8.0 pip>=21.3.1 -ruff==0.4.2 +ruff==0.6.8