Add binary sensor for rain detection for Velux windows that have them (#148275)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
wollew 2025-07-28 13:15:59 +02:00 committed by GitHub
parent 95c5a91f01
commit 850e04d9aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 166 additions and 2 deletions

View File

@ -0,0 +1,63 @@
"""Support for rain sensors build into some velux windows."""
from __future__ import annotations
from datetime import timedelta
from pyvlx.exception import PyVLXException
from pyvlx.opening_device import OpeningDevice, Window
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, LOGGER
from .entity import VeluxEntity
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(minutes=5) # Use standard polling
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up rain sensor(s) for Velux platform."""
module = hass.data[DOMAIN][config.entry_id]
async_add_entities(
VeluxRainSensor(node, config.entry_id)
for node in module.pyvlx.nodes
if isinstance(node, Window) and node.rain_sensor
)
class VeluxRainSensor(VeluxEntity, BinarySensorEntity):
"""Representation of a Velux rain sensor."""
node: Window
_attr_should_poll = True # the rain sensor / opening limitations needs polling unlike the rest of the Velux devices
_attr_entity_registry_enabled_default = False
_attr_device_class = BinarySensorDeviceClass.MOISTURE
def __init__(self, node: OpeningDevice, config_entry_id: str) -> None:
"""Initialize VeluxRainSensor."""
super().__init__(node, config_entry_id)
self._attr_unique_id = f"{self._attr_unique_id}_rain_sensor"
self._attr_name = f"{node.name} Rain sensor"
async def async_update(self) -> None:
"""Fetch the latest state from the device."""
try:
limitation = await self.node.get_limitation()
except PyVLXException:
LOGGER.error("Error fetching limitation data for cover %s", self.name)
return
# Velux windows with rain sensors report an opening limitation of 93 when rain is detected.
self._attr_is_on = limitation.min_value == 93

View File

@ -5,5 +5,5 @@ from logging import getLogger
from homeassistant.const import Platform from homeassistant.const import Platform
DOMAIN = "velux" DOMAIN = "velux"
PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SCENE] PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.LIGHT, Platform.SCENE]
LOGGER = getLogger(__package__) LOGGER = getLogger(__package__)

View File

@ -1,16 +1,18 @@
"""Configuration for Velux tests.""" """Configuration for Velux tests."""
from collections.abc import Generator from collections.abc import Generator
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from homeassistant.components.velux import DOMAIN from homeassistant.components.velux import DOMAIN
from homeassistant.components.velux.binary_sensor import Window
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
# Fixtures for the config flow tests
@pytest.fixture @pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]: def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry.""" """Override async_setup_entry."""
@ -59,3 +61,52 @@ def mock_discovered_config_entry() -> MockConfigEntry:
}, },
unique_id="VELUX_KLF_ABCD", unique_id="VELUX_KLF_ABCD",
) )
# fixtures for the binary sensor tests
@pytest.fixture
def mock_window() -> AsyncMock:
"""Create a mock Velux window with a rain sensor."""
window = AsyncMock(spec=Window, autospec=True)
window.name = "Test Window"
window.rain_sensor = True
window.serial_number = "123456789"
window.get_limitation.return_value = MagicMock(min_value=0)
return window
@pytest.fixture
def mock_pyvlx(mock_window: MagicMock) -> MagicMock:
"""Create the library mock."""
pyvlx = MagicMock()
pyvlx.nodes = [mock_window]
pyvlx.load_scenes = AsyncMock()
pyvlx.load_nodes = AsyncMock()
pyvlx.disconnect = AsyncMock()
return pyvlx
@pytest.fixture
def mock_module(mock_pyvlx: MagicMock) -> Generator[AsyncMock]:
"""Create the Velux module mock."""
with (
patch(
"homeassistant.components.velux.VeluxModule",
autospec=True,
) as mock_velux,
):
module = mock_velux.return_value
module.pyvlx = mock_pyvlx
yield module
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "testhost",
CONF_PASSWORD: "testpw",
},
)

View File

@ -0,0 +1,50 @@
"""Tests for the Velux binary sensor platform."""
from datetime import timedelta
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.usefixtures("mock_module")
async def test_rain_sensor_state(
hass: HomeAssistant,
mock_window: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test the rain sensor."""
mock_config_entry.add_to_hass(hass)
test_entity_id = "binary_sensor.test_window_rain_sensor"
with (
patch("homeassistant.components.velux.PLATFORMS", [Platform.BINARY_SENSOR]),
):
# setup config entry
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# simulate no rain detected
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(test_entity_id)
assert state is not None
assert state.state == STATE_OFF
# simulate rain detected
mock_window.get_limitation.return_value.min_value = 93
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(test_entity_id)
assert state is not None
assert state.state == STATE_ON