add upper and lower shutter of Velux dualrollershutters as entities (#162998)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
wollew
2026-02-16 18:57:45 +01:00
committed by GitHub
parent 66dc566d3a
commit e6c5e72470
6 changed files with 452 additions and 10 deletions

View File

@@ -2,11 +2,13 @@
from __future__ import annotations
from enum import StrEnum
from typing import Any
from pyvlx import (
from pyvlx.opening_device import (
Awning,
Blind,
DualRollerShutter,
GarageDoor,
Gate,
OpeningDevice,
@@ -43,6 +45,23 @@ async def async_setup_entry(
for node in pyvlx.nodes:
if isinstance(node, Blind):
entities.append(VeluxBlind(node, config_entry.entry_id))
elif isinstance(node, DualRollerShutter):
# add three entities, one for each part and the "dual" control
entities.append(
VeluxDualRollerShutter(
node, config_entry.entry_id, VeluxDualRollerPart.DUAL
)
)
entities.append(
VeluxDualRollerShutter(
node, config_entry.entry_id, VeluxDualRollerPart.UPPER
)
)
entities.append(
VeluxDualRollerShutter(
node, config_entry.entry_id, VeluxDualRollerPart.LOWER
)
)
elif isinstance(node, OpeningDevice):
entities.append(VeluxCover(node, config_entry.entry_id))
@@ -54,9 +73,6 @@ class VeluxCover(VeluxEntity, CoverEntity):
node: OpeningDevice
# Do not name the "main" feature of the device (position control)
_attr_name = None
# Features common to all covers
_attr_supported_features = (
CoverEntityFeature.OPEN
@@ -125,6 +141,72 @@ class VeluxCover(VeluxEntity, CoverEntity):
await self.node.stop(wait_for_completion=False)
class VeluxDualRollerPart(StrEnum):
"""Enum for the parts of a dual roller shutter."""
UPPER = "upper"
LOWER = "lower"
DUAL = "dual"
class VeluxDualRollerShutter(VeluxCover):
"""Representation of a Velux dual roller shutter cover."""
node: DualRollerShutter
_attr_device_class = CoverDeviceClass.SHUTTER
def __init__(
self, node: DualRollerShutter, config_entry_id: str, part: VeluxDualRollerPart
) -> None:
"""Initialize VeluxDualRollerShutter."""
super().__init__(node, config_entry_id)
if part == VeluxDualRollerPart.DUAL:
self._attr_name = None
else:
self._attr_unique_id = f"{self._attr_unique_id}_{part}"
self._attr_translation_key = f"dual_roller_shutter_{part}"
self.part = part
@property
def current_cover_position(self) -> int:
"""Return the current position of the cover."""
if self.part == VeluxDualRollerPart.UPPER:
return 100 - self.node.position_upper_curtain.position_percent
if self.part == VeluxDualRollerPart.LOWER:
return 100 - self.node.position_lower_curtain.position_percent
return 100 - self.node.position.position_percent
@property
def is_closed(self) -> bool:
"""Return if the cover is closed."""
if self.part == VeluxDualRollerPart.UPPER:
return self.node.position_upper_curtain.closed
if self.part == VeluxDualRollerPart.LOWER:
return self.node.position_lower_curtain.closed
return self.node.position.closed
@wrap_pyvlx_call_exceptions
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
await self.node.close(curtain=self.part, wait_for_completion=False)
@wrap_pyvlx_call_exceptions
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self.node.open(curtain=self.part, wait_for_completion=False)
@wrap_pyvlx_call_exceptions
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
position_percent = 100 - kwargs[ATTR_POSITION]
await self.node.set_position(
Position(position_percent=position_percent),
curtain=self.part,
wait_for_completion=False,
)
class VeluxBlind(VeluxCover):
"""Representation of a Velux blind cover."""

View File

@@ -45,6 +45,14 @@
"rain_sensor": {
"name": "Rain sensor"
}
},
"cover": {
"dual_roller_shutter_lower": {
"name": "Lower shutter"
},
"dual_roller_shutter_upper": {
"name": "Upper shutter"
}
}
},
"exceptions": {

View File

@@ -15,7 +15,8 @@ async def update_callback_entity(
) -> None:
"""Simulate an update triggered by the pyvlx lib for a Velux node."""
callback = mock_velux_node.register_device_updated_cb.call_args[0][0]
for c in mock_velux_node.register_device_updated_cb.call_args_list:
callback = c[0][0]
await callback(mock_velux_node)
await hass.async_block_till_done()

View File

@@ -4,7 +4,8 @@ from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pyvlx import Blind, Light, OnOffLight, OnOffSwitch, Scene, Window
from pyvlx import Light, OnOffLight, OnOffSwitch, Scene
from pyvlx.opening_device import Blind, DualRollerShutter, Window
from homeassistant.components.velux import DOMAIN
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform
@@ -69,6 +70,22 @@ def mock_window() -> AsyncMock:
return window
# a dual roller shutter
@pytest.fixture
def mock_dual_roller_shutter() -> AsyncMock:
"""Create a mock Velux dual roller shutter."""
cover = AsyncMock(spec=DualRollerShutter, autospec=True)
cover.name = "Test Dual Roller Shutter"
cover.serial_number = "987654321"
cover.is_opening = False
cover.is_closing = False
cover.position_upper_curtain = MagicMock(position_percent=30, closed=False)
cover.position_lower_curtain = MagicMock(position_percent=30, closed=False)
cover.position = MagicMock(position_percent=30, closed=False)
cover.pyvlx = MagicMock()
return cover
# a blind
@pytest.fixture
def mock_blind() -> AsyncMock:
@@ -137,6 +154,8 @@ def mock_cover_type(request: pytest.FixtureRequest) -> AsyncMock:
cover.is_opening = False
cover.is_closing = False
cover.position = MagicMock(position_percent=30, closed=False)
cover.position_upper_curtain = MagicMock(position_percent=30, closed=False)
cover.position_lower_curtain = MagicMock(position_percent=30, closed=False)
cover.pyvlx = MagicMock()
return cover
@@ -149,6 +168,7 @@ def mock_pyvlx(
mock_onoff_switch: AsyncMock,
mock_window: AsyncMock,
mock_blind: AsyncMock,
mock_dual_roller_shutter: AsyncMock,
request: pytest.FixtureRequest,
) -> Generator[MagicMock]:
"""Create the library mock and patch PyVLX in both component and config_flow.
@@ -164,6 +184,7 @@ def mock_pyvlx(
pyvlx.nodes = [request.getfixturevalue(request.param)]
else:
pyvlx.nodes = [
mock_dual_roller_shutter,
mock_light,
mock_onoff_light,
mock_onoff_switch,

View File

@@ -104,6 +104,162 @@
'state': 'open',
})
# ---
# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'cover',
'entity_category': None,
'entity_id': 'cover.test_dualrollershutter',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': <CoverDeviceClass.SHUTTER: 'shutter'>,
'original_icon': None,
'original_name': None,
'platform': 'velux',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <CoverEntityFeature: 15>,
'translation_key': None,
'unique_id': 'serial_DualRollerShutter',
'unit_of_measurement': None,
})
# ---
# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_position': 70,
'device_class': 'shutter',
'friendly_name': 'Test DualRollerShutter',
'supported_features': <CoverEntityFeature: 15>,
}),
'context': <ANY>,
'entity_id': 'cover.test_dualrollershutter',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'open',
})
# ---
# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter_lower_shutter-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'cover',
'entity_category': None,
'entity_id': 'cover.test_dualrollershutter_lower_shutter',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Lower shutter',
'options': dict({
}),
'original_device_class': <CoverDeviceClass.SHUTTER: 'shutter'>,
'original_icon': None,
'original_name': 'Lower shutter',
'platform': 'velux',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <CoverEntityFeature: 15>,
'translation_key': 'dual_roller_shutter_lower',
'unique_id': 'serial_DualRollerShutter_lower',
'unit_of_measurement': None,
})
# ---
# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter_lower_shutter-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_position': 70,
'device_class': 'shutter',
'friendly_name': 'Test DualRollerShutter Lower shutter',
'supported_features': <CoverEntityFeature: 15>,
}),
'context': <ANY>,
'entity_id': 'cover.test_dualrollershutter_lower_shutter',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'open',
})
# ---
# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter_upper_shutter-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'cover',
'entity_category': None,
'entity_id': 'cover.test_dualrollershutter_upper_shutter',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Upper shutter',
'options': dict({
}),
'original_device_class': <CoverDeviceClass.SHUTTER: 'shutter'>,
'original_icon': None,
'original_name': 'Upper shutter',
'platform': 'velux',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <CoverEntityFeature: 15>,
'translation_key': 'dual_roller_shutter_upper',
'unique_id': 'serial_DualRollerShutter_upper',
'unit_of_measurement': None,
})
# ---
# name: test_cover_entity_setup[mock_cover_type-DualRollerShutter][cover.test_dualrollershutter_upper_shutter-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_position': 70,
'device_class': 'shutter',
'friendly_name': 'Test DualRollerShutter Upper shutter',
'supported_features': <CoverEntityFeature: 15>,
}),
'context': <ANY>,
'entity_id': 'cover.test_dualrollershutter_upper_shutter',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'open',
})
# ---
# name: test_cover_entity_setup[mock_cover_type-GarageDoor][cover.test_garagedoor-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -4,7 +4,14 @@ from unittest.mock import AsyncMock
import pytest
from pyvlx.exception import PyVLXException
from pyvlx.opening_device import Awning, GarageDoor, Gate, RollerShutter, Window
from pyvlx.opening_device import (
Awning,
DualRollerShutter,
GarageDoor,
Gate,
RollerShutter,
Window,
)
from homeassistant.components.cover import (
ATTR_POSITION,
@@ -64,7 +71,9 @@ async def test_blind_entity_setup(
@pytest.mark.usefixtures("mock_cover_type")
@pytest.mark.parametrize(
"mock_cover_type", [Awning, GarageDoor, Gate, RollerShutter, Window], indirect=True
"mock_cover_type",
[Awning, DualRollerShutter, GarageDoor, Gate, RollerShutter, Window],
indirect=True,
)
@pytest.mark.parametrize(
"mock_pyvlx",
@@ -103,7 +112,13 @@ async def test_cover_device_association(
assert entry.device_id is not None
device_entry = device_registry.async_get(entry.device_id)
assert device_entry is not None
assert (DOMAIN, entry.unique_id) in device_entry.identifiers
# For dual roller shutters, the unique_id is suffixed with "_upper" or "_lower",
# so remove that suffix to get the domain_id for device registry lookup
domain_id = entry.unique_id
if entry.unique_id.endswith("_upper") or entry.unique_id.endswith("_lower"):
domain_id = entry.unique_id.rsplit("_", 1)[0]
assert (DOMAIN, domain_id) in device_entry.identifiers
assert device_entry.via_device_id is not None
via_device_entry = device_registry.async_get(device_entry.via_device_id)
assert via_device_entry is not None
@@ -220,6 +235,165 @@ async def test_window_current_position_and_opening_closing_states(
assert state.state == STATE_CLOSING
# Dual roller shutter command tests
async def test_dual_roller_shutter_open_close_services(
hass: HomeAssistant, mock_dual_roller_shutter: AsyncMock
) -> None:
"""Verify open/close services map to device calls with correct part."""
dual_entity_id = "cover.test_dual_roller_shutter"
upper_entity_id = "cover.test_dual_roller_shutter_upper_shutter"
lower_entity_id = "cover.test_dual_roller_shutter_lower_shutter"
# Open upper part
await hass.services.async_call(
COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": upper_entity_id}, blocking=True
)
mock_dual_roller_shutter.open.assert_awaited_with(
curtain="upper", wait_for_completion=False
)
# Open lower part
await hass.services.async_call(
COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": lower_entity_id}, blocking=True
)
mock_dual_roller_shutter.open.assert_awaited_with(
curtain="lower", wait_for_completion=False
)
# Open dual
await hass.services.async_call(
COVER_DOMAIN, SERVICE_OPEN_COVER, {"entity_id": dual_entity_id}, blocking=True
)
mock_dual_roller_shutter.open.assert_awaited_with(
curtain="dual", wait_for_completion=False
)
# Close upper part
await hass.services.async_call(
COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": upper_entity_id}, blocking=True
)
mock_dual_roller_shutter.close.assert_awaited_with(
curtain="upper", wait_for_completion=False
)
# Close lower part
await hass.services.async_call(
COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": lower_entity_id}, blocking=True
)
mock_dual_roller_shutter.close.assert_awaited_with(
curtain="lower", wait_for_completion=False
)
# Close dual
await hass.services.async_call(
COVER_DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": dual_entity_id}, blocking=True
)
mock_dual_roller_shutter.close.assert_awaited_with(
curtain="dual", wait_for_completion=False
)
async def test_dual_shutter_set_cover_position_inversion(
hass: HomeAssistant, mock_dual_roller_shutter: AsyncMock
) -> None:
"""HA position is inverted for device's Position."""
entity_id = "cover.test_dual_roller_shutter"
# Call with position 30 (=70% for device)
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_SET_COVER_POSITION,
{"entity_id": entity_id, ATTR_POSITION: 30},
blocking=True,
)
# Expect device Position 70%
args, kwargs = mock_dual_roller_shutter.set_position.await_args
position_obj = args[0]
assert position_obj.position_percent == 70
assert kwargs.get("wait_for_completion") is False
assert kwargs.get("curtain") == "dual"
entity_id = "cover.test_dual_roller_shutter_upper_shutter"
# Call with position 30 (=70% for device)
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_SET_COVER_POSITION,
{"entity_id": entity_id, ATTR_POSITION: 30},
blocking=True,
)
# Expect device Position 70%
args, kwargs = mock_dual_roller_shutter.set_position.await_args
position_obj = args[0]
assert position_obj.position_percent == 70
assert kwargs.get("wait_for_completion") is False
assert kwargs.get("curtain") == "upper"
entity_id = "cover.test_dual_roller_shutter_lower_shutter"
# Call with position 30 (=70% for device)
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_SET_COVER_POSITION,
{"entity_id": entity_id, ATTR_POSITION: 30},
blocking=True,
)
# Expect device Position 70%
args, kwargs = mock_dual_roller_shutter.set_position.await_args
position_obj = args[0]
assert position_obj.position_percent == 70
assert kwargs.get("wait_for_completion") is False
assert kwargs.get("curtain") == "lower"
async def test_dual_roller_shutter_position_tests(
hass: HomeAssistant, mock_dual_roller_shutter: AsyncMock
) -> None:
"""Validate current_position and open/closed state."""
entity_id_dual = "cover.test_dual_roller_shutter"
entity_id_lower = "cover.test_dual_roller_shutter_lower_shutter"
entity_id_upper = "cover.test_dual_roller_shutter_upper_shutter"
# device position is inverted (100 - x)
mock_dual_roller_shutter.position.position_percent = 29
mock_dual_roller_shutter.position_upper_curtain.position_percent = 28
mock_dual_roller_shutter.position_lower_curtain.position_percent = 27
await update_callback_entity(hass, mock_dual_roller_shutter)
state = hass.states.get(entity_id_dual)
assert state is not None
assert state.attributes.get("current_position") == 71
assert state.state == STATE_OPEN
state = hass.states.get(entity_id_upper)
assert state is not None
assert state.attributes.get("current_position") == 72
assert state.state == STATE_OPEN
state = hass.states.get(entity_id_lower)
assert state is not None
assert state.attributes.get("current_position") == 73
assert state.state == STATE_OPEN
mock_dual_roller_shutter.position.closed = True
mock_dual_roller_shutter.position_upper_curtain.closed = True
mock_dual_roller_shutter.position_lower_curtain.closed = True
await update_callback_entity(hass, mock_dual_roller_shutter)
state = hass.states.get(entity_id_dual)
assert state is not None
assert state.state == STATE_CLOSED
state = hass.states.get(entity_id_upper)
assert state is not None
assert state.state == STATE_CLOSED
state = hass.states.get(entity_id_lower)
assert state is not None
assert state.state == STATE_CLOSED
# Blind command tests