Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,6 @@ cython_debug/
#.idea/
aes.py
*.bak
emu/PV_BLE_cover/PV_BLE_cover.ino
img/
*.zip
63 changes: 48 additions & 15 deletions custom_components/hunterdouglas_powerview_ble/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
AEADEncryptionContext,
)

from homeassistant.components.cover import ATTR_CURRENT_POSITION
from homeassistant.components.cover import (
ATTR_CURRENT_POSITION,
ATTR_CURRENT_TILT_POSITION,
)

from .const import LOGGER, TIMEOUT

Expand Down Expand Up @@ -49,6 +52,9 @@
9: "Duette DuoLite, Top Down Bottom Up",
33: "Duette Architella, Top Down Bottom Up",
47: "Pleated, Top Down Bottom Up",
# top down, tilt anywhere
51: "Venetian, Tilt Anywhere",
62: "Venetian, Tilt Anywhere",
}

OPEN_POSITION: Final[int] = 100
Expand Down Expand Up @@ -138,9 +144,7 @@ def is_connected(self) -> bool:
return self._client.is_connected

# general cmd: uint16_t cmd, uint8_t seqID, uint8_t data_len
async def _cmd(
self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True
) -> None:
async def _cmd(self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True) -> None:
self._cmd_next = cmd
if self._cmd_lock.locked():
LOGGER.debug("%s: device busy, queuing %s command", self.name, cmd[0])
Expand Down Expand Up @@ -179,16 +183,25 @@ async def _cmd(
@staticmethod
def dec_manufacturer_data(data: bytearray) -> list[tuple[str, float]]:
"""Decode manufacturer data from BLE advertisement V2."""

if len(data) != 9:
LOGGER.debug("not a V2 record!")
return []
pos: int = int.from_bytes(data[3:5], byteorder="little")
pos2: int = (int(data[5]) << 4) + (int(data[4]) >> 4)
# Get the last 4 bits of data[4] and shift them 6 places to the left
last_4_bits = (data[4] & 0b1111) << 6
# Get the first 6 bits of data[3]
first_6_bits = (data[3] >> 2) & 0b111111
# Combine them into a single integer
pos = last_4_bits | first_6_bits

# LOGGER.debug(f"(bin: {data[3]:08b} - {data[4]:08b} - {data[5]:08b})")

pos2: Final[int] = (int(data[5]) << 4) + (int(data[4]) >> 4)
return [
(ATTR_CURRENT_POSITION, ((pos >> 2) / 10)),
(ATTR_CURRENT_POSITION, (pos / 10)),
("position2", pos2 >> 2),
("position3", int(data[6])),
("tilt", int(data[7])),
(ATTR_CURRENT_TILT_POSITION, ((pos2 >> 2) / 10)), # int(data[7])),
("home_id", int.from_bytes(data[0:2], byteorder="little")),
("type_id", int(data[2])),
("is_opening", bool(pos & 0x3 == 0x2)),
Expand All @@ -200,16 +213,32 @@ def dec_manufacturer_data(data: bytearray) -> list[tuple[str, float]]:
]

# position cmd: uint16_t pos1, uint16_t pos2, uint16_t pos3, uint16_t tilt, uint8_t velocity
async def set_position(self, value: int, disconnect: bool = True) -> None:
async def set_position(
self,
pos1: int,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the parameters pos1-pos3, tilt, velocity should go into a dataclass.

pos2: int | None = None,
pos3: int | None = None,
tilt: int | None = None,
velocity: int = 0x0,
disconnect: bool = True,
) -> None:
"""Set position of device."""
LOGGER.debug("%s setting position to %i", self.name, value)

LOGGER.warn("%s setting position to %i, tilt %i", self.name, pos1, tilt)
await self._cmd(
(
ShadeCmd.SET_POSITION,
bytes(
int.to_bytes(value * 100, 2, byteorder="little")
+ bytes([0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x0])
),
int.to_bytes(pos1 * 100, 2, byteorder="little")
+ int.to_bytes(
pos2 * 100 if pos2 is not None else 0x8000, 2, byteorder="little"
)
+ int.to_bytes(
pos3 if pos3 is not None else 0x8000, 2, byteorder="little"
)
+ int.to_bytes(
tilt if tilt is not None else 0x8000, 2, byteorder="little"
)
+ int.to_bytes(velocity, 1),
),
disconnect,
)
Expand Down Expand Up @@ -310,7 +339,11 @@ def _notification_handler(self, _sender, data: bytearray) -> None:
if self._cipher is not None and self._is_encrypted:
dec: AEADDecryptionContext = self._cipher.decryptor()
self._data = bytes(dec.update(bytes(data)) + dec.finalize())
LOGGER.debug("%s %s", "decoded data: ".rjust(19+len(self.name)), self._data.hex(" "))
LOGGER.debug(
"%s %s",
"decoded data: ".rjust(19 + len(self.name)),
self._data.hex(" "),
)

self._data_event.set()

Expand Down
88 changes: 83 additions & 5 deletions custom_components/hunterdouglas_powerview_ble/cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
)
from homeassistant.components.cover import (
ATTR_CURRENT_POSITION,
ATTR_CURRENT_TILT_POSITION,
ATTR_POSITION,
ATTR_TILT_POSITION,
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
Expand All @@ -32,11 +34,15 @@ async def async_setup_entry(
"""Set up the demo cover platform."""

coordinator: PVCoordinator = config_entry.runtime_data
async_add_entities([PowerViewCover(coordinator)])
async_add_entities(
[PowerViewCoverTilt(coordinator)]
if coordinator.dev_details.get("model") in ["8", "51", "62"]
else [PowerViewCover(coordinator)]
)


class PowerViewCover(PassiveBluetoothCoordinatorEntity[PVCoordinator], CoverEntity): # type: ignore[reportIncompatibleVariableOverride]
"""Representation of a powerview shade."""
"""Representation of a PowerView shade with Up/Down functionality only."""

_attr_has_entity_name = True
_attr_device_class = CoverDeviceClass.SHADE
Expand All @@ -52,8 +58,9 @@ def __init__(
coordinator: PVCoordinator,
) -> None:
"""Initialize the shade."""
LOGGER.debug("%s: init() PowerViewCover", coordinator.name)
self._attr_name = CoverDeviceClass.SHADE
self._coord = coordinator
self._coord: PVCoordinator = coordinator
self._attr_device_info = self._coord.device_info
self._target_position: int | None = round(
self._coord.data.get(ATTR_CURRENT_POSITION, OPEN_POSITION)
Expand Down Expand Up @@ -113,7 +120,7 @@ def current_cover_position(self) -> int | None: # type: ignore[reportIncompatib
return round(pos) if pos is not None else None

async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""

target_position: Final = kwargs.get(ATTR_POSITION)
if target_position is not None:
LOGGER.debug("set cover to position %f", target_position)
Expand All @@ -123,7 +130,7 @@ async def async_set_cover_position(self, **kwargs: Any) -> None:
return
self._target_position = round(target_position)
try:
await self._coord.api.set_position(round(target_position))
await self._coord.api.set_position(round(target_position), tilt = self.current_cover_tilt_position )
self.async_write_ha_state()
except BleakError as err:
LOGGER.error(
Expand Down Expand Up @@ -171,3 +178,74 @@ async def async_stop_cover(self, **kwargs: Any) -> None:
self.async_write_ha_state()
except BleakError as err:
LOGGER.error("Failed to stop cover '%s': %s", self.name, err)


class PowerViewCoverTilt(PowerViewCover):
"""Representation of a PowerView shade with additional tilt functionality."""

_attr_supported_features = (
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.STOP
| CoverEntityFeature.SET_POSITION
| CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT
| CoverEntityFeature.SET_TILT_POSITION
)

def __init__(
self,
coordinator: PVCoordinator,
) -> None:
LOGGER.debug("%s: init() PowerViewCoverTilt", coordinator.name)
super().__init__(coordinator)

@property
def current_cover_tilt_position(self) -> int | None: # type: ignore[reportIncompatibleVariableOverride]
"""Return current tilt of cover.

None is unknown
"""
pos: Final = self._coord.data.get(ATTR_CURRENT_TILT_POSITION)
return round(pos) if pos is not None else None

async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the tilt to a specific position."""

if isinstance(target_position := kwargs.get(ATTR_TILT_POSITION), int):
LOGGER.debug("set cover tilt to position %i", target_position)
if (
self.current_cover_tilt_position == round(target_position)
or self.current_cover_position is None
):
return

try:
await self._coord.api.set_position(
self.current_cover_position, pos2=target_position
)
self.async_write_ha_state()
except BleakError as err:
LOGGER.error(
"Failed to tilt cover '%s' to %f%%: %s",
self.name,
target_position,
err,
)

async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
"""Stop the cover."""
await self.async_stop_cover(kwargs=kwargs)

async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the cover tilt."""
LOGGER.debug("open cover tilt")
_kwargs = {**kwargs, ATTR_TILT_POSITION: OPEN_POSITION}
await self.async_set_cover_tilt_position(**_kwargs)

async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the cover tilt."""
LOGGER.debug("close cover tilt")
_kwargs = {**kwargs, ATTR_TILT_POSITION: CLOSED_POSITION}
await self.async_set_cover_tilt_position(**_kwargs)
3 changes: 1 addition & 2 deletions custom_components/hunterdouglas_powerview_ble/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@
"bluetooth": [
{
"service_uuid": "0000fdc1-0000-1000-8000-00805f9b34fb",
"manufacturer_id": 2073,
"manufacturer_data_start": [0,0]
"manufacturer_id": 2073
}
],
"codeowners": ["@patman15"],
Expand Down