diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4afb279 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,8 @@ +# Contributing + +## Issues +In case you have troubles, please enable the debug protocol for the integration and [open an issue](https://github.com/patman15/hdpv_ble/issues) with a good description of what happened and the relevant snippet from the log. + +### Any contributions you make will be under the Apache License Version 2.0 + +In short, when you submit code changes, your submissions are understood to be under the same [Apache License Version 2.0](LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. diff --git a/README.md b/README.md index db26da8..f38eaa9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ -# hunterdouglas_powerview_ble +# HD PowerView Support via BLE for Home Assistant A Home Assistant integration to support Hunter Douglas Powerview devices via Bluetooth + diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..2998179 --- /dev/null +++ b/custom_components/__init__.py @@ -0,0 +1 @@ +"""Custom component.""" diff --git a/custom_components/hunterdouglas_powerview_ble/__init__.py b/custom_components/hunterdouglas_powerview_ble/__init__.py new file mode 100644 index 0000000..b181157 --- /dev/null +++ b/custom_components/hunterdouglas_powerview_ble/__init__.py @@ -0,0 +1,65 @@ +"""The Hunter Douglas PowerView (BLE) integration.""" + +from homeassistant.components.bluetooth import async_ble_device_from_address +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import DOMAIN, LOGGER +from .coordinator import PVCoordinator + +PLATFORMS: list[Platform] = [Platform.COVER] + +type ConfigEntryType = ConfigEntry[PVCoordinator] + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntryType) -> bool: + """Set up BT Battery Management System from a config entry.""" + LOGGER.debug("Setup of %s", repr(entry)) + + if entry.unique_id is None: + raise ConfigEntryError("Missing unique ID for device.") + + ble_device = async_ble_device_from_address( + hass=hass, address=entry.unique_id, connectable=True + ) + + if not ble_device: + raise ConfigEntryNotReady( + f"Could not find PowerView device ({entry.unique_id}) via Bluetooth" + ) + + coordinator = PVCoordinator(hass, ble_device) + # Query the device the first time, initialise coordinator.data + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady: + # Ignore, e.g. timeouts, to gracefully handle connection issues + LOGGER.warning("Failed to initialize PowerView device %s, continuing", ble_device.name) + + # Insert the coordinator in the global registry + hass.data.setdefault(DOMAIN, {}) + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntryType) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + LOGGER.debug("Unloaded config entry: %s, ok? %s!", entry.unique_id, str(unload_ok)) + return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntryType) -> bool: + """Migrate old entry.""" + + if config_entry.version > 1: + # This means the user has downgraded from a future version + LOGGER.debug("Cannot downgrade from version %s", config_entry.version) + return False + + LOGGER.debug("Migrating from version %s", config_entry.version) + + return False diff --git a/custom_components/hunterdouglas_powerview_ble/api.py b/custom_components/hunterdouglas_powerview_ble/api.py new file mode 100644 index 0000000..65ed7c9 --- /dev/null +++ b/custom_components/hunterdouglas_powerview_ble/api.py @@ -0,0 +1,128 @@ +"""Hunter Douglas PowerView BLE API.""" + +import asyncio +from enum import Enum + +from bleak import BleakClient +from bleak.backends.device import BLEDevice +from bleak.exc import BleakError + +from .const import LOGGER + + +class shade_cmd(Enum): + """The PowerView cover commands.""" + + set_position = 0x01F7 + stop = 0xB8F7 + activate_scene = 0xBAF7 + + +class PowerViewBLE: + """Class to handle connection to PowerView remote device.""" + + UUID_SERVICE = "0000fdc1-0000-1000-8000-00805f9b34fb" + UUID_TX = "cafe1001-c0ff-ee01-8000-A110CA7AB1E0" + + def __init__(self, ble_device: BLEDevice) -> None: + """Initialize device API via Bluetooth.""" + self._ble_device: BLEDevice = ble_device + self.name = self._ble_device.name + self.seqcnt: int = 0 + self._client: BleakClient | None = None + self._data_event = asyncio.Event() + + async def _wait_event(self) -> None: + await self._data_event.wait() + self._data_event.clear() + + @property + def is_connected(self) -> bool: + """Return whether remote device is connected.""" + return self._client is not None and self._client.is_connected + + # general cmd: uint16_t cmd, uint8_t seqID, uint8_t data_len + async def _cmd(self, cmd: shade_cmd, data: bytearray) -> None: + try: + await self._connect() + assert self._client is not None, "missing BT client" + tx_data = ( + bytearray( + int.to_bytes(cmd.value, 2, byteorder="little") + + bytes([self.seqcnt, len(data)]) + ) + + data + ) + self.seqcnt += 1 + await self._client.write_gatt_char(self.UUID_TX, tx_data, False) + finally: + await self._disconnect() + + # position cmd: uint16_t pos1, uint16_t pos2, uint16_t pos3, uint16_t tilt, uint8_t velocity + async def set_position(self, value: int) -> None: + """Set position of device.""" + LOGGER.debug("%s setting position to %i", self.name, value) + await self._cmd( + shade_cmd.set_position, + bytearray( + int.to_bytes(value * 100, 2, byteorder="little") + + bytes([0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x0]) + ), + ) + + # TODO: currently HA / Bleak communication is too slow to make that function reasonable + # def stop(self) -> None: + # """Stop device movement.""" + # LOGGER.debug("%s stop") + + # uint8_t scene#, uint8_t unknown + # open: scene 2 + # close: scene 3 + async def activate_scene(self, idx: int) -> None: + """Stop device movement.""" + LOGGER.debug("%s set scene #%i", self.name, idx) + await self._cmd( + shade_cmd.activate_scene, + bytearray( + int.to_bytes(idx, 1, byteorder="little") + + bytes([0xA2]) + ), + ) + + def _on_disconnect(self, client: BleakClient) -> None: + """Disconnect callback function.""" + + LOGGER.debug("Disconnected from %s", client.address) + + def _notification_handler(self, sender, data: bytearray) -> None: + LOGGER.debug("%s received BLE data: %s", self.name, data) + + self._data_event.set() + + async def _connect(self) -> None: + """Connect to the device and setup notification if not connected.""" + + if not self.is_connected: + LOGGER.debug("Connecting %s", self.name) + self._client = BleakClient( + self._ble_device, + disconnected_callback=self._on_disconnect, + services=[self.UUID_SERVICE], + ) + await self._client.connect() + await self._client.start_notify(self.UUID_TX, self._notification_handler) + else: + LOGGER.debug("%s already connected", self.name) + + async def _disconnect(self) -> None: + """Disconnect the device and stop notifications.""" + + if self._client and self.is_connected: + LOGGER.debug("Disconnecting device %s", self.name) + try: + self._data_event.clear() + await self._client.disconnect() + except BleakError: + LOGGER.warning("Disconnect failed!") + + self._client = None diff --git a/custom_components/hunterdouglas_powerview_ble/config_flow.py b/custom_components/hunterdouglas_powerview_ble/config_flow.py new file mode 100644 index 0000000..292463f --- /dev/null +++ b/custom_components/hunterdouglas_powerview_ble/config_flow.py @@ -0,0 +1,127 @@ +"""Config flow for BLE Battery Management System integration.""" + +from dataclasses import dataclass +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +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 .const import DOMAIN, LOGGER, MFCT_ID, UUID + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for BT Battery Management System.""" + + VERSION = 1 + MINOR_VERSION = 0 + + @dataclass + class DiscoveredDevice: + """A discovered bluetooth device.""" + + name: str + discovery_info: BluetoothServiceInfoBleak + + def __init__(self) -> None: + """Initialize the config flow.""" + + self._discovered_device: ConfigFlow.DiscoveredDevice | None = None + self._discovered_devices: dict[str, ConfigFlow.DiscoveredDevice] = {} + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + """Handle a flow initialized by Bluetooth discovery.""" + LOGGER.debug("Bluetooth device detected: %s", discovery_info) + + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + + self._discovered_device = ConfigFlow.DiscoveredDevice( + discovery_info.name, discovery_info + ) + self.context["title_placeholders"] = {"name": self._discovered_device.name} + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm bluetooth device discovery.""" + assert self._discovered_device is not None + LOGGER.debug("confirm step for %s", self._discovered_device.name) + + if user_input is not None: + return self.async_create_entry( + title=self._discovered_device.name, + data={}, + ) + + self._set_confirm_only() + + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders={"name": self._discovered_device.name}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step to pick discovered device.""" + LOGGER.debug("user step") + + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + self._discovered_device = self._discovered_devices[address] + + self.context["title_placeholders"] = {"name": self._discovered_device.name} + + return self.async_create_entry( + title=self._discovered_device.name, + data={}, + ) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass, False): + address = discovery_info.address + if address in current_addresses or address in self._discovered_devices: + continue + + if MFCT_ID not in discovery_info.manufacturer_data: + continue + + if UUID not in discovery_info.service_uuids: + continue + + self._discovered_devices[address] = ConfigFlow.DiscoveredDevice( + discovery_info.name, discovery_info + ) + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + titles = [] + for address, discovery in self._discovered_devices.items(): + titles.append({"value": address, "label": discovery.name}) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ADDRESS): SelectSelector( + SelectSelectorConfig(options=titles) + ) + } + ), + ) diff --git a/custom_components/hunterdouglas_powerview_ble/const.py b/custom_components/hunterdouglas_powerview_ble/const.py new file mode 100644 index 0000000..77252d1 --- /dev/null +++ b/custom_components/hunterdouglas_powerview_ble/const.py @@ -0,0 +1,21 @@ +"""Constants for the BLE Battery Management System integration.""" + +import logging + +# from homeassistant.const import ( # noqa: F401 +# ATTR_BATTERY_CHARGING, +# ATTR_BATTERY_LEVEL, +# ATTR_TEMPERATURE, +# ATTR_VOLTAGE, +# ) + + +DOMAIN = "hunterdouglas_powerview_ble" +LOGGER = logging.getLogger(__package__) +UPDATE_INTERVAL = 30 # in seconds +UUID = "0000fdc1-0000-1000-8000-00805f9b34fb" +MFCT_ID = 2073 + +# attributes (do not change) +ATTR_RSSI = "rssi" + diff --git a/custom_components/hunterdouglas_powerview_ble/coordinator.py b/custom_components/hunterdouglas_powerview_ble/coordinator.py new file mode 100644 index 0000000..d51f69d --- /dev/null +++ b/custom_components/hunterdouglas_powerview_ble/coordinator.py @@ -0,0 +1,90 @@ +"""Home Assistant coordinator for Hunter Douglas PowerView (BLE) integration.""" + +from datetime import timedelta +from typing import Any + +from bleak.backends.device import BLEDevice +from bleak.exc import BleakError + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import DOMAIN as BLUETOOTH_DOMAIN +from homeassistant.const import ATTR_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import PowerViewBLE +from .const import ATTR_RSSI, DOMAIN, LOGGER, UPDATE_INTERVAL + + +class PVCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Update coordinator for a battery management system.""" + + def __init__( + self, + hass: HomeAssistant, + ble_device: BLEDevice, + ) -> None: + """Initialize BMS data coordinator.""" + assert ble_device.name is not None + super().__init__( + hass=hass, + logger=LOGGER, + name=ble_device.name, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + always_update=False, # only update when sensor value has changed + ) + + self._mac = ble_device.address + self.api = PowerViewBLE(ble_device) + LOGGER.debug( + "Initializing coordinator for %s (%s)", + ble_device.name, + ble_device.address, + ) + if service_info := bluetooth.async_last_service_info( + self.hass, address=self._mac, connectable=True + ): + LOGGER.debug("device data: %s", service_info.as_dict()) + + self.device_info = DeviceInfo( + identifiers={ + (DOMAIN, ble_device.name), + (BLUETOOTH_DOMAIN, ble_device.address), + }, + connections={(CONNECTION_BLUETOOTH, ble_device.address)}, + name=ble_device.name, + configuration_url=None, + # properties used in GUI: + manufacturer="Hunter Douglas", + model="shade", + ) + + @property + def address(self) -> str: + """Return MAC address of remote device.""" + return self._mac + + async def _async_update_data(self) -> dict[str, Any]: + """Return the latest data from the device.""" + LOGGER.debug("%s data update", self.device_info.get(ATTR_NAME)) + + try: + info = {} # await self._device.async_update() + except TimeoutError: + LOGGER.debug("Device communication timeout") + raise + except BleakError as err: + raise UpdateFailed( + f"device communicating failed: {err!s} ({type(err).__name__})" + ) from err + + if ( + service_info := bluetooth.async_last_service_info( + self.hass, address=self._mac, connectable=True + ) + ) is not None: + info.update({ATTR_RSSI: service_info.rssi}) + + LOGGER.debug("data sample %s", info) + return info diff --git a/custom_components/hunterdouglas_powerview_ble/cover.py b/custom_components/hunterdouglas_powerview_ble/cover.py new file mode 100644 index 0000000..4c9a51e --- /dev/null +++ b/custom_components/hunterdouglas_powerview_ble/cover.py @@ -0,0 +1,94 @@ +"""Hunter Douglas Powerview cover.""" + +from typing import Any + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_IDENTIFIERS, ATTR_NAME +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, LOGGER +from .coordinator import PVCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the demo cover platform.""" + + coordinator: PVCoordinator = config_entry.runtime_data + async_add_entities([PowerViewCover(coordinator)]) + + +class PowerViewCover(CoverEntity): + """Representation of a powerview shade.""" + + _attr_has_entity_name = True + _attr_device_class = CoverDeviceClass.SHADE + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + # | CoverEntityFeature.STOP + ) + _attr_current_cover_position: int | None = None + + def __init__( + self, + coordinator: PVCoordinator, + ) -> None: + """Initialize the shade.""" + self._attr_name = CoverDeviceClass.SHADE + self._coord = coordinator + self._attr_device_info = self._coord.device_info + self._attr_unique_id = ( + f"{DOMAIN}_{format_mac(self._coord.address)}_{CoverDeviceClass.SHADE}" + ) + self._attr_current_cover_position: int | None = 0 + + @property + def is_closed(self) -> bool: # type: ignore[reportIncompatibleVariableOverride] + """Return if the cover is closed.""" + return self._attr_current_cover_position == 0 + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + if pos := kwargs[ATTR_POSITION]: + LOGGER.debug("set cover to position %i", pos) + try: + await self._coord.api.set_position(pos) + self._attr_current_cover_position = pos + except TimeoutError: + pass + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + LOGGER.debug("open cover") + try: + await self._coord.api.activate_scene(2) + self._attr_current_cover_position = 100 + except TimeoutError: + pass + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + LOGGER.debug("close cover") + try: + await self._coord.api.activate_scene(3) + self._attr_current_cover_position = 0 + except TimeoutError: + pass + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + LOGGER.debug("stop cover") diff --git a/custom_components/hunterdouglas_powerview_ble/manifest.json b/custom_components/hunterdouglas_powerview_ble/manifest.json new file mode 100644 index 0000000..d917e85 --- /dev/null +++ b/custom_components/hunterdouglas_powerview_ble/manifest.json @@ -0,0 +1,21 @@ +{ + "domain": "hunterdouglas_powerview_ble", + "name": "Hunter Douglas PowerView (BLE)", + "bluetooth": [ + { + "service_uuid": "0000fdc1-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 2073, + "manufacturer_data_start": [0,0,42,0] + } + ], + "codeowners": ["@patman15"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://github.com/patman15/hdpv_ble", + "integration_type": "device", + "iot_class": "local_polling", + "issue_tracker": "https://github.com/patman15/hdpv_ble/issues", + "loggers": ["hunterdouglas_powerview_ble"], + "requirements": [], + "version": "0.1" +} diff --git a/custom_components/hunterdouglas_powerview_ble/strings.json b/custom_components/hunterdouglas_powerview_ble/strings.json new file mode 100644 index 0000000..19601be --- /dev/null +++ b/custom_components/hunterdouglas_powerview_ble/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "flow_title": "Setup {name}", + "step": { + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "not_supported": "Device not supported" + } + } +} diff --git a/custom_components/hunterdouglas_powerview_ble/translations/en.json b/custom_components/hunterdouglas_powerview_ble/translations/en.json new file mode 100644 index 0000000..528686d --- /dev/null +++ b/custom_components/hunterdouglas_powerview_ble/translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "no_devices_found": "No devices found on the network", + "not_supported": "Device not supported" + }, + "flow_title": "Setup {name}", + "step": { + "bluetooth_confirm": { + "description": "Do you want to set up {name}?" + } + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..8a936b8 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "Hunter Douglas PowerView (BLE)", + "homeassistant": "2024.6.0", + "render_readme": true +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..404daa0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +# pyproject.toml + +#[tool.setuptools.packages.find] +#where = ["custom_components/"] +#include = ["bms_ble"] + +[tool.pytest.ini_options] +minversion = "8.0" +addopts="--cov=custom_components.bms_ble --cov-report=term-missing --cov-fail-under=100" +pythonpath = [ + "custom_components.hunterdouglas_powerview_ble", +] +testpaths = [ + "tests", +] +asyncio_mode = "auto" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0bfa0e9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +homeassistant==2024.6.0 +pip>=21.3.1 +ruff==0.4.2 + diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..ef103e2 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,25 @@ +pip>=21.3.1 +homeassistant==2024.6.0 +wheel +home-assistant-bluetooth +habluetooth>=2.4.0 +bluetooth-adapters +pytest>=8.0.2 +pytest-cov>=4.0.0 +pytest-socket>=0.5.0 +pytest-asyncio +sqlalchemy +freezegun +requests-mock +syrupy>=4.1.0 +aiohttp +aiohttp_cors +aiohttp-fast-url-dispatcher +aiohttp-zlib-ng +bleak>=0.19.0 +bleak-retry-connector>=3.3.0 +bluetooth-data-tools +pyserial-asyncio +pyudev +pytest-homeassistant-custom-component==0.13.132 +