Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
38 changes: 34 additions & 4 deletions homeassistant/components/velux/__init__.py
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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."""
Expand All @@ -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)
10 changes: 5 additions & 5 deletions homeassistant/components/velux/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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:
Expand Down
51 changes: 51 additions & 0 deletions homeassistant/components/velux/button.py
Original file line number Diff line number Diff line change
@@ -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}
}
Comment on lines +38 to +40
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

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

device_info should use the DeviceInfo class from homeassistant.helpers.device_registry instead of a plain dictionary for better type safety and consistency with the rest of the codebase.

Copilot uses AI. Check for mistakes.

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
8 changes: 7 additions & 1 deletion homeassistant/components/velux/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
10 changes: 5 additions & 5 deletions homeassistant/components/velux/cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand All @@ -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):
Expand Down
14 changes: 8 additions & 6 deletions homeassistant/components/velux/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity

from . import VeluxConfigEntry
from .const import DOMAIN


Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions homeassistant/components/velux/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down
4 changes: 2 additions & 2 deletions homeassistant/components/velux/scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion homeassistant/components/velux/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Expand Down
18 changes: 17 additions & 1 deletion tests/components/velux/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
8 changes: 8 additions & 0 deletions tests/components/velux/test_binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading