Skip to content

Commit 5d498b8

Browse files
mannkindpatman15
andauthored
Initial support for Parkland/TypeID 39 shades (#12)
* disabled home_id filter * stronger typing * Update .gitignore * extend set_position() for all values * support setting tilt * completed tilt control * add tilt functions * Initial support for Parkland/TypeID 39 shades * Update cover.py * Update README.md * simplify set_position() * fix spelling issues * Update .gitignore * remove unverified shade types * clean code * fix ruff * update linting * Update lint.yml --------- Co-authored-by: patman15 <14628713+patman15@users.noreply.github.com>
1 parent b558083 commit 5d498b8

File tree

8 files changed

+200
-30
lines changed

8 files changed

+200
-30
lines changed

.github/workflows/lint.yml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ on:
88
- cron: '0 5 * * 6'
99

1010
jobs:
11-
ruff:
12-
name: "Ruff"
11+
lint:
12+
name: "Lint the code"
1313
runs-on: "ubuntu-latest"
1414
steps:
1515
- name: "Checkout the repository"
@@ -24,5 +24,11 @@ jobs:
2424
- name: "Install requirements"
2525
run: python3 -m pip install -r requirements.txt
2626

27-
- name: "Run Ruff"
28-
run: python3 -m ruff check .
27+
- name: "Run ruff"
28+
run: ruff check .
29+
30+
- name: "Run mypy"
31+
run: mypy .
32+
33+
- name: "Run codespell"
34+
run: codespell -L hass

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Type* | Description
2525
10 | Duette and Applause SkyLift
2626
19 | Provenance Woven Wood
2727
31, 32, 84 | Vignette
28+
39 | Parkland
2829
42 | M25T Roller Blind
2930
49 | AC Roller
3031
52 | Banded Shades
@@ -85,6 +86,9 @@ In case you have severe troubles,
8586
- disable the log (Home Assistant will prompt you to download the log), and finally
8687
- [open an issue](https://github.com/patman15/hdpv_ble/issues/new?assignees=&labels=Bug&projects=&template=bug.yml) with a good description of what happened and attach the log.
8788

89+
# Thanks To
90+
[@mannkind](https://github.com/mannkind)
91+
8892
[license-shield]: https://img.shields.io/github/license/patman15/hdpv_ble.svg?style=for-the-badge
8993
[releases-shield]: https://img.shields.io/github/release/patman15/hdpv_ble.svg?style=for-the-badge
9094
[releases]: https://github.com//patman15/hdpv_ble/releases

custom_components/hunterdouglas_powerview_ble/__init__.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,15 @@
1313
from homeassistant.core import HomeAssistant
1414
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
1515

16-
from .const import DOMAIN, LOGGER
16+
from .const import LOGGER
1717
from .coordinator import PVCoordinator
1818

19-
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.COVER, Platform.SENSOR, Platform.BUTTON]
19+
PLATFORMS: list[Platform] = [
20+
Platform.BINARY_SENSOR,
21+
Platform.COVER,
22+
Platform.SENSOR,
23+
Platform.BUTTON,
24+
]
2025

2126
type ConfigEntryType = ConfigEntry[PVCoordinator]
2227

@@ -43,8 +48,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntryType) -> bool
4348
except BleakError as err:
4449
raise ConfigEntryNotReady("Unable to query device info.") from err
4550

46-
# Insert the coordinator in the global registry
47-
hass.data.setdefault(DOMAIN, {})
4851
entry.runtime_data = coordinator
4952
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
5053
entry.async_on_unload(coordinator.async_start())

custom_components/hunterdouglas_powerview_ble/api.py

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
AEADEncryptionContext,
1818
)
1919

20-
from homeassistant.components.cover import ATTR_CURRENT_POSITION
20+
from homeassistant.components.cover import (
21+
ATTR_CURRENT_POSITION,
22+
ATTR_CURRENT_TILT_POSITION,
23+
)
2124

2225
from .const import LOGGER, TIMEOUT
2326

