diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index e53514ae19fc1d..f5e9f1c5951629 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -1,17 +1,31 @@ """Support for VELUX KLF 200 devices.""" +from __future__ import annotations + +from dataclasses import dataclass + from pyvlx import PyVLX, PyVLXException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import device_registry as dr from .const import DOMAIN, LOGGER, PLATFORMS -type VeluxConfigEntry = ConfigEntry[PyVLX] + +@dataclass +class VeluxData: + """Runtime data for Velux integration.""" + + pyvlx: PyVLX + gateway_device_id: tuple[str, str] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type VeluxConfigEntry = ConfigEntry[VeluxData] + + +async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool: """Set up the velux component.""" host = entry.data[CONF_HOST] password = entry.data[CONF_PASSWORD] @@ -25,7 +39,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.exception("Can't connect to velux interface: %s", ex) return False - entry.runtime_data = pyvlx + device_identifier = (DOMAIN, f"gateway_{entry.entry_id}") + entry.runtime_data = VeluxData(pyvlx=pyvlx, gateway_device_id=device_identifier) + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={device_identifier}, + name="KLF 200 Gateway", + manufacturer="Velux", + model="KLF 200", + hw_version=str(pyvlx.klf200.version.hardwareversion) + if pyvlx.klf200.version + else None, + sw_version=str(pyvlx.klf200.version.softwareversion) + if pyvlx.klf200.version + else None, + ) async def on_hass_stop(event): """Close connection when hass stops.""" @@ -46,6 +76,6 @@ async def async_reboot_gateway(service_call: ServiceCall) -> None: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py index c5dd12ad2ef9e0..003008dacfa7ee 100644 --- a/homeassistant/components/velux/binary_sensor.py +++ b/homeassistant/components/velux/binary_sensor.py @@ -24,14 +24,14 @@ async def async_setup_entry( hass: HomeAssistant, - config: VeluxConfigEntry, + config_entry: VeluxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up rain sensor(s) for Velux platform.""" - pyvlx = config.runtime_data + pyvlx = config_entry.runtime_data.pyvlx async_add_entities( - VeluxRainSensor(node, config.entry_id) + VeluxRainSensor(node, config_entry) for node in pyvlx.nodes if isinstance(node, Window) and node.rain_sensor ) @@ -46,9 +46,9 @@ class VeluxRainSensor(VeluxEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOISTURE _attr_translation_key = "rain_sensor" - def __init__(self, node: OpeningDevice, config_entry_id: str) -> None: + def __init__(self, node: OpeningDevice, config_entry: VeluxConfigEntry) -> None: """Initialize VeluxRainSensor.""" - super().__init__(node, config_entry_id) + super().__init__(node, config_entry) self._attr_unique_id = f"{self._attr_unique_id}_rain_sensor" async def async_update(self) -> None: diff --git a/homeassistant/components/velux/button.py b/homeassistant/components/velux/button.py new file mode 100644 index 00000000000000..aba9572e9bf5a9 --- /dev/null +++ b/homeassistant/components/velux/button.py @@ -0,0 +1,51 @@ +"""Support for VELUX KLF 200 gateway button.""" + +from __future__ import annotations + +from pyvlx import PyVLXException + +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import VeluxConfigEntry +from .const import DOMAIN, LOGGER + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VeluxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up button entities for the Velux integration.""" + async_add_entities([VeluxGatewayRebootButton(config_entry)]) + + +class VeluxGatewayRebootButton(ButtonEntity): + """Representation of the Velux Gateway reboot button.""" + + _attr_has_entity_name = True + _attr_translation_key = "reboot_gateway" + _attr_device_class = ButtonDeviceClass.RESTART + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, config_entry: VeluxConfigEntry) -> None: + """Initialize the gateway reboot button.""" + self.pyvlx = config_entry.runtime_data.pyvlx + self._attr_unique_id = f"{config_entry.entry_id}_reboot_gateway" + self._attr_device_info = { + "identifiers": {config_entry.runtime_data.gateway_device_id} + } + + async def async_press(self) -> None: + """Handle the button press - reboot the gateway.""" + try: + await self.pyvlx.reboot_gateway() + except PyVLXException as ex: + LOGGER.error("Failed to reboot gateway: %s", ex) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="reboot_failed", + ) from ex diff --git a/homeassistant/components/velux/const.py b/homeassistant/components/velux/const.py index 46663383250cd6..b33a98caa88769 100644 --- a/homeassistant/components/velux/const.py +++ b/homeassistant/components/velux/const.py @@ -5,5 +5,11 @@ from homeassistant.const import Platform DOMAIN = "velux" -PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.LIGHT, Platform.SCENE] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.COVER, + Platform.LIGHT, + Platform.SCENE, +] LOGGER = getLogger(__package__) diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 6207611403c432..0eaa9c208557d1 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -32,13 +32,13 @@ async def async_setup_entry( hass: HomeAssistant, - config: VeluxConfigEntry, + config_entry: VeluxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up cover(s) for Velux platform.""" - pyvlx = config.runtime_data + pyvlx = config_entry.runtime_data.pyvlx async_add_entities( - VeluxCover(node, config.entry_id) + VeluxCover(node, config_entry) for node in pyvlx.nodes if isinstance(node, OpeningDevice) ) @@ -53,9 +53,9 @@ class VeluxCover(VeluxEntity, CoverEntity): # Do not name the "main" feature of the device (position control) _attr_name = None - def __init__(self, node: OpeningDevice, config_entry_id: str) -> None: + def __init__(self, node: OpeningDevice, config_entry: VeluxConfigEntry) -> None: """Initialize VeluxCover.""" - super().__init__(node, config_entry_id) + super().__init__(node, config_entry) # Window is the default device class for covers self._attr_device_class = CoverDeviceClass.WINDOW if isinstance(node, Awning): diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py index fa06598f97958a..4971060cf14f0d 100644 --- a/homeassistant/components/velux/entity.py +++ b/homeassistant/components/velux/entity.py @@ -6,6 +6,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity +from . import VeluxConfigEntry from .const import DOMAIN @@ -15,25 +16,26 @@ class VeluxEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, node: Node, config_entry_id: str) -> None: + def __init__(self, node: Node, config_entry: VeluxConfigEntry) -> None: """Initialize the Velux device.""" self.node = node - self._attr_unique_id = ( + unique_id = ( node.serial_number if node.serial_number - else f"{config_entry_id}_{node.node_id}" + else f"{config_entry.entry_id}_{node.node_id}" ) + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( identifiers={ ( DOMAIN, - node.serial_number - if node.serial_number - else f"{config_entry_id}_{node.node_id}", + unique_id, ) }, name=node.name if node.name else f"#{node.node_id}", serial_number=node.serial_number, + via_device=config_entry.runtime_data.gateway_device_id, ) @callback diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py index 4bd367a0d62124..a4997a504d5acb 100644 --- a/homeassistant/components/velux/light.py +++ b/homeassistant/components/velux/light.py @@ -18,13 +18,13 @@ async def async_setup_entry( hass: HomeAssistant, - config: VeluxConfigEntry, + config_entry: VeluxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up light(s) for Velux platform.""" - pyvlx = config.runtime_data + pyvlx = config_entry.runtime_data.pyvlx async_add_entities( - VeluxLight(node, config.entry_id) + VeluxLight(node, config_entry) for node in pyvlx.nodes if isinstance(node, LighteningDevice) ) diff --git a/homeassistant/components/velux/scene.py b/homeassistant/components/velux/scene.py index f844070736be90..bd5a1b99d502ef 100644 --- a/homeassistant/components/velux/scene.py +++ b/homeassistant/components/velux/scene.py @@ -15,11 +15,11 @@ async def async_setup_entry( hass: HomeAssistant, - config: VeluxConfigEntry, + config_entry: VeluxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the scenes for Velux platform.""" - pyvlx = config.runtime_data + pyvlx = config_entry.runtime_data.pyvlx entities = [VeluxScene(scene) for scene in pyvlx.scenes] async_add_entities(entities) diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index cce7cdf2f137e9..b5d6d65f9f7a87 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -27,11 +27,21 @@ "rain_sensor": { "name": "Rain sensor" } + }, + "button": { + "reboot_gateway": { + "name": "Reboot gateway" + } + } + }, + "exceptions": { + "reboot_failed": { + "message": "Failed to reboot gateway. Try again in a few moments or power cycle the device manually" } }, "services": { "reboot_gateway": { - "description": "Reboots the KLF200 Gateway.", + "description": "Reboots the KLF200 Gateway", "name": "Reboot gateway" } } diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index f85d2f65d7dc8c..11e0f94181bf16 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -7,7 +7,8 @@ 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, Platform +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -101,3 +102,18 @@ def mock_config_entry() -> MockConfigEntry: CONF_PASSWORD: "testpw", }, ) + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_pyvlx: MagicMock, + platform: Platform, +) -> None: + """Set up the integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.velux.PLATFORMS", [platform]): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/velux/test_binary_sensor.py b/tests/components/velux/test_binary_sensor.py index f48a9102dfbbe3..ac39e7a3b6cccd 100644 --- a/tests/components/velux/test_binary_sensor.py +++ b/tests/components/velux/test_binary_sensor.py @@ -95,3 +95,11 @@ async def test_rain_sensor_device_association( # Verify device has correct identifiers assert ("velux", mock_window.serial_number) in device_entry.identifiers assert device_entry.name == mock_window.name + + # Verify via_device is gateway + 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 + assert via_device_entry.identifiers == { + mock_config_entry.runtime_data.gateway_device_id + } diff --git a/tests/components/velux/test_button.py b/tests/components/velux/test_button.py new file mode 100644 index 00000000000000..6b774cd7f6a289 --- /dev/null +++ b/tests/components/velux/test_button.py @@ -0,0 +1,95 @@ +"""Test Velux button entities.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pyvlx import PyVLXException + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.velux import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platform() -> Platform: + """Fixture to specify platform to test.""" + return Platform.BUTTON + + +@pytest.mark.usefixtures("setup_integration") +async def test_button_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test button entity setup and device association.""" + + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entity_entries) == 1 + + entry = entity_entries[0] + assert entry.translation_key == "reboot_gateway" + assert entry.unique_id == f"{mock_config_entry.entry_id}_reboot_gateway" + + # Check 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, f"gateway_{mock_config_entry.entry_id}") in device_entry.identifiers + assert device_entry.via_device_id is None + + +@pytest.mark.usefixtures("setup_integration") +async def test_button_press_success( + hass: HomeAssistant, + mock_pyvlx: MagicMock, +) -> None: + """Test successful button press.""" + + # Configure the mock method to be async and return a coroutine + mock_pyvlx.reboot_gateway.return_value = AsyncMock()() + + # Press the button + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.klf_200_gateway_reboot_gateway"}, + blocking=True, + ) + + # Verify the reboot method was called + mock_pyvlx.reboot_gateway.assert_called_once() + + +@pytest.mark.usefixtures("setup_integration") +async def test_button_press_failure( + hass: HomeAssistant, + mock_pyvlx: MagicMock, +) -> None: + """Test button press failure handling.""" + + # Mock reboot failure + mock_pyvlx.reboot_gateway.side_effect = PyVLXException("Connection failed") + + # Press the button and expect HomeAssistantError + with pytest.raises( + HomeAssistantError, + match="Failed to reboot gateway. Try again in a few moments or power cycle the device manually", + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.klf_200_gateway_reboot_gateway"}, + blocking=True, + ) + + # Verify the reboot method was called + mock_pyvlx.reboot_gateway.assert_called_once()