Clean-up code and upgrade dependencies (#20)

* Update pyproject.toml

* stronger typing

* fix type annotations

* update dependencies

* fix spelling

* add missing info to pyproject.toml

* code cleanup

* add project URLs

* fix mypy issues

* update HA to 2025.11

* upgrade Python to 3.13.2 to match HA

* Update lint.yml
This commit is contained in:
Patrick
2025-12-29 19:36:14 +01:00
committed by GitHub
parent 883aca753e
commit 3775496936
15 changed files with 127 additions and 85 deletions

View File

@@ -18,7 +18,7 @@ jobs:
- name: "Set up Python"
uses: actions/setup-python@main
with:
python-version: "3.12"
python-version: "3.13.2"
cache: "pip"
- name: "Install requirements"

View File

@@ -61,11 +61,11 @@ Installation can be done using [HACS](https://hacs.xyz/) by [adding a custom rep
1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Hunter Douglas PowerView (BLE)"
## Set the Encryption Key
Currently, there are three methods to optain the key:
Currently, there are three methods to obtain the key:
1. Via adopting a BLE shade: There is a [shade emulator](/emu/PV_BLE_cover) that works with Arduino IDE and an ESP32 device (≥ 2MiB flash, ≥ 128KiB required), e.g. [Adafruit QT Py ESP32-S3](https://www.adafruit.com/product/5426). Install and connect via serial port, then go to the PowerView app and add the shade `myPVcover` to your home. You will see a log message `set shade key: \xx\xx\xx\xx\xx\xx\xx\xx\xx\xx\xx\xx\xx\xx\xx\xx` . Copy this key. You can delete the shade from the app when done.
2. Extracting from gateway: This [script](scripts/extract_gateway3_homekey.py) is able to extract the key from a working PowerView gateway.
3. Grabing from the app: Checkout this [post in the Home Assistant community forum](https://community.home-assistant.io/t/hunter-douglas-powerview-gen-3-integration/424836/228).
3. Grabbing from the app: Checkout this [post in the Home Assistant community forum](https://community.home-assistant.io/t/hunter-douglas-powerview-gen-3-integration/424836/228).
Finally, you need to manually copy the key to [`const.py`](https://github.com/patman15/hdpv_ble/blob/main/custom_components/hunterdouglas_powerview_ble/const.py).

View File

@@ -4,7 +4,9 @@
@license: Apache-2.0 license
"""
from bleak.backends.device import BLEDevice
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
@@ -26,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntryType) -> bool
if entry.unique_id is None:
raise ConfigEntryError("Missing unique ID for device.")
ble_device = async_ble_device_from_address(
ble_device: BLEDevice | None = async_ble_device_from_address(
hass=hass, address=entry.unique_id, connectable=True
)

View File

@@ -1,9 +1,9 @@
"""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
@@ -12,7 +12,11 @@ from bleak.exc import BleakError
from bleak.uuids import normalize_uuid_str
from bleak_retry_connector import establish_connection
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.ciphers.base import CipherContext
from cryptography.hazmat.primitives.ciphers.base import (
AEADDecryptionContext,
AEADEncryptionContext,
)
from homeassistant.components.cover import ATTR_CURRENT_POSITION
from .const import LOGGER, TIMEOUT
@@ -26,6 +30,7 @@ ATTR_ACTIVITY: Final[str] = "activity"
SHADE_TYPE: Final[dict[int, str]] = {
# up down only
1: "Designer Roller",
4: "Roman",
5: "Bottom Up",
@@ -39,6 +44,11 @@ SHADE_TYPE: Final[dict[int, str]] = {
52: "Banded Shades",
53: "Sonnette",
84: "Vignette",
# top down bottom up
8: "Duette, Top Down Bottom Up",
9: "Duette DuoLite, Top Down Bottom Up",
33: "Duette Architella, Top Down Bottom Up",
47: "Pleated, Top Down Bottom Up",
}
OPEN_POSITION: Final[int] = 100
@@ -93,13 +103,13 @@ class PowerViewBLE:
],
)
self._data_event = asyncio.Event()
self._data: bytearray
self._data: bytes = b""
self._info: PVDeviceInfo = PVDeviceInfo()
self._cmd_lock: Final = asyncio.Lock()
self._cmd_next = None
self._is_encrypted: bool = False
self._cipher: Final = (
Cipher(algorithms.AES(home_key), modes.CTR(bytearray(16)))
self._cmd_lock: Final = asyncio.Lock()
self._cmd_next: tuple[ShadeCmd, bytes]
self._cipher: Final[Cipher | None] = (
Cipher(algorithms.AES(home_key), modes.CTR(bytes(16)))
if len(home_key) == 16
else None
)
@@ -129,7 +139,7 @@ class PowerViewBLE:
# general cmd: uint16_t cmd, uint8_t seqID, uint8_t data_len
async def _cmd(
self, cmd: tuple[ShadeCmd, bytearray], disconnect: bool = True
self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True
) -> None:
self._cmd_next = cmd
if self._cmd_lock.locked():
@@ -139,17 +149,15 @@ class PowerViewBLE:
async with self._cmd_lock:
try:
await self._connect()
cmd_run = self._cmd_next
tx_data = (
bytearray(
cmd_run: tuple[ShadeCmd, bytes] = self._cmd_next
tx_data: bytes = bytes(
int.to_bytes(cmd_run[0].value, 2, byteorder="little")
+ bytes([self._seqcnt, len(cmd_run[1])])
)
+ cmd_run[1]
)
LOGGER.debug("sending cmd: %s", tx_data.hex(" "))
if self._cipher is not None and self._is_encrypted:
enc = self._cipher.encryptor()
enc: AEADEncryptionContext = self._cipher.encryptor()
tx_data = enc.update(tx_data) + enc.finalize()
LOGGER.debug(" encrypted: %s", tx_data.hex(" "))
self._data_event.clear()
@@ -174,8 +182,8 @@ class PowerViewBLE:
if len(data) != 9:
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)
pos: int = int.from_bytes(data[3:5], byteorder="little")
pos2: int = (int(data[5]) << 4) + (int(data[4]) >> 4)
return [
(ATTR_CURRENT_POSITION, ((pos >> 2) / 10)),
("position2", pos2 >> 2),
@@ -198,7 +206,7 @@ class PowerViewBLE:
await self._cmd(
(
ShadeCmd.SET_POSITION,
bytearray(
bytes(
int.to_bytes(value * 100, 2, byteorder="little")
+ bytes([0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x0])
),
@@ -214,7 +222,7 @@ class PowerViewBLE:
async def stop(self) -> None:
"""Stop device movement."""
LOGGER.debug("%s stop", self.name)
await self._cmd((ShadeCmd.STOP, bytearray()))
await self._cmd((ShadeCmd.STOP, b""))
async def close(self) -> None:
"""Fully close cover."""
@@ -230,19 +238,19 @@ class PowerViewBLE:
await self._cmd(
(
ShadeCmd.ACTIVATE_SCENE,
bytearray(int.to_bytes(idx, 1, byteorder="little") + bytes([0xA2])),
int.to_bytes(idx, 1, byteorder="little") + bytes([0xA2]),
),
)
async def identify(self, beeps: int = 0x3) -> None:
"""Identify device."""
LOGGER.debug("%s identify (%i)", self.name, beeps)
await self._cmd((ShadeCmd.IDENTIFY, bytearray([min(beeps, 0xFF)])))
await self._cmd((ShadeCmd.IDENTIFY, bytes([min(beeps, 0xFF)])))
def _verify_response(self, data: bytearray, seq_nr: int, cmd: ShadeCmd) -> bool:
def _verify_response(self, data: bytes, seq_nr: int, cmd: ShadeCmd) -> bool:
"""Verify shade response data."""
if len(data) < 4:
LOGGER.error("Reponse message too short")
LOGGER.error("Response message too short")
return False
if int.from_bytes(data[0:2], byteorder="little") != cmd.value & 0xFFEF:
LOGGER.warning("Response to wrong command")
@@ -298,10 +306,10 @@ class PowerViewBLE:
def _notification_handler(self, _sender, data: bytearray) -> None:
LOGGER.debug("%s received BLE data: %s", self.name, data.hex(" "))
self._data = data
self._data = bytes(data)
if self._cipher is not None and self._is_encrypted:
dec: CipherContext = self._cipher.decryptor()
self._data = bytearray(dec.update(data) + dec.finalize())
dec: AEADDecryptionContext = self._cipher.decryptor()
self._data = bytes(dec.update(bytes(data)) + dec.finalize())
LOGGER.debug("%s %s", "decoded data: ".rjust(19+len(self.name)), self._data.hex(" "))
self._data_event.set()

View File

@@ -49,7 +49,7 @@ class PVBinarySensor(PassiveBluetoothCoordinatorEntity[PVCoordinator], BinarySen
descr: BinarySensorEntityDescription,
unique_id: str,
) -> None:
"""Intialize PV binary sensor."""
"""Initialize PV binary sensor."""
self._attr_unique_id = f"{DOMAIN}-{unique_id}-{descr.key}"
self._attr_device_info = coord.device_info
self._attr_has_entity_name = True

View File

@@ -4,6 +4,7 @@ from dataclasses import dataclass
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
@@ -11,9 +12,11 @@ from homeassistant.components.bluetooth import (
)
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
# from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
)
from .api import UUID_COV_SERVICE as UUID
from .const import DOMAIN, LOGGER, MFCT_ID
@@ -63,7 +66,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None:
return self.async_create_entry(
title=self._discovered_device.name,
data={"manufacturer_data": self._discovered_device.discovery_info.manufacturer_data[MFCT_ID].hex()},
data={
"manufacturer_data": self._discovered_device.discovery_info.manufacturer_data[
MFCT_ID
].hex()
},
)
self._set_confirm_only()
@@ -89,7 +96,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title=self._discovered_device.name,
data={"manufacturer_data": self._discovered_device.discovery_info.manufacturer_data[MFCT_ID].hex()},
data={
"manufacturer_data": self._discovered_device.discovery_info.manufacturer_data[
MFCT_ID
].hex()
},
)
current_addresses = self._async_current_ids()
@@ -111,7 +122,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
titles = []
titles: list[SelectOptionDict] = []
for address, discovery in self._discovered_devices.items():
titles.append({"value": address, "label": discovery.name})

View File

@@ -3,16 +3,6 @@
import logging
from typing import Final
# from bleak.uuids import normalize_uuid_str
# from homeassistant.const import ( # noqa: F401
# ATTR_BATTERY_CHARGING,
# ATTR_BATTERY_LEVEL,
# ATTR_TEMPERATURE,
# ATTR_VOLTAGE,
# )
DOMAIN: Final[str] = "hunterdouglas_powerview_ble"
LOGGER: Final = logging.getLogger(__package__)
MFCT_ID: Final[int] = 2073

View File

@@ -3,6 +3,7 @@
from typing import Any
from bleak.backends.device import BLEDevice
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth.const import DOMAIN as BLUETOOTH_DOMAIN
from homeassistant.components.bluetooth.passive_update_coordinator import (
@@ -80,7 +81,7 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator):
def _async_stop(self) -> None:
"""Shutdown coordinator and any connection."""
LOGGER.debug("%s: shuting down BMS device", self.name)
LOGGER.debug("%s: shutting down BMS device", self.name)
self.hass.async_create_task(self.api.disconnect())
super()._async_stop()

View File

@@ -3,6 +3,7 @@
from typing import Any, Final
from bleak.exc import BleakError
from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity,
)

View File

@@ -3,14 +3,8 @@
from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity,
)
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.components.sensor.const import (
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.components.sensor.const import SensorDeviceClass, SensorStateClass
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
PERCENTAGE,
@@ -67,7 +61,7 @@ class PVSensor(PassiveBluetoothCoordinatorEntity[PVCoordinator], SensorEntity):
def __init__(
self, pv_dev: PVCoordinator, descr: SensorEntityDescription, unique_id: str
) -> None:
"""Intitialize the BMS sensor."""
"""Initialize the BMS sensor."""
self._attr_unique_id = f"{DOMAIN}-{unique_id}-{descr.key}"
self._attr_device_info = pv_dev.device_info
self.entity_description = descr

View File

@@ -166,7 +166,7 @@ void decode(BLECharacteristic *pChar) {
memcpy((void *)&msg, data_dec, 4);
Serial.printf("\t message: SRV: %02x, CMD %02x, SEQ %i, LEN %i\n", msg.serviceID, msg.cmdID, msg.sequence, msg.data_len);
// sepecial responses (static data!)
// special responses (static data!)
const byte ret_valF1DD[] = { 0x00, 0x04, 0x01, 0x00, 0x00, 0x00, 0x87, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; // product info
const byte ret_valFFDD[] = { 0x00, 0x05, 0xd1, 0xa2, 0x9a, 0x42, 0x59, 0x5d, 0x5c, 0x52, 0x1b, 0x00, 0x00, 0x00, (uint8_t)(SW_VERSION & 0xFF), (uint8_t)(SW_VERSION >> 8), 0x00, 0x00, 0x5f, 0x9c, 0x02, 0x00, 0x5f, 0x9c, 0x02, 0x00, TYP_ID, MODEL_ID, 0x08 }; // HW diagnostics
const byte ret_valFFDE[] = { 0x08, 0x00, 0x02, 0x26, 0x72, 0x01, 0x59, 0x01, 0x00 }; // power status
@@ -217,7 +217,7 @@ void decode(BLECharacteristic *pChar) {
// set shade key
Serial.print("set shade key: ");
print_hex(&data_raw[4], data_len - 4, "\\x", "");
// set resonse before key, to acknowledge unencrypted
// set response before key, to acknowledge unencrypted
resp_size = set_response(&response, (const message *)data_dec);
if (msg.data_len == 16) {
memcpy(home_key, &data_raw[4], 16);

View File

@@ -1,7 +1,17 @@
# pyproject.toml
[project]
requires-python = ">=3.12.0"
name = "hunterdouglas_powerview_ble"
classifiers = [
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3.13"
]
readme = "README.md"
requires-python = ">=3.13.2"
[project.urls]
"Source Code" = "https://github.com/patman15/hdpv_ble/"
"Bug Reports" = "https://github.com/patman15/hdpv_ble/issues"
[tool.pytest.ini_options]
minversion = "8.0"
@@ -14,12 +24,9 @@ testpaths = [
]
asyncio_mode = "auto"
# ruff configuration taken from HA 2024.11.2 (less ignores)
# ruff settings from HA 2025.2.2
[tool.ruff]
required-version = ">=0.6.8"
[tool.ruff.lint.per-file-ignores]
"scripts/*" = ["T201"]
required-version = ">=0.9.1"
[tool.ruff.lint]
select = [
@@ -38,8 +45,10 @@ select = [
"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}
"B024", # `{name}` is an abstract base class, but it has no abstract methods or properties
"B026", # Star-arg unpacking after a keyword argument is strongly discouraged
"B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)?
"B035", # Dictionary comprehension uses static key
"B904", # Use raise from to specify exception cause
"B905", # zip() without an explicit strict= parameter
"BLE",
@@ -73,12 +82,27 @@ select = [
"RSE", # flake8-raise
"RUF005", # Consider iterable unpacking instead of concatenation
"RUF006", # Store a reference to the return value of asyncio.create_task
"RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs
"RUF008", # Do not use mutable default values for dataclass attributes
"RUF010", # Use explicit conversion flag
"RUF013", # PEP 484 prohibits implicit Optional
"RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer
"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
"RUF020", # {never_like} | T is equivalent to T
"RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear
"RUF022", # Sort __all__
"RUF023", # Sort __slots__
"RUF024", # Do not pass mutable objects as values to dict.fromkeys
"RUF026", # default_factory is a positional-only argument to defaultdict
"RUF030", # print() call in assert statement is likely unintentional
"RUF032", # Decimal() called with float literal argument
"RUF033", # __post_init__ method with argument defaults
"RUF034", # Useless if-else condition
"RUF100", # Unused `noqa` directive
"RUF101", # noqa directives that use redirected rule codes
"RUF200", # Failed to parse pyproject.toml: {message}
"S102", # Use of exec detected
"S103", # bad-file-permissions
"S108", # hardcoded-temp-file
@@ -91,7 +115,7 @@ select = [
"S317", # suspicious-xml-sax-usage
"S318", # suspicious-xml-mini-dom-usage
"S319", # suspicious-xml-pull-dom-usage
"S320", # suspicious-xmle-tree-usage
# "S320", # suspicious-xmle-tree-usage
"S601", # paramiko-call
"S602", # subprocess-popen-with-shell-equals-true
"S604", # call-with-shell-equals-true
@@ -102,7 +126,7 @@ select = [
"SLOT", # flake8-slots
"T100", # Trace found: {name} used
"T20", # flake8-print
"TCH", # flake8-type-checking
"TC", # flake8-type-checking
"TID", # Tidy imports
"TRY", # tryceratops
"UP", # pyupgrade
@@ -122,13 +146,12 @@ ignore = [
# "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})
"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
"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.
@@ -139,14 +162,12 @@ ignore = [
# "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
"TC001", # Move application import {} into a type-checking block
"TC002", # Move third-party import {} into a type-checking block
"TC003", # 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",
@@ -163,3 +184,15 @@ ignore = [
# Disabled because ruff does not understand type of __all__ generated by a function
"PLE0605"
]
[tool.ruff.lint.isort]
force-sort-within-sections = true
known-first-party = [
"homeassistant",
]
combine-as-imports = true
split-on-trailing-comma = false
[tool.ruff.lint.per-file-ignores]
"scripts/*" = ["T201"]

View File

@@ -1,4 +1,4 @@
homeassistant==2024.11.0
homeassistant==2025.11.0
pip>=21.3.1
ruff==0.6.8
ruff>=0.9.1,<=0.15.0

View File

@@ -2,9 +2,9 @@
wheel
home-assistant-bluetooth
habluetooth>=3.6.0
habluetooth>=5.3.0
bluetooth-adapters
pytest>=8.3.3
pytest>=8.4.1
pytest-cov>=5.0.0
pytest-socket>=0.7.0
pytest-asyncio>=0.24.0
@@ -16,10 +16,10 @@ aiohttp
aiohttp_cors
aiohttp-fast-url-dispatcher
aiohttp-zlib-ng
bleak>=0.22.3
bleak-retry-connector>=3.6.0
bleak>=1.0.1
bleak-retry-connector>=4.4.3
bluetooth-data-tools
pyserial-asyncio
pyudev
pytest-homeassistant-custom-component==0.13.181
pytest-homeassistant-custom-component==0.13.294

View File

@@ -66,7 +66,7 @@ def get_shade_key(hub: str, ble_name) -> bytes:
return dec_resp["data"]
def main(hub: str) -> None:
def main(hub: str) -> int:
"""Extract the homekeys from all shades."""
try:
shades_resp: requests.Response = requests.get(
@@ -75,7 +75,7 @@ def main(hub: str) -> None:
shades_resp.raise_for_status()
except requests.exceptions.RequestException as ex:
print(f"Unable to get list of shades:\n\t{ex!s}")
return
return -1
shades = json.loads(shades_resp.content)
print(f"Found {len(shades)} shades, interrogating")
@@ -87,6 +87,8 @@ def main(hub: str) -> None:
print(f"\tBLE name: '{shade['bleName']}'")
print(f"\tHomeKey: {key.hex()}")
return 0
if __name__ == "__main__":
import argparse