@@ -48,7 +51,11 @@
4851
8: "Duette, Top Down Bottom Up",
4952
9: "Duette DuoLite, Top Down Bottom Up",
5053
33: "Duette Architella, Top Down Bottom Up",
54+
39: "Parkland",
5155
47: "Pleated, Top Down Bottom Up",
56+
# top down, tilt anywhere
57+
51: "Venetian, Tilt Anywhere",
58+
62: "Venetian, Tilt Anywhere",
5259
}
5360

5461
OPEN_POSITION: Final[int] = 100
@@ -138,9 +145,7 @@ def is_connected(self) -> bool:
138145
return self._client.is_connected
139146

140147
# general cmd: uint16_t cmd, uint8_t seqID, uint8_t data_len
141-
async def _cmd(
142-
self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True
143-
) -> None:
148+
async def _cmd(self, cmd: tuple[ShadeCmd, bytes], disconnect: bool = True) -> None:
144149
self._cmd_next = cmd
145150
if self._cmd_lock.locked():
146151
LOGGER.debug("%s: device busy, queuing %s command", self.name, cmd[0])
@@ -182,13 +187,13 @@ def dec_manufacturer_data(data: bytearray) -> list[tuple[str, float]]:
182187
if len(data) != 9:
183188
LOGGER.debug("not a V2 record!")
184189
return []
185-
pos: int = int.from_bytes(data[3:5], byteorder="little")
186-
pos2: int = (int(data[5]) << 4) + (int(data[4]) >> 4)
190+
pos: Final[int] = int.from_bytes(data[3:5], byteorder="little")
191+
pos2: Final[int] = (int(data[5]) << 4) + (int(data[4]) >> 4)
187192
return [
188193
(ATTR_CURRENT_POSITION, ((pos >> 2) / 10)),
189194
("position2", pos2 >> 2),
190195
("position3", int(data[6])),
191-
("tilt", int(data[7])),
196+
(ATTR_CURRENT_TILT_POSITION, int(data[7])),
192197
("home_id", int.from_bytes(data[0:2], byteorder="little")),
193198
("type_id", int(data[2])),
194199
("is_opening", bool(pos & 0x3 == 0x2)),
@@ -200,16 +205,33 @@ def dec_manufacturer_data(data: bytearray) -> list[tuple[str, float]]:
200205
]
201206

202207
# position cmd: uint16_t pos1, uint16_t pos2, uint16_t pos3, uint16_t tilt, uint8_t velocity
203-
async def set_position(self, value: int, disconnect: bool = True) -> None:
208+
async def set_position(
209+
self,
210+
pos1: int,
211+
pos2: int = 0x8000,
212+
pos3: int = 0x8000,
213+
tilt: int = 0x8000,
214+
velocity: int = 0x0,
215+
disconnect: bool = True,
216+
) -> None:
204217
"""Set position of device."""
205-
LOGGER.debug("%s setting position to %i", self.name, value)
218+
LOGGER.debug(
219+
"%s setting position to %i/%i/%i, tilt %i, velocity %s",
220+
self.name,
221+
pos1,
222+
pos2,
223+
pos3,
224+
tilt,
225+
velocity,
226+
)
206227
await self._cmd(
207228
(
208229
ShadeCmd.SET_POSITION,
209-
bytes(
210-
int.to_bytes(value * 100, 2, byteorder="little")
211-
+ bytes([0x00, 0x80, 0x00, 0x80, 0x00, 0x80, 0x0])
212-
),
230+
int.to_bytes(pos1, 2, byteorder="little")
231+
+ int.to_bytes(pos2, 2, byteorder="little")
232+
+ int.to_bytes(pos3, 2, byteorder="little")
233+
+ int.to_bytes(tilt, 2, byteorder="little")
234+
+ int.to_bytes(velocity, 1),
213235
),
214236
disconnect,
215237
)
@@ -291,8 +313,8 @@ async def query_dev_info(self) -> dict[str, str]:
291313
.copy()
292314
.decode("UTF-8")
293315
)
294-
except Exception as ex:
295-
LOGGER.error("Error: %s - %s", type(ex).__name__, ex)
316+
except BleakError as ex:
317+
LOGGER.debug("%s: querying failed: %s", self.name, ex)
296318
raise
297319
finally:
298320
await self.disconnect()
@@ -310,7 +332,11 @@ def _notification_handler(self, _sender, data: bytearray) -> None:
310332
if self._cipher is not None and self._is_encrypted:
311333
dec: AEADDecryptionContext = self._cipher.decryptor()
312334
self._data = bytes(dec.update(bytes(data)) + dec.finalize())
313-
LOGGER.debug("%s %s", "decoded data: ".rjust(19+len(self.name)), self._data.hex(" "))
335+
LOGGER.debug(
336+
"%s %s",
337+
"decoded data: ".rjust(19 + len(self.name)),
338+
self._data.hex(" "),
339+
)
314340

