From eaaab4ccfeff3e4d8ec3f47aed0e0ec39dc1a51f Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 13 Jan 2025 20:10:45 +0100 Subject: [PATCH] Velbus add subdevices for din-rail modules (#131371) --- homeassistant/components/velbus/entity.py | 24 +- tests/components/velbus/conftest.py | 38 ++- .../velbus/snapshots/test_cover.ambr | 4 +- .../velbus/snapshots/test_init.ambr | 245 ++++++++++++++++++ .../velbus/snapshots/test_light.ambr | 2 +- tests/components/velbus/test_init.py | 51 +++- 6 files changed, 348 insertions(+), 16 deletions(-) create mode 100644 tests/components/velbus/snapshots/test_init.ambr diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index 82d06cdca28..634d20dcfa6 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -14,6 +14,12 @@ from homeassistant.helpers.entity import Entity 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): """Representation of a Velbus entity.""" @@ -23,19 +29,33 @@ class VelbusEntity(Entity): def __init__(self, channel: VelbusChannel) -> None: """Initialize a Velbus entity.""" self._channel = channel + self._module_adress = str(channel.get_module_address()) self._attr_name = channel.get_name() self._attr_device_info = DeviceInfo( identifiers={ - (DOMAIN, str(channel.get_module_address())), + (DOMAIN, self._get_identifier()), }, manufacturer="Velleman", model=channel.get_module_type_name(), + model_id=str(channel.get_module_type()), name=channel.get_full_name(), 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()}" + 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: """Add listener for state changes.""" self._channel.on_status_update(self._on_update) diff --git a/tests/components/velbus/conftest.py b/tests/components/velbus/conftest.py index 20d26a895c0..b9145cc256a 100644 --- a/tests/components/velbus/conftest.py +++ b/tests/components/velbus/conftest.py @@ -113,9 +113,11 @@ def mock_button() -> AsyncMock: channel.get_module_address.return_value = 1 channel.get_channel_number.return_value = 1 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_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = False channel.is_closed.return_value = True channel.is_on.return_value = False return channel @@ -133,6 +135,8 @@ def mock_temperature() -> AsyncMock: channel.get_full_name.return_value = "Channel full name" channel.get_module_sw_version.return_value = "3.0.0" 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.get_class.return_value = "temperature" channel.get_unit.return_value = "°C" @@ -153,12 +157,14 @@ def mock_relay() -> AsyncMock: channel = AsyncMock(spec=Relay) channel.get_categories.return_value = ["switch"] 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_module_type_name.return_value = "VMB4RYNO" channel.get_full_name.return_value = "Full relay name" channel.get_module_sw_version.return_value = "1.0.1" 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 return channel @@ -169,12 +175,14 @@ def mock_select() -> AsyncMock: channel = AsyncMock(spec=SelectedProgram) channel.get_categories.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_module_type_name.return_value = "VMB4RYNO" + channel.get_module_type.return_value = 3 channel.get_full_name.return_value = "Full module name" channel.get_module_sw_version.return_value = "1.1.1" 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_selected_program.return_value = "winter" return channel @@ -186,12 +194,14 @@ def mock_buttoncounter() -> AsyncMock: channel = AsyncMock(spec=ButtonCounter) channel.get_categories.return_value = ["sensor"] 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_module_type_name.return_value = "VMB7IN" + channel.get_module_type.return_value = 4 channel.get_full_name.return_value = "Channel full name" channel.get_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = True channel.is_counter_channel.return_value = True channel.is_temperature.return_value = False channel.get_state.return_value = 100 @@ -210,9 +220,11 @@ def mock_sensornumber() -> AsyncMock: channel.get_module_address.return_value = 2 channel.get_channel_number.return_value = 3 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_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = False channel.is_counter_channel.return_value = False channel.is_temperature.return_value = False channel.get_unit.return_value = "m" @@ -229,9 +241,11 @@ def mock_lightsensor() -> AsyncMock: channel.get_module_address.return_value = 2 channel.get_channel_number.return_value = 4 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_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6" + channel.is_sub_device.return_value = False channel.is_counter_channel.return_value = False channel.is_temperature.return_value = False channel.get_unit.return_value = "illuminance" @@ -245,12 +259,14 @@ def mock_dimmer() -> AsyncMock: channel = AsyncMock(spec=Dimmer) channel.get_categories.return_value = ["light"] channel.get_name.return_value = "Dimmer" - channel.get_module_address.return_value = 3 - channel.get_channel_number.return_value = 1 + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 10 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_module_sw_version.return_value = "1.0.0" channel.get_module_serial.return_value = "a1b2c3d4e5f6g7" + channel.is_sub_device.return_value = True channel.is_on.return_value = False channel.get_dimmer_state.return_value = 33 return channel @@ -262,12 +278,14 @@ def mock_cover() -> AsyncMock: channel = AsyncMock(spec=Blind) channel.get_categories.return_value = ["cover"] channel.get_name.return_value = "CoverName" - channel.get_module_address.return_value = 201 - channel.get_channel_number.return_value = 2 + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 9 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_module_sw_version.return_value = "1.0.1" channel.get_module_serial.return_value = "1234" + channel.is_sub_device.return_value = True channel.support_position.return_value = True channel.get_position.return_value = 50 channel.is_closed.return_value = False @@ -283,12 +301,14 @@ def mock_cover_no_position() -> AsyncMock: channel = AsyncMock(spec=Blind) channel.get_categories.return_value = ["cover"] channel.get_name.return_value = "CoverNameNoPos" - channel.get_module_address.return_value = 200 - channel.get_channel_number.return_value = 1 + channel.get_module_address.return_value = 88 + channel.get_channel_number.return_value = 11 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_module_sw_version.return_value = "1.0.1" channel.get_module_serial.return_value = "12345" + channel.is_sub_device.return_value = True channel.support_position.return_value = False channel.get_position.return_value = None channel.is_closed.return_value = False diff --git a/tests/components/velbus/snapshots/test_cover.ambr b/tests/components/velbus/snapshots/test_cover.ambr index eb41839078d..1ca867ec9a4 100644 --- a/tests/components/velbus/snapshots/test_cover.ambr +++ b/tests/components/velbus/snapshots/test_cover.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '1234-2', + 'unique_id': '1234-9', 'unit_of_measurement': None, }) # --- @@ -76,7 +76,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': '12345-1', + 'unique_id': '12345-11', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/velbus/snapshots/test_init.ambr b/tests/components/velbus/snapshots/test_init.ambr new file mode 100644 index 00000000000..850231a45d2 --- /dev/null +++ b/tests/components/velbus/snapshots/test_init.ambr @@ -0,0 +1,245 @@ +# serializer version: 1 +# name: test_device_registry + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + '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': , + 'serial_number': 'a1b2c3d4e5f6', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + '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': , + 'serial_number': '1234', + 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + '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': , + 'serial_number': '12345', + 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + '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': , + 'serial_number': 'a1b2c3d4e5f6g7', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + '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': , + 'serial_number': 'a1b2c3d4e5f6', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': , + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + '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': , + 'serial_number': 'asdfghjk', + 'suggested_area': None, + 'sw_version': '3.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + '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': , + 'serial_number': 'a1b2c3d4e5f6', + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + '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': , + 'serial_number': 'qwerty123', + 'suggested_area': None, + 'sw_version': '1.0.1', + 'via_device_id': , + }), + ]) +# --- diff --git a/tests/components/velbus/snapshots/test_light.ambr b/tests/components/velbus/snapshots/test_light.ambr index a4574f1b339..ec18305984c 100644 --- a/tests/components/velbus/snapshots/test_light.ambr +++ b/tests/components/velbus/snapshots/test_light.ambr @@ -32,7 +32,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'a1b2c3d4e5f6g7-1', + 'unique_id': 'a1b2c3d4e5f6g7-10', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/velbus/test_init.py b/tests/components/velbus/test_init.py index b7c334d7814..436a3d8fa95 100644 --- a/tests/components/velbus/test_init.py +++ b/tests/components/velbus/test_init.py @@ -1,14 +1,18 @@ """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 homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.velbus import VelbusConfigEntry from homeassistant.components.velbus.const import DOMAIN 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.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from . import init_integration @@ -113,3 +117,46 @@ async def test_migrate_config_entry( await hass.config_entries.async_setup(entry.entry_id) assert dict(entry.data) == legacy_config 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