Initial commit
Basic functionality could work (untested!)
This commit is contained in:
8
CONTRIBUTING.md
Normal file
8
CONTRIBUTING.md
Normal file
@@ -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.
|
||||||
@@ -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
|
A Home Assistant integration to support Hunter Douglas Powerview devices via Bluetooth
|
||||||
|
|
||||||
|
|||||||
1
custom_components/__init__.py
Normal file
1
custom_components/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Custom component."""
|
||||||
65
custom_components/hunterdouglas_powerview_ble/__init__.py
Normal file
65
custom_components/hunterdouglas_powerview_ble/__init__.py
Normal 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
|
||||||
128
custom_components/hunterdouglas_powerview_ble/api.py
Normal file
128
custom_components/hunterdouglas_powerview_ble/api.py
Normal 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
|
||||||
127
custom_components/hunterdouglas_powerview_ble/config_flow.py
Normal file
127
custom_components/hunterdouglas_powerview_ble/config_flow.py
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
21
custom_components/hunterdouglas_powerview_ble/const.py
Normal file
21
custom_components/hunterdouglas_powerview_ble/const.py
Normal 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"
|
||||||
|
|
||||||
90
custom_components/hunterdouglas_powerview_ble/coordinator.py
Normal file
90
custom_components/hunterdouglas_powerview_ble/coordinator.py
Normal 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
|
||||||
94
custom_components/hunterdouglas_powerview_ble/cover.py
Normal file
94
custom_components/hunterdouglas_powerview_ble/cover.py
Normal 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")
|
||||||
21
custom_components/hunterdouglas_powerview_ble/manifest.json
Normal file
21
custom_components/hunterdouglas_powerview_ble/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
15
custom_components/hunterdouglas_powerview_ble/strings.json
Normal file
15
custom_components/hunterdouglas_powerview_ble/strings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
hacs.json
Normal file
5
hacs.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "Hunter Douglas PowerView (BLE)",
|
||||||
|
"homeassistant": "2024.6.0",
|
||||||
|
"render_readme": true
|
||||||
|
}
|
||||||
16
pyproject.toml
Normal file
16
pyproject.toml
Normal file
@@ -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"
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
homeassistant==2024.6.0
|
||||||
|
pip>=21.3.1
|
||||||
|
ruff==0.4.2
|
||||||
|
|
||||||
25
requirements_test.txt
Normal file
25
requirements_test.txt
Normal file
@@ -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
|
||||||
|
|
||||||
Reference in New Issue
Block a user