Velbus add subdevices for din-rail modules (#131371)

pull/135545/head
Maikel Punie 2025-01-13 20:10:45 +01:00 committed by GitHub
parent 4ddb72314d
commit eaaab4ccfe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 348 additions and 16 deletions

View File

@ -14,6 +14,12 @@ from homeassistant.helpers.entity import Entity
from .const import DOMAIN from .const import DOMAIN
# device identifiers for modules
# (DOMAIN, module_address)
# device identifiers for channels that are subdevices of a module
# (DOMAIN, f"{module_address}-{channel_number}")
class VelbusEntity(Entity): class VelbusEntity(Entity):
"""Representation of a Velbus entity.""" """Representation of a Velbus entity."""
@ -23,19 +29,33 @@ class VelbusEntity(Entity):
def __init__(self, channel: VelbusChannel) -> None: def __init__(self, channel: VelbusChannel) -> None:
"""Initialize a Velbus entity.""" """Initialize a Velbus entity."""
self._channel = channel self._channel = channel
self._module_adress = str(channel.get_module_address())
self._attr_name = channel.get_name() self._attr_name = channel.get_name()
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={ identifiers={
(DOMAIN, str(channel.get_module_address())), (DOMAIN, self._get_identifier()),
}, },
manufacturer="Velleman", manufacturer="Velleman",
model=channel.get_module_type_name(), model=channel.get_module_type_name(),
model_id=str(channel.get_module_type()),
name=channel.get_full_name(), name=channel.get_full_name(),
sw_version=channel.get_module_sw_version(), sw_version=channel.get_module_sw_version(),
serial_number=channel.get_module_serial(),
) )
serial = channel.get_module_serial() or str(channel.get_module_address()) if self._channel.is_sub_device():
self._attr_device_info["via_device"] = (
DOMAIN,
self._module_adress,
)
serial = channel.get_module_serial() or self._module_adress
self._attr_unique_id = f"{serial}-{channel.get_channel_number()}" self._attr_unique_id = f"{serial}-{channel.get_channel_number()}"
def _get_identifier(self) -> str:
"""Return the identifier of the entity."""
if not self._channel.is_sub_device():
return self._module_adress
return f"{self._module_adress}-{self._channel.get_channel_number()}"
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Add listener for state changes.""" """Add listener for state changes."""
self._channel.on_status_update(self._on_update) self._channel.on_status_update(self._on_update)

View File

@ -113,9 +113,11 @@ def mock_button() -> AsyncMock:
channel.get_module_address.return_value = 1 channel.get_module_address.return_value = 1
channel.get_channel_number.return_value = 1 channel.get_channel_number.return_value = 1
channel.get_module_type_name.return_value = "VMB4RYLD" channel.get_module_type_name.return_value = "VMB4RYLD"
channel.get_module_type.return_value = 99
channel.get_full_name.return_value = "Channel full name" channel.get_full_name.return_value = "Channel full name"
channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_sw_version.return_value = "1.0.0"
channel.get_module_serial.return_value = "a1b2c3d4e5f6" channel.get_module_serial.return_value = "a1b2c3d4e5f6"
channel.is_sub_device.return_value = False
channel.is_closed.return_value = True channel.is_closed.return_value = True
channel.is_on.return_value = False channel.is_on.return_value = False
return channel return channel
@ -133,6 +135,8 @@ def mock_temperature() -> AsyncMock:
channel.get_full_name.return_value = "Channel full name" channel.get_full_name.return_value = "Channel full name"
channel.get_module_sw_version.return_value = "3.0.0" channel.get_module_sw_version.return_value = "3.0.0"
channel.get_module_serial.return_value = "asdfghjk" channel.get_module_serial.return_value = "asdfghjk"
channel.get_module_type.return_value = 1
channel.is_sub_device.return_value = False
channel.is_counter_channel.return_value = False channel.is_counter_channel.return_value = False
channel.get_class.return_value = "temperature" channel.get_class.return_value = "temperature"
channel.get_unit.return_value = "°C" channel.get_unit.return_value = "°C"
@ -153,12 +157,14 @@ def mock_relay() -> AsyncMock:
channel = AsyncMock(spec=Relay) channel = AsyncMock(spec=Relay)
channel.get_categories.return_value = ["switch"] channel.get_categories.return_value = ["switch"]
channel.get_name.return_value = "RelayName" channel.get_name.return_value = "RelayName"
channel.get_module_address.return_value = 99 channel.get_module_address.return_value = 88
channel.get_channel_number.return_value = 55 channel.get_channel_number.return_value = 55
channel.get_module_type_name.return_value = "VMB4RYNO" channel.get_module_type_name.return_value = "VMB4RYNO"
channel.get_full_name.return_value = "Full relay name" channel.get_full_name.return_value = "Full relay name"
channel.get_module_sw_version.return_value = "1.0.1" channel.get_module_sw_version.return_value = "1.0.1"
channel.get_module_serial.return_value = "qwerty123" channel.get_module_serial.return_value = "qwerty123"
channel.get_module_type.return_value = 2
channel.is_sub_device.return_value = True
channel.is_on.return_value = True channel.is_on.return_value = True
return channel return channel
@ -169,12 +175,14 @@ def mock_select() -> AsyncMock:
channel = AsyncMock(spec=SelectedProgram) channel = AsyncMock(spec=SelectedProgram)
channel.get_categories.return_value = ["select"] channel.get_categories.return_value = ["select"]
channel.get_name.return_value = "select" channel.get_name.return_value = "select"
channel.get_module_address.return_value = 55 channel.get_module_address.return_value = 88
channel.get_channel_number.return_value = 33 channel.get_channel_number.return_value = 33
channel.get_module_type_name.return_value = "VMB4RYNO" channel.get_module_type_name.return_value = "VMB4RYNO"
channel.get_module_type.return_value = 3
channel.get_full_name.return_value = "Full module name" channel.get_full_name.return_value = "Full module name"
channel.get_module_sw_version.return_value = "1.1.1" channel.get_module_sw_version.return_value = "1.1.1"
channel.get_module_serial.return_value = "qwerty1234567" channel.get_module_serial.return_value = "qwerty1234567"
channel.is_sub_device.return_value = False
channel.get_options.return_value = ["none", "summer", "winter", "holiday"] channel.get_options.return_value = ["none", "summer", "winter", "holiday"]
channel.get_selected_program.return_value = "winter" channel.get_selected_program.return_value = "winter"
return channel return channel
@ -186,12 +194,14 @@ def mock_buttoncounter() -> AsyncMock:
channel = AsyncMock(spec=ButtonCounter) channel = AsyncMock(spec=ButtonCounter)
channel.get_categories.return_value = ["sensor"] channel.get_categories.return_value = ["sensor"]
channel.get_name.return_value = "ButtonCounter" channel.get_name.return_value = "ButtonCounter"
channel.get_module_address.return_value = 2 channel.get_module_address.return_value = 88
channel.get_channel_number.return_value = 2 channel.get_channel_number.return_value = 2
channel.get_module_type_name.return_value = "VMB7IN" channel.get_module_type_name.return_value = "VMB7IN"
channel.get_module_type.return_value = 4
channel.get_full_name.return_value = "Channel full name" channel.get_full_name.return_value = "Channel full name"
channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_sw_version.return_value = "1.0.0"
channel.get_module_serial.return_value = "a1b2c3d4e5f6" channel.get_module_serial.return_value = "a1b2c3d4e5f6"
channel.is_sub_device.return_value = True
channel.is_counter_channel.return_value = True channel.is_counter_channel.return_value = True
channel.is_temperature.return_value = False channel.is_temperature.return_value = False
channel.get_state.return_value = 100 channel.get_state.return_value = 100
@ -210,9 +220,11 @@ def mock_sensornumber() -> AsyncMock:
channel.get_module_address.return_value = 2 channel.get_module_address.return_value = 2
channel.get_channel_number.return_value = 3 channel.get_channel_number.return_value = 3
channel.get_module_type_name.return_value = "VMB7IN" channel.get_module_type_name.return_value = "VMB7IN"
channel.get_module_type.return_value = 8
channel.get_full_name.return_value = "Channel full name" channel.get_full_name.return_value = "Channel full name"
channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_sw_version.return_value = "1.0.0"
channel.get_module_serial.return_value = "a1b2c3d4e5f6" channel.get_module_serial.return_value = "a1b2c3d4e5f6"
channel.is_sub_device.return_value = False
channel.is_counter_channel.return_value = False channel.is_counter_channel.return_value = False
channel.is_temperature.return_value = False channel.is_temperature.return_value = False
channel.get_unit.return_value = "m" channel.get_unit.return_value = "m"
@ -229,9 +241,11 @@ def mock_lightsensor() -> AsyncMock:
channel.get_module_address.return_value = 2 channel.get_module_address.return_value = 2
channel.get_channel_number.return_value = 4 channel.get_channel_number.return_value = 4
channel.get_module_type_name.return_value = "VMB7IN" channel.get_module_type_name.return_value = "VMB7IN"
channel.get_module_type.return_value = 8
channel.get_full_name.return_value = "Channel full name" channel.get_full_name.return_value = "Channel full name"
channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_sw_version.return_value = "1.0.0"
channel.get_module_serial.return_value = "a1b2c3d4e5f6" channel.get_module_serial.return_value = "a1b2c3d4e5f6"
channel.is_sub_device.return_value = False
channel.is_counter_channel.return_value = False channel.is_counter_channel.return_value = False
channel.is_temperature.return_value = False channel.is_temperature.return_value = False
channel.get_unit.return_value = "illuminance" channel.get_unit.return_value = "illuminance"
@ -245,12 +259,14 @@ def mock_dimmer() -> AsyncMock:
channel = AsyncMock(spec=Dimmer) channel = AsyncMock(spec=Dimmer)
channel.get_categories.return_value = ["light"] channel.get_categories.return_value = ["light"]
channel.get_name.return_value = "Dimmer" channel.get_name.return_value = "Dimmer"
channel.get_module_address.return_value = 3 channel.get_module_address.return_value = 88
channel.get_channel_number.return_value = 1 channel.get_channel_number.return_value = 10
channel.get_module_type_name.return_value = "VMBDN1" channel.get_module_type_name.return_value = "VMBDN1"
channel.get_module_type.return_value = 9
channel.get_full_name.return_value = "Dimmer full name" channel.get_full_name.return_value = "Dimmer full name"
channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_sw_version.return_value = "1.0.0"
channel.get_module_serial.return_value = "a1b2c3d4e5f6g7" channel.get_module_serial.return_value = "a1b2c3d4e5f6g7"
channel.is_sub_device.return_value = True
channel.is_on.return_value = False channel.is_on.return_value = False
channel.get_dimmer_state.return_value = 33 channel.get_dimmer_state.return_value = 33
return channel return channel
@ -262,12 +278,14 @@ def mock_cover() -> AsyncMock:
channel = AsyncMock(spec=Blind) channel = AsyncMock(spec=Blind)
channel.get_categories.return_value = ["cover"] channel.get_categories.return_value = ["cover"]
channel.get_name.return_value = "CoverName" channel.get_name.return_value = "CoverName"
channel.get_module_address.return_value = 201 channel.get_module_address.return_value = 88
channel.get_channel_number.return_value = 2 channel.get_channel_number.return_value = 9
channel.get_module_type_name.return_value = "VMB2BLE" channel.get_module_type_name.return_value = "VMB2BLE"
channel.get_module_type.return_value = 10
channel.get_full_name.return_value = "Full cover name" channel.get_full_name.return_value = "Full cover name"
channel.get_module_sw_version.return_value = "1.0.1" channel.get_module_sw_version.return_value = "1.0.1"
channel.get_module_serial.return_value = "1234" channel.get_module_serial.return_value = "1234"
channel.is_sub_device.return_value = True
channel.support_position.return_value = True channel.support_position.return_value = True
channel.get_position.return_value = 50 channel.get_position.return_value = 50
channel.is_closed.return_value = False channel.is_closed.return_value = False
@ -283,12 +301,14 @@ def mock_cover_no_position() -> AsyncMock:
channel = AsyncMock(spec=Blind) channel = AsyncMock(spec=Blind)
channel.get_categories.return_value = ["cover"] channel.get_categories.return_value = ["cover"]
channel.get_name.return_value = "CoverNameNoPos" channel.get_name.return_value = "CoverNameNoPos"
channel.get_module_address.return_value = 200 channel.get_module_address.return_value = 88
channel.get_channel_number.return_value = 1 channel.get_channel_number.return_value = 11
channel.get_module_type_name.return_value = "VMB2BLE" channel.get_module_type_name.return_value = "VMB2BLE"
channel.get_module_type.return_value = 10
channel.get_full_name.return_value = "Full cover name no position" channel.get_full_name.return_value = "Full cover name no position"
channel.get_module_sw_version.return_value = "1.0.1" channel.get_module_sw_version.return_value = "1.0.1"
channel.get_module_serial.return_value = "12345" channel.get_module_serial.return_value = "12345"
channel.is_sub_device.return_value = True
channel.support_position.return_value = False channel.support_position.return_value = False
channel.get_position.return_value = None channel.get_position.return_value = None
channel.is_closed.return_value = False channel.is_closed.return_value = False

View File

@ -28,7 +28,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': <CoverEntityFeature: 15>, 'supported_features': <CoverEntityFeature: 15>,
'translation_key': None, 'translation_key': None,
'unique_id': '1234-2', 'unique_id': '1234-9',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---
@ -76,7 +76,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': <CoverEntityFeature: 11>, 'supported_features': <CoverEntityFeature: 11>,
'translation_key': None, 'translation_key': None,
'unique_id': '12345-1', 'unique_id': '12345-11',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---

View File

@ -0,0 +1,245 @@
# serializer version: 1
# name: test_device_registry
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'velbus',
'1',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Velleman',
'model': 'VMB4RYLD',
'model_id': '99',
'name': 'Channel full name',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'a1b2c3d4e5f6',
'suggested_area': None,
'sw_version': '1.0.0',
'via_device_id': None,
}),
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'velbus',
'88-9',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Velleman',
'model': 'VMB2BLE',
'model_id': '10',
'name': 'Full cover name',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '1234',
'suggested_area': None,
'sw_version': '1.0.1',
'via_device_id': <ANY>,
}),
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'velbus',
'88-11',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Velleman',
'model': 'VMB2BLE',
'model_id': '10',
'name': 'Full cover name no position',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '12345',
'suggested_area': None,
'sw_version': '1.0.1',
'via_device_id': <ANY>,
}),
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'velbus',
'88-10',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Velleman',
'model': 'VMBDN1',
'model_id': '9',
'name': 'Dimmer full name',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'a1b2c3d4e5f6g7',
'suggested_area': None,
'sw_version': '1.0.0',
'via_device_id': <ANY>,
}),
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'velbus',
'88-2',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Velleman',
'model': 'VMB7IN',
'model_id': '4',
'name': 'Channel full name',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'a1b2c3d4e5f6',
'suggested_area': None,
'sw_version': '1.0.0',
'via_device_id': <ANY>,
}),
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'velbus',
'88',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Velleman',
'model': 'VMB4GPO',
'model_id': '1',
'name': 'Channel full name',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'asdfghjk',
'suggested_area': None,
'sw_version': '3.0.0',
'via_device_id': None,
}),
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'velbus',
'2',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Velleman',
'model': 'VMB7IN',
'model_id': '8',
'name': 'Channel full name',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'a1b2c3d4e5f6',
'suggested_area': None,
'sw_version': '1.0.0',
'via_device_id': None,
}),
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'velbus',
'88-55',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Velleman',
'model': 'VMB4RYNO',
'model_id': '2',
'name': 'Full relay name',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'qwerty123',
'suggested_area': None,
'sw_version': '1.0.1',
'via_device_id': <ANY>,
}),
])
# ---

View File

@ -32,7 +32,7 @@
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': <LightEntityFeature: 32>, 'supported_features': <LightEntityFeature: 32>,
'translation_key': None, 'translation_key': None,
'unique_id': 'a1b2c3d4e5f6g7-1', 'unique_id': 'a1b2c3d4e5f6g7-10',
'unit_of_measurement': None, 'unit_of_measurement': None,
}) })
# --- # ---

View File

@ -1,14 +1,18 @@
"""Tests for the Velbus component initialisation.""" """Tests for the Velbus component initialisation."""
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from velbusaio.exceptions import VelbusConnectionFailed from velbusaio.exceptions import VelbusConnectionFailed
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.velbus import VelbusConfigEntry from homeassistant.components.velbus import VelbusConfigEntry
from homeassistant.components.velbus.const import DOMAIN from homeassistant.components.velbus.const import DOMAIN
from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_PORT, SERVICE_TURN_ON
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import init_integration from . import init_integration
@ -113,3 +117,46 @@ async def test_migrate_config_entry(
await hass.config_entries.async_setup(entry.entry_id) await hass.config_entries.async_setup(entry.entry_id)
assert dict(entry.data) == legacy_config assert dict(entry.data) == legacy_config
assert entry.version == 2 assert entry.version == 2
async def test_api_call(
hass: HomeAssistant,
mock_relay: AsyncMock,
config_entry: MockConfigEntry,
) -> None:
"""Test the api call decorator action."""
await init_integration(hass, config_entry)
mock_relay.turn_on.side_effect = OSError()
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: "switch.relayname"},
blocking=True,
)
async def test_device_registry(
hass: HomeAssistant,
config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the velbus device registry."""
await init_integration(hass, config_entry)
# Ensure devices are correctly registered
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)
assert device_entries == snapshot
device_parent = device_registry.async_get_device(identifiers={(DOMAIN, "88")})
assert device_parent.via_device_id is None
device = device_registry.async_get_device(identifiers={(DOMAIN, "88-9")})
assert device.via_device_id == device_parent.id
device_no_sub = device_registry.async_get_device(identifiers={(DOMAIN, "2")})
assert device_no_sub.via_device_id is None