From b35afccdb7293bc3444d94a7046bc053ed069663 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Mon, 18 Dec 2023 21:11:06 +0100 Subject: [PATCH] Add PLC PHY rates as sensor to devolo Home Network (#87039) * Add plc phyrate sensors * Fix mypy * Add tests * Use suggested_display_precision * Adapt to recent development * Remove accidentally added constant * Fix tests * Fix pylint * Use PHY rate instead of phyrate * Adapt tests * Hopefully fix mypy * Hopefully fix mypy * Use LogicalNetwork * Apply mypy fixes --- .../components/devolo_home_network/const.py | 2 + .../components/devolo_home_network/entity.py | 3 +- .../components/devolo_home_network/sensor.py | 133 ++++++++++++++++-- .../devolo_home_network/strings.json | 6 + tests/components/devolo_home_network/const.py | 43 +++++- .../snapshots/test_sensor.ambr | 96 +++++++++++++ .../devolo_home_network/test_sensor.py | 78 ++++++++++ 7 files changed, 340 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py index aaee8051cb5..4caa4f5b60b 100644 --- a/homeassistant/components/devolo_home_network/const.py +++ b/homeassistant/components/devolo_home_network/const.py @@ -25,6 +25,8 @@ IDENTIFY = "identify" IMAGE_GUEST_WIFI = "image_guest_wifi" NEIGHBORING_WIFI_NETWORKS = "neighboring_wifi_networks" PAIRING = "pairing" +PLC_RX_RATE = "plc_rx_rate" +PLC_TX_RATE = "plc_tx_rate" REGULAR_FIRMWARE = "regular_firmware" RESTART = "restart" START_WPS = "start_wps" diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index a0aa0466d90..d6ddf661494 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -9,7 +9,7 @@ from devolo_plc_api.device_api import ( NeighborAPInfo, WifiGuestAccessGet, ) -from devolo_plc_api.plcnet_api import LogicalNetwork +from devolo_plc_api.plcnet_api import DataRate, LogicalNetwork from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -25,6 +25,7 @@ _DataT = TypeVar( "_DataT", bound=( LogicalNetwork + | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo] | WifiGuestAccessGet diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 7a6da1f41a5..5d2b768d547 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -3,19 +3,21 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from enum import StrEnum from typing import Any, Generic, TypeVar from devolo_plc_api.device import Device from devolo_plc_api.device_api import ConnectedStationInfo, NeighborAPInfo -from devolo_plc_api.plcnet_api import LogicalNetwork +from devolo_plc_api.plcnet_api import REMOTE, DataRate, LogicalNetwork from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -25,25 +27,38 @@ from .const import ( CONNECTED_WIFI_CLIENTS, DOMAIN, NEIGHBORING_WIFI_NETWORKS, + PLC_RX_RATE, + PLC_TX_RATE, ) from .entity import DevoloCoordinatorEntity -_DataT = TypeVar( - "_DataT", - bound=LogicalNetwork | list[ConnectedStationInfo] | list[NeighborAPInfo], +_CoordinatorDataT = TypeVar( + "_CoordinatorDataT", + bound=LogicalNetwork | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo], +) +_ValueDataT = TypeVar( + "_ValueDataT", + bound=LogicalNetwork | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo], ) +class DataRateDirection(StrEnum): + """Direction of data transfer.""" + + RX = "rx_rate" + TX = "tx_rate" + + @dataclass -class DevoloSensorRequiredKeysMixin(Generic[_DataT]): +class DevoloSensorRequiredKeysMixin(Generic[_CoordinatorDataT]): """Mixin for required keys.""" - value_func: Callable[[_DataT], int] + value_func: Callable[[_CoordinatorDataT], float] @dataclass class DevoloSensorEntityDescription( - SensorEntityDescription, DevoloSensorRequiredKeysMixin[_DataT] + SensorEntityDescription, DevoloSensorRequiredKeysMixin[_CoordinatorDataT] ): """Describes devolo sensor entity.""" @@ -71,6 +86,24 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { icon="mdi:wifi-marker", value_func=len, ), + PLC_RX_RATE: DevoloSensorEntityDescription[DataRate]( + key=PLC_RX_RATE, + entity_category=EntityCategory.DIAGNOSTIC, + name="PLC downlink PHY rate", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, + value_func=lambda data: getattr(data, DataRateDirection.RX, 0), + suggested_display_precision=0, + ), + PLC_TX_RATE: DevoloSensorEntityDescription[DataRate]( + key=PLC_TX_RATE, + entity_category=EntityCategory.DIAGNOSTIC, + name="PLC uplink PHY rate", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, + value_func=lambda data: getattr(data, DataRateDirection.TX, 0), + suggested_display_precision=0, + ), } @@ -83,7 +116,7 @@ async def async_setup_entry( entry.entry_id ]["coordinators"] - entities: list[DevoloSensorEntity[Any]] = [] + entities: list[BaseDevoloSensorEntity[Any, Any]] = [] if device.plcnet: entities.append( DevoloSensorEntity( @@ -93,6 +126,29 @@ async def async_setup_entry( device, ) ) + network = await device.plcnet.async_get_network_overview() + peers = [ + peer.mac_address for peer in network.devices if peer.topology == REMOTE + ] + for peer in peers: + entities.append( + DevoloPlcDataRateSensorEntity( + entry, + coordinators[CONNECTED_PLC_DEVICES], + SENSOR_TYPES[PLC_TX_RATE], + device, + peer, + ) + ) + entities.append( + DevoloPlcDataRateSensorEntity( + entry, + coordinators[CONNECTED_PLC_DEVICES], + SENSOR_TYPES[PLC_RX_RATE], + device, + peer, + ) + ) if device.device and "wifi1" in device.device.features: entities.append( DevoloSensorEntity( @@ -113,23 +169,70 @@ async def async_setup_entry( async_add_entities(entities) -class DevoloSensorEntity(DevoloCoordinatorEntity[_DataT], SensorEntity): +class BaseDevoloSensorEntity( + Generic[_CoordinatorDataT, _ValueDataT], + DevoloCoordinatorEntity[_CoordinatorDataT], + SensorEntity, +): """Representation of a devolo sensor.""" - entity_description: DevoloSensorEntityDescription[_DataT] - def __init__( self, entry: ConfigEntry, - coordinator: DataUpdateCoordinator[_DataT], - description: DevoloSensorEntityDescription[_DataT], + coordinator: DataUpdateCoordinator[_CoordinatorDataT], + description: DevoloSensorEntityDescription[_ValueDataT], device: Device, ) -> None: """Initialize entity.""" self.entity_description = description super().__init__(entry, coordinator, device) + +class DevoloSensorEntity(BaseDevoloSensorEntity[_CoordinatorDataT, _CoordinatorDataT]): + """Representation of a generic devolo sensor.""" + + entity_description: DevoloSensorEntityDescription[_CoordinatorDataT] + @property - def native_value(self) -> int: + def native_value(self) -> float: """State of the sensor.""" return self.entity_description.value_func(self.coordinator.data) + + +class DevoloPlcDataRateSensorEntity(BaseDevoloSensorEntity[LogicalNetwork, DataRate]): + """Representation of a devolo PLC data rate sensor.""" + + entity_description: DevoloSensorEntityDescription[DataRate] + + def __init__( + self, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator[LogicalNetwork], + description: DevoloSensorEntityDescription[DataRate], + device: Device, + peer: str, + ) -> None: + """Initialize entity.""" + super().__init__(entry, coordinator, description, device) + self._peer = peer + peer_device = next( + device + for device in self.coordinator.data.devices + if device.mac_address == peer + ) + + self._attr_unique_id = f"{self._attr_unique_id}_{peer}" + self._attr_name = f"{description.name} ({peer_device.user_device_name})" + self._attr_entity_registry_enabled_default = peer_device.attached_to_router + + @property + def native_value(self) -> float: + """State of the sensor.""" + return self.entity_description.value_func( + next( + data_rate + for data_rate in self.coordinator.data.data_rates + if data_rate.mac_address_from == self.device.mac + and data_rate.mac_address_to == self._peer + ) + ) diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 55a7920ab3e..1362417c125 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -62,6 +62,12 @@ }, "neighboring_wifi_networks": { "name": "Neighboring Wifi networks" + }, + "plc_rx_rate": { + "name": "PLC downlink PHY rate" + }, + "plc_tx_rate": { + "name": "PLC uplink PHY rate" } }, "switch": { diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index 8cf63cf07ae..9d8faab9b13 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -12,7 +12,7 @@ from devolo_plc_api.device_api import ( UpdateFirmwareCheck, WifiGuestAccessGet, ) -from devolo_plc_api.plcnet_api import LogicalNetwork +from devolo_plc_api.plcnet_api import LOCAL, REMOTE, LogicalNetwork from homeassistant.components.zeroconf import ZeroconfServiceInfo @@ -117,14 +117,34 @@ PLCNET = LogicalNetwork( { "mac_address": "AA:BB:CC:DD:EE:FF", "attached_to_router": False, - } + "topology": LOCAL, + "user_device_name": "test1", + }, + { + "mac_address": "11:22:33:44:55:66", + "attached_to_router": True, + "topology": REMOTE, + "user_device_name": "test2", + }, + { + "mac_address": "12:34:56:78:9A:BC", + "attached_to_router": False, + "topology": REMOTE, + "user_device_name": "test3", + }, ], data_rates=[ { "mac_address_from": "AA:BB:CC:DD:EE:FF", "mac_address_to": "11:22:33:44:55:66", - "rx_rate": 0.0, - "tx_rate": 0.0, + "rx_rate": 100.0, + "tx_rate": 100.0, + }, + { + "mac_address_from": "AA:BB:CC:DD:EE:FF", + "mac_address_to": "12:34:56:78:9A:BC", + "rx_rate": 150.0, + "tx_rate": 150.0, }, ], ) @@ -136,5 +156,18 @@ PLCNET_ATTACHED = LogicalNetwork( "attached_to_router": True, } ], - data_rates=[], + data_rates=[ + { + "mac_address_from": "AA:BB:CC:DD:EE:FF", + "mac_address_to": "11:22:33:44:55:66", + "rx_rate": 100.0, + "tx_rate": 100.0, + }, + { + "mac_address_from": "AA:BB:CC:DD:EE:FF", + "mac_address_to": "12:34:56:78:9A:BC", + "rx_rate": 150.0, + "tx_rate": 150.0, + }, + ], ) diff --git a/tests/components/devolo_home_network/snapshots/test_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_sensor.ambr index 88eb46d57e8..4ab4635683c 100644 --- a/tests/components/devolo_home_network/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_sensor.ambr @@ -134,3 +134,99 @@ 'unit_of_measurement': None, }) # --- +# name: test_update_plc_phyrates + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title PLC downlink PHY rate (test2)', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_plc_downlink_phy_rate_test2', + 'last_changed': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_update_plc_phyrates.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_plc_downlink_phy_rate_test2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PLC downlink PHY rate (test2)', + 'platform': 'devolo_home_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plc_rx_rate', + 'unique_id': '1234567890_plc_rx_rate_11:22:33:44:55:66', + 'unit_of_measurement': , + }) +# --- +# name: test_update_plc_phyrates.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title PLC downlink PHY rate (test2)', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_plc_downlink_phy_rate_test2', + 'last_changed': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_update_plc_phyrates.3 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_plc_downlink_phy_rate_test2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PLC downlink PHY rate (test2)', + 'platform': 'devolo_home_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plc_rx_rate', + 'unique_id': '1234567890_plc_rx_rate_11:22:33:44:55:66', + 'unit_of_measurement': , + }) +# --- diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index 230457f5617..e6f02033425 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import configure_integration +from .const import PLCNET from .mock import MockDevice from tests.common import async_fire_time_changed @@ -33,6 +34,30 @@ async def test_sensor_setup(hass: HomeAssistant) -> None: assert hass.states.get(f"{DOMAIN}.{device_name}_connected_wifi_clients") is not None assert hass.states.get(f"{DOMAIN}.{device_name}_connected_plc_devices") is None assert hass.states.get(f"{DOMAIN}.{device_name}_neighboring_wifi_networks") is None + assert ( + hass.states.get( + f"{DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" + ) + is not None + ) + assert ( + hass.states.get( + f"{DOMAIN}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" + ) + is not None + ) + assert ( + hass.states.get( + f"{DOMAIN}.{device_name}_plc_downlink_phyrate_{PLCNET.devices[2].user_device_name}" + ) + is None + ) + assert ( + hass.states.get( + f"{DOMAIN}.{device_name}_plc_uplink_phyrate_{PLCNET.devices[2].user_device_name}" + ) + is None + ) await hass.config_entries.async_unload(entry.entry_id) @@ -100,3 +125,56 @@ async def test_sensor( assert state.state == "1" await hass.config_entries.async_unload(entry.entry_id) + + +async def test_update_plc_phyrates( + hass: HomeAssistant, + mock_device: MockDevice, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test state change of plc_downlink_phyrate and plc_uplink_phyrate sensor devices.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key_downlink = f"{DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" + state_key_uplink = f"{DOMAIN}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(state_key_downlink) == snapshot + assert entity_registry.async_get(state_key_downlink) == snapshot + assert hass.states.get(state_key_downlink) == snapshot + assert entity_registry.async_get(state_key_downlink) == snapshot + + # Emulate device failure + mock_device.plcnet.async_get_network_overview = AsyncMock( + side_effect=DeviceUnavailable + ) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(state_key_downlink) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get(state_key_uplink) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # Emulate state change + mock_device.reset() + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(state_key_downlink) + assert state is not None + assert state.state == str(PLCNET.data_rates[0].rx_rate) + + state = hass.states.get(state_key_uplink) + assert state is not None + assert state.state == str(PLCNET.data_rates[0].tx_rate) + + await hass.config_entries.async_unload(entry.entry_id)