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
# 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)

View File

@ -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

View File

@ -28,7 +28,7 @@
'previous_unique_id': None,
'supported_features': <CoverEntityFeature: 15>,
'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': <CoverEntityFeature: 11>,
'translation_key': None,
'unique_id': '12345-1',
'unique_id': '12345-11',
'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,
'supported_features': <LightEntityFeature: 32>,
'translation_key': None,
'unique_id': 'a1b2c3d4e5f6g7-1',
'unique_id': 'a1b2c3d4e5f6g7-10',
'unit_of_measurement': None,
})
# ---

View File

@ -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