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" - name: "Set up Python"
uses: actions/setup-python@main uses: actions/setup-python@main
with: with:
python-version: "3.12" python-version: "3.13.2"
cache: "pip" cache: "pip"
- name: "Install requirements" - 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)" 1. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Hunter Douglas PowerView (BLE)"
## Set the Encryption Key ## 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. 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. 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). 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 @license: Apache-2.0 license
""" """
from bleak.backends.device import BLEDevice
from bleak.exc import BleakError from bleak.exc import BleakError
from homeassistant.components.bluetooth import async_ble_device_from_address from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform 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: if entry.unique_id is None:
raise ConfigEntryError("Missing unique ID for device.") 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 hass=hass, address=entry.unique_id, connectable=True
) )

View File

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

View File

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

View File

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

View File

@@ -3,16 +3,6 @@
import logging import logging
from typing import Final 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" DOMAIN: Final[str] = "hunterdouglas_powerview_ble"
LOGGER: Final = logging.getLogger(__package__) LOGGER: Final = logging.getLogger(__package__)
MFCT_ID: Final[int] = 2073 MFCT_ID: Final[int] = 2073

View File

@@ -3,6 +3,7 @@
from typing import Any from typing import Any
from bleak.backends.device import BLEDevice from bleak.backends.device import BLEDevice
from homeassistant.components import bluetooth from homeassistant.components import bluetooth
from homeassistant.components.bluetooth.const import DOMAIN as BLUETOOTH_DOMAIN from homeassistant.components.bluetooth.const import DOMAIN as BLUETOOTH_DOMAIN
from homeassistant.components.bluetooth.passive_update_coordinator import ( from homeassistant.components.bluetooth.passive_update_coordinator import (
@@ -80,7 +81,7 @@ class PVCoordinator(PassiveBluetoothDataUpdateCoordinator):
def _async_stop(self) -> None: def _async_stop(self) -> None:
"""Shutdown coordinator and any connection.""" """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()) self.hass.async_create_task(self.api.disconnect())
super()._async_stop() super()._async_stop()

View File

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

View File

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

View File

@@ -166,7 +166,7 @@ void decode(BLECharacteristic *pChar) {
memcpy((void *)&msg, data_dec, 4); 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); 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_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_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 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 // set shade key
Serial.print("set shade key: "); Serial.print("set shade key: ");
print_hex(&data_raw[4], data_len - 4, "\\x", ""); 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); resp_size = set_response(&response, (const message *)data_dec);
if (msg.data_len == 16) { if (msg.data_len == 16) {
memcpy(home_key, &data_raw[4], 16); memcpy(home_key, &data_raw[4], 16);

View File

@@ -1,7 +1,17 @@
# pyproject.toml # pyproject.toml
[project] [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] [tool.pytest.ini_options]
minversion = "8.0" minversion = "8.0"
@@ -14,12 +24,9 @@ testpaths = [
] ]
asyncio_mode = "auto" asyncio_mode = "auto"
# ruff configuration taken from HA 2024.11.2 (less ignores) # ruff settings from HA 2025.2.2
[tool.ruff] [tool.ruff]
required-version = ">=0.6.8" required-version = ">=0.9.1"
[tool.ruff.lint.per-file-ignores]
"scripts/*" = ["T201"]
[tool.ruff.lint] [tool.ruff.lint]
select = [ select = [
@@ -38,8 +45,10 @@ select = [
"B017", # pytest.raises(BaseException) should be considered evil "B017", # pytest.raises(BaseException) should be considered evil
"B018", # Found useless attribute access. Either assign it to a variable or remove it. "B018", # Found useless attribute access. Either assign it to a variable or remove it.
"B023", # Function definition does not bind loop variable {name} "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 "B026", # Star-arg unpacking after a keyword argument is strongly discouraged
"B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)? "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 "B904", # Use raise from to specify exception cause
"B905", # zip() without an explicit strict= parameter "B905", # zip() without an explicit strict= parameter
"BLE", "BLE",
@@ -73,12 +82,27 @@ select = [
"RSE", # flake8-raise "RSE", # flake8-raise
"RUF005", # Consider iterable unpacking instead of concatenation "RUF005", # Consider iterable unpacking instead of concatenation
"RUF006", # Store a reference to the return value of asyncio.create_task "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 "RUF010", # Use explicit conversion flag
"RUF013", # PEP 484 prohibits implicit Optional "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 "RUF017", # Avoid quadratic list summation
"RUF018", # Avoid assignment expressions in assert statements "RUF018", # Avoid assignment expressions in assert statements
"RUF019", # Unnecessary key check before dictionary access "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 "S102", # Use of exec detected
"S103", # bad-file-permissions "S103", # bad-file-permissions
"S108", # hardcoded-temp-file "S108", # hardcoded-temp-file
@@ -91,7 +115,7 @@ select = [
"S317", # suspicious-xml-sax-usage "S317", # suspicious-xml-sax-usage
"S318", # suspicious-xml-mini-dom-usage "S318", # suspicious-xml-mini-dom-usage
"S319", # suspicious-xml-pull-dom-usage "S319", # suspicious-xml-pull-dom-usage
"S320", # suspicious-xmle-tree-usage # "S320", # suspicious-xmle-tree-usage
"S601", # paramiko-call "S601", # paramiko-call
"S602", # subprocess-popen-with-shell-equals-true "S602", # subprocess-popen-with-shell-equals-true
"S604", # call-with-shell-equals-true "S604", # call-with-shell-equals-true
@@ -102,7 +126,7 @@ select = [
"SLOT", # flake8-slots "SLOT", # flake8-slots
"T100", # Trace found: {name} used "T100", # Trace found: {name} used
"T20", # flake8-print "T20", # flake8-print
"TCH", # flake8-type-checking "TC", # flake8-type-checking
"TID", # Tidy imports "TID", # Tidy imports
"TRY", # tryceratops "TRY", # tryceratops
"UP", # pyupgrade "UP", # pyupgrade
@@ -122,13 +146,12 @@ ignore = [
# "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives # "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}) # "PLR0911", # Too many return statements ({returns} > {max_returns})
# "PLR0912", # Too many branches ({branches} > {max_branches}) # "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}) # "PLR0915", # Too many statements ({statements} > {max_statements})
"PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable "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 # "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 # "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. # "RUF001", # String contains ambiguous unicode character.
# "RUF002", # Docstring contains ambiguous unicode character. # "RUF002", # Docstring contains ambiguous unicode character.
# "RUF003", # Comment contains ambiguous unicode character. # "RUF003", # Comment contains ambiguous unicode character.
@@ -139,14 +162,12 @@ ignore = [
# "SIM115", # Use context handler for opening files # "SIM115", # Use context handler for opening files
# Moving imports into type-checking blocks can mess with pytest.patch() # Moving imports into type-checking blocks can mess with pytest.patch()
"TCH001", # Move application import {} into a type-checking block "TC001", # Move application import {} into a type-checking block
"TCH002", # Move third-party import {} into a type-checking block "TC002", # Move third-party import {} into a type-checking block
"TCH003", # Move standard library 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 "TRY003", # Avoid specifying long messages outside the exception class
"TRY400", # Use `logging.exception` instead of `logging.error` "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 # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
"W191", "W191",
@@ -163,3 +184,15 @@ ignore = [
# Disabled because ruff does not understand type of __all__ generated by a function # Disabled because ruff does not understand type of __all__ generated by a function
"PLE0605" "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 pip>=21.3.1
ruff==0.6.8 ruff>=0.9.1,<=0.15.0

View File

@@ -2,9 +2,9 @@
wheel wheel
home-assistant-bluetooth home-assistant-bluetooth
habluetooth>=3.6.0 habluetooth>=5.3.0
bluetooth-adapters bluetooth-adapters
pytest>=8.3.3 pytest>=8.4.1
pytest-cov>=5.0.0 pytest-cov>=5.0.0
pytest-socket>=0.7.0 pytest-socket>=0.7.0
pytest-asyncio>=0.24.0 pytest-asyncio>=0.24.0
@@ -16,10 +16,10 @@ aiohttp
aiohttp_cors aiohttp_cors
aiohttp-fast-url-dispatcher aiohttp-fast-url-dispatcher
aiohttp-zlib-ng aiohttp-zlib-ng
bleak>=0.22.3 bleak>=1.0.1
bleak-retry-connector>=3.6.0 bleak-retry-connector>=4.4.3
bluetooth-data-tools bluetooth-data-tools
pyserial-asyncio pyserial-asyncio
pyudev 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"] return dec_resp["data"]
def main(hub: str) -> None: def main(hub: str) -> int:
"""Extract the homekeys from all shades.""" """Extract the homekeys from all shades."""
try: try:
shades_resp: requests.Response = requests.get( shades_resp: requests.Response = requests.get(
@@ -75,7 +75,7 @@ def main(hub: str) -> None:
shades_resp.raise_for_status() shades_resp.raise_for_status()
except requests.exceptions.RequestException as ex: except requests.exceptions.RequestException as ex:
print(f"Unable to get list of shades:\n\t{ex!s}") print(f"Unable to get list of shades:\n\t{ex!s}")
return return -1
shades = json.loads(shades_resp.content) shades = json.loads(shades_resp.content)
print(f"Found {len(shades)} shades, interrogating") print(f"Found {len(shades)} shades, interrogating")
@@ -87,6 +87,8 @@ def main(hub: str) -> None:
print(f"\tBLE name: '{shade['bleName']}'") print(f"\tBLE name: '{shade['bleName']}'")
print(f"\tHomeKey: {key.hex()}") print(f"\tHomeKey: {key.hex()}")
return 0
if __name__ == "__main__": if __name__ == "__main__":
import argparse import argparse