Initial commit

Basic functionality could work (untested!)
This commit is contained in:
patman15
2024-07-04 11:44:36 +02:00
parent f4f6323f95
commit a007861a4c
16 changed files with 637 additions and 1 deletions

View File

@@ -0,0 +1 @@
"""Custom component."""

View File

@@ -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

View File

@@ -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

View File

@@ -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)
)
}
),
)

View File

@@ -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"

View File

@@ -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

View File

@@ -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")

View File

@@ -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"
}

View File

@@ -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"
}
}
}

View File

@@ -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}?"
}
}
}
}