315341
self._data_event.set()
316342

custom_components/hunterdouglas_powerview_ble/cover.py

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
)
1010
from homeassistant.components.cover import (
1111
ATTR_CURRENT_POSITION,
12+
ATTR_CURRENT_TILT_POSITION,
1213
ATTR_POSITION,
14+
ATTR_TILT_POSITION,
1315
CoverDeviceClass,
1416
CoverEntity,
1517
CoverEntityFeature,
@@ -32,11 +34,18 @@ async def async_setup_entry(
3234
"""Set up the demo cover platform."""
3335

3436
coordinator: PVCoordinator = config_entry.runtime_data
35-
async_add_entities([PowerViewCover(coordinator)])
37+
model: Final[str|None] = coordinator.dev_details.get("model")
38+
entities: list[PowerViewCover] = []
39+
if model in ["39"]:
40+
entities.append(PowerViewCoverTiltOnly(coordinator))
41+
else:
42+
entities.append(PowerViewCover(coordinator))
43+
44+
async_add_entities(entities)
3645

3746

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

4150
_attr_has_entity_name = True
4251
_attr_device_class = CoverDeviceClass.SHADE
@@ -52,8 +61,9 @@ def __init__(
5261
coordinator: PVCoordinator,
5362
) -> None:
5463
"""Initialize the shade."""
64+
LOGGER.debug("%s: init() PowerViewCover", coordinator.name)
5565
self._attr_name = CoverDeviceClass.SHADE
56-
self._coord = coordinator
66+
self._coord: PVCoordinator = coordinator
5767
self._attr_device_info = self._coord.device_info
5868
self._target_position: int | None = round(
5969
self._coord.data.get(ATTR_CURRENT_POSITION, OPEN_POSITION)
@@ -171,3 +181,117 @@ async def async_stop_cover(self, **kwargs: Any) -> None:
171181
self.async_write_ha_state()
172182
except BleakError as err:
173183
LOGGER.error("Failed to stop cover '%s': %s", self.name, err)
184+
185+
186+
class PowerViewCoverTilt(PowerViewCover):
187+
"""Representation of a PowerView shade with additional tilt functionality."""
188+
189+
_attr_supported_features = (
190+
CoverEntityFeature.OPEN
191+
| CoverEntityFeature.CLOSE
192+
| CoverEntityFeature.STOP
193+
| CoverEntityFeature.SET_POSITION
194+
| CoverEntityFeature.OPEN_TILT
195+
| CoverEntityFeature.CLOSE_TILT
196+
| CoverEntityFeature.STOP_TILT
197+
| CoverEntityFeature.SET_TILT_POSITION
198+
)
199+
200+
def __init__(
201+
self,
202+
coordinator: PVCoordinator,
203+
) -> None:
204+
"""Initialize the shade with tilt."""
205+
LOGGER.debug("%s: init() PowerViewCoverTilt", coordinator.name)
206+
super().__init__(coordinator)
207+
208+
@property
209+
def current_cover_tilt_position(self) -> int | None: # type: ignore[reportIncompatibleVariableOverride]
210+
"""Return current tilt of cover.
211+
212+
None is unknown
213+
"""
214+
pos: Final = self._coord.data.get(ATTR_CURRENT_TILT_POSITION)
215+
return round(pos) if pos is not None else None
216+
217+
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
218+
"""Move the tilt to a specific position."""
219+
220+
if isinstance(target_position := kwargs.get(ATTR_TILT_POSITION), int):
221+
LOGGER.debug("set cover tilt to position %i", target_position)
222+
if (
223+
self.current_cover_tilt_position == round(target_position)
224+
or self.current_cover_position is None
225+
):
226+
return
227+
228+
try:
229+
await self._coord.api.set_position(
230+
self.current_cover_position, tilt=target_position
231+
)
232+
self.async_write_ha_state()
233+
except BleakError as err:
234+
LOGGER.error(
235+
"Failed to tilt cover '%s' to %f%%: %s",
236+
self.name,
237+
target_position,
238+
err,
239+
)
240+
241+
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
242+
"""Stop the cover."""
243+
await self.async_stop_cover(kwargs=kwargs)
244+
245+
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
246+
"""Open the cover tilt."""
247+
LOGGER.debug("open cover tilt")
248+
_kwargs = {**kwargs, ATTR_TILT_POSITION: OPEN_POSITION}
249+
await self.async_set_cover_tilt_position(**_kwargs)
250+
251+
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
252+
"""Close the cover tilt."""
253+
LOGGER.debug("close cover tilt")
254+
_kwargs = {**kwargs, ATTR_TILT_POSITION: CLOSED_POSITION}
255+
await self.async_set_cover_tilt_position(**_kwargs)
256+
257+
258+
class PowerViewCoverTiltOnly(PowerViewCoverTilt):
259+
"""Representation of a PowerView shade with additional tilt functionality."""
260+
261+
OPENCLOSED_THRESHOLD = 5
262+
263+
_attr_device_class = CoverDeviceClass.BLIND
264+
_attr_supported_features = (
265+
CoverEntityFeature.OPEN_TILT
266+
| CoverEntityFeature.CLOSE_TILT
267+
| CoverEntityFeature.STOP_TILT
268+
| CoverEntityFeature.SET_TILT_POSITION
269+
)
270+
271+
def __init__(
272+
self,
273+
coordinator: PVCoordinator,
274+
) -> None:
275+
"""Initialize the shade with tilt only."""
276+
LOGGER.debug("%s: init() PowerViewCoverTiltOnly", coordinator.name)
277+
super().__init__(coordinator)
278+
279+
@property
280+
def is_opening(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride]
281+
"""Return if the cover is opening or not."""
282+
return False
283+
284+
@property
285+
def is_closing(self) -> bool | None: # type: ignore[reportIncompatibleVariableOverride]
286+
"""Return if the cover is closing or not."""
287+
return False
288+
289+
@property
290+
def is_closed(self) -> bool: # type: ignore[reportIncompatibleVariableOverride]
291+
"""Return if the cover is closed."""
292+
return isinstance(self.current_cover_tilt_position, int) and (
293+
self.current_cover_tilt_position
294+
>= OPEN_POSITION - PowerViewCoverTiltOnly.OPENCLOSED_THRESHOLD
295+
or self.current_cover_tilt_position
296+
<= CLOSED_POSITION + PowerViewCoverTiltOnly.OPENCLOSED_THRESHOLD
297+
)

custom_components/hunterdouglas_powerview_ble/manifest.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44
"bluetooth": [
55
{
66
"service_uuid": "0000fdc1-0000-1000-8000-00805f9b34fb",
7-
"manufacturer_id": 2073,
8-
"manufacturer_data_start": [0,0]
7+
"manufacturer_id": 2073
98
}
109
],
1110
"codeowners": ["@patman15"],

emu/PV_BLE_cover/PV_BLE_cover.ino

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
* Emulate a Hunter Douglas PowerView cover device using ESP32
33
* used e.g. to gain the home_key from an existing installation via BLE
44
*
5+
* REQUIRES:
6+
* - ESP32 Board Definitions 3.0.x (tested on 3.0.7)
7+
* - WolfSSL 5.7.x (tested on 5.7.6)
8+
* - Phone Region: Potentially an alternative region depending on the app response
9+
* - e.g. To add Parkland shades in the US, phone region set to the UK temporarily
10+
*
511
* TODO:
612
* - cleanup code
713
* - think about emulating a remote

requirements.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
homeassistant==2025.11.0
22
pip>=21.3.1
33
ruff>=0.9.1,<=0.15.0
4-
4+
types-requests
5+
mypy~=1.19.1
6+
codespell

0 commit comments

Comments
 (0)