Add sensors to Omada (#127767)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
MarkGodwin 2024-10-18 08:48:06 +01:00 committed by GitHub
parent 275c86a0a9
commit 57ef175050
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 630 additions and 16 deletions

View File

@ -24,6 +24,7 @@ from .controller import OmadaSiteController
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.DEVICE_TRACKER,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]

View File

@ -99,7 +99,6 @@ class OmadaGatewayPortBinarySensor(
"""Binary status of a property on an internet gateway."""
entity_description: GatewayPortBinarySensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,

View File

@ -1,3 +1,17 @@
"""Constants for the TP-Link Omada integration."""
from enum import StrEnum
DOMAIN = "tplink_omada"
class OmadaDeviceStatus(StrEnum):
"""Possible composite status values for Omada devices."""
DISCONNECTED = "disconnected"
CONNECTED = "connected"
PENDING = "pending"
HEARTBEAT_MISSED = "heartbeat_missed"
ISOLATED = "isolated"
ADOPT_FAILED = "adopt_failed"
MANAGED_EXTERNALLY = "managed_externally"

View File

@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__)
POLL_SWITCH_PORT = 300
POLL_GATEWAY = 300
POLL_CLIENTS = 300
POLL_DEVICES = 900
POLL_DEVICES = 300
class OmadaCoordinator[_T](DataUpdateCoordinator[dict[str, _T]]):

View File

@ -14,6 +14,8 @@ from .coordinator import OmadaCoordinator
class OmadaDeviceEntity[_T: OmadaCoordinator[Any]](CoordinatorEntity[_T]):
"""Common base class for all entities associated with Omada SDN Devices."""
_attr_has_entity_name = True
def __init__(self, coordinator: _T, device: OmadaDevice) -> None:
"""Initialize the device."""
super().__init__(coordinator)

View File

@ -18,6 +18,14 @@
"off": "mdi:cloud-cancel"
}
}
},
"sensor": {
"cpu_usage": {
"default": "mdi:cpu-32-bit"
},
"mem_usage": {
"default": "mdi:memory"
}
}
}
}

View File

@ -0,0 +1,132 @@
"""Support for TPLink Omada binary sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from tplink_omada_client.definitions import DeviceStatus, DeviceStatusCategory
from tplink_omada_client.devices import OmadaListDevice
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import OmadaConfigEntry
from .const import OmadaDeviceStatus
from .coordinator import OmadaDevicesCoordinator
from .entity import OmadaDeviceEntity
# Useful low level status categories, mapped to a more descriptive status.
DEVICE_STATUS_MAP = {
DeviceStatus.PROVISIONING: OmadaDeviceStatus.PENDING,
DeviceStatus.CONFIGURING: OmadaDeviceStatus.PENDING,
DeviceStatus.UPGRADING: OmadaDeviceStatus.PENDING,
DeviceStatus.REBOOTING: OmadaDeviceStatus.PENDING,
DeviceStatus.ADOPT_FAILED: OmadaDeviceStatus.ADOPT_FAILED,
DeviceStatus.ADOPT_FAILED_WIRELESS: OmadaDeviceStatus.ADOPT_FAILED,
DeviceStatus.MANAGED_EXTERNALLY: OmadaDeviceStatus.MANAGED_EXTERNALLY,
DeviceStatus.MANAGED_EXTERNALLY_WIRELESS: OmadaDeviceStatus.MANAGED_EXTERNALLY,
}
# High level status categories, suitable for most device statuses.
DEVICE_STATUS_CATEGORY_MAP = {
DeviceStatusCategory.DISCONNECTED: OmadaDeviceStatus.DISCONNECTED,
DeviceStatusCategory.CONNECTED: OmadaDeviceStatus.CONNECTED,
DeviceStatusCategory.PENDING: OmadaDeviceStatus.PENDING,
DeviceStatusCategory.HEARTBEAT_MISSED: OmadaDeviceStatus.HEARTBEAT_MISSED,
DeviceStatusCategory.ISOLATED: OmadaDeviceStatus.ISOLATED,
}
def _map_device_status(device: OmadaListDevice) -> str | None:
"""Map the API device status to the best available descriptive device status."""
display_status = DEVICE_STATUS_MAP.get(
device.status
) or DEVICE_STATUS_CATEGORY_MAP.get(device.status_category)
return display_status.value if display_status else None
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OmadaConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors."""
controller = config_entry.runtime_data
devices_coordinator = controller.devices_coordinator
async_add_entities(
OmadaDeviceSensor(devices_coordinator, device, desc)
for device in devices_coordinator.data.values()
for desc in OMADA_DEVICE_SENSORS
if desc.exists_func(device)
)
@dataclass(frozen=True, kw_only=True)
class OmadaDeviceSensorEntityDescription(SensorEntityDescription):
"""Entity description for a status derived from an Omada device in the device list."""
exists_func: Callable[[OmadaListDevice], bool] = lambda _: True
update_func: Callable[[OmadaListDevice], StateType]
OMADA_DEVICE_SENSORS: list[OmadaDeviceSensorEntityDescription] = [
OmadaDeviceSensorEntityDescription(
key="device_status",
translation_key="device_status",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
update_func=_map_device_status,
options=[v.value for v in OmadaDeviceStatus],
),
OmadaDeviceSensorEntityDescription(
key="cpu_usage",
translation_key="cpu_usage",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
update_func=lambda device: device.cpu_usage,
),
OmadaDeviceSensorEntityDescription(
key="mem_usage",
translation_key="mem_usage",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
update_func=lambda device: device.mem_usage,
),
]
class OmadaDeviceSensor(OmadaDeviceEntity[OmadaDevicesCoordinator], SensorEntity):
"""Sensor for property of a generic Omada device."""
entity_description: OmadaDeviceSensorEntityDescription
def __init__(
self,
coordinator: OmadaDevicesCoordinator,
device: OmadaListDevice,
entity_description: OmadaDeviceSensorEntityDescription,
) -> None:
"""Initialize the device sensor."""
super().__init__(coordinator, device)
self.entity_description = entity_description
self._attr_unique_id = f"{device.mac}_{entity_description.key}"
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.update_func(
self.coordinator.data[self.device.mac]
)

View File

@ -65,6 +65,27 @@
"poe_delivery": {
"name": "Port {port_name} PoE Delivery"
}
},
"sensor": {
"device_status": {
"name": "Device status",
"state": {
"error": "Error",
"disconnected": "[%key:common::state::disconnected%]",
"connected": "[%key:common::state::connected%]",
"pending": "Pending",
"heartbeat_missed": "Heartbeat missed",
"isolated": "Isolated",
"adopt_failed": "Adopt failed",
"managed_externally": "Managed externally"
}
},
"cpu_usage": {
"name": "CPU usage"
},
"mem_usage": {
"name": "Memory usage"
}
}
}
}

View File

@ -229,7 +229,6 @@ class OmadaDevicePortSwitchEntity(
):
"""Generic toggle switch entity for a Netork Port of an Omada Device."""
_attr_has_entity_name = True
entity_description: OmadaDevicePortSwitchEntityDescription[
TCoordinator, TDevice, TPort
]

View File

@ -119,7 +119,6 @@ class OmadaDeviceUpdate(
| UpdateEntityFeature.PROGRESS
| UpdateEntityFeature.RELEASE_NOTES
)
_attr_has_entity_name = True
_attr_device_class = UpdateDeviceClass.FIRMWARE
def __init__(

View File

@ -163,21 +163,10 @@ def mock_omada_clients_only_client(
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_omada_client: MagicMock,
) -> MockConfigEntry:
"""Set up the TP-Link Omada integration for testing."""
mock_config_entry = MockConfigEntry(
title="Test Omada Controller",
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PASSWORD: "mocked-password",
CONF_USERNAME: "mocked-user",
CONF_VERIFY_SSL: False,
CONF_SITE: "Default",
},
unique_id="12345",
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)

View File

@ -0,0 +1,333 @@
# serializer version: 1
# name: test_entities[sensor.test_poe_switch_cpu_usage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_poe_switch_cpu_usage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'CPU usage',
'platform': 'tplink_omada',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'cpu_usage',
'unique_id': '54-AF-97-00-00-01_cpu_usage',
'unit_of_measurement': '%',
})
# ---
# name: test_entities[sensor.test_poe_switch_cpu_usage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test PoE Switch CPU usage',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.test_poe_switch_cpu_usage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '10',
})
# ---
# name: test_entities[sensor.test_poe_switch_device_status-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'disconnected',
'connected',
'pending',
'heartbeat_missed',
'isolated',
'adopt_failed',
'managed_externally',
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_poe_switch_device_status',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Device status',
'platform': 'tplink_omada',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'device_status',
'unique_id': '54-AF-97-00-00-01_device_status',
'unit_of_measurement': None,
})
# ---
# name: test_entities[sensor.test_poe_switch_device_status-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Test PoE Switch Device status',
'options': list([
'disconnected',
'connected',
'pending',
'heartbeat_missed',
'isolated',
'adopt_failed',
'managed_externally',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.test_poe_switch_device_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'connected',
})
# ---
# name: test_entities[sensor.test_poe_switch_memory_usage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_poe_switch_memory_usage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Memory usage',
'platform': 'tplink_omada',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'mem_usage',
'unique_id': '54-AF-97-00-00-01_mem_usage',
'unit_of_measurement': '%',
})
# ---
# name: test_entities[sensor.test_poe_switch_memory_usage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test PoE Switch Memory usage',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.test_poe_switch_memory_usage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '20',
})
# ---
# name: test_entities[sensor.test_router_cpu_usage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_router_cpu_usage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'CPU usage',
'platform': 'tplink_omada',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'cpu_usage',
'unique_id': 'AA-BB-CC-DD-EE-FF_cpu_usage',
'unit_of_measurement': '%',
})
# ---
# name: test_entities[sensor.test_router_cpu_usage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Router CPU usage',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.test_router_cpu_usage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '16',
})
# ---
# name: test_entities[sensor.test_router_device_status-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'disconnected',
'connected',
'pending',
'heartbeat_missed',
'isolated',
'adopt_failed',
'managed_externally',
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_router_device_status',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Device status',
'platform': 'tplink_omada',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'device_status',
'unique_id': 'AA-BB-CC-DD-EE-FF_device_status',
'unit_of_measurement': None,
})
# ---
# name: test_entities[sensor.test_router_device_status-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Test Router Device status',
'options': list([
'disconnected',
'connected',
'pending',
'heartbeat_missed',
'isolated',
'adopt_failed',
'managed_externally',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.test_router_device_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'connected',
})
# ---
# name: test_entities[sensor.test_router_memory_usage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_router_memory_usage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Memory usage',
'platform': 'tplink_omada',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'mem_usage',
'unique_id': 'AA-BB-CC-DD-EE-FF_mem_usage',
'unit_of_measurement': '%',
})
# ---
# name: test_entities[sensor.test_router_memory_usage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Router Memory usage',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.test_router_memory_usage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '47',
})
# ---

View File

@ -0,0 +1,117 @@
"""Tests for TP-Link Omada sensor entities."""
from datetime import timedelta
import json
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from tplink_omada_client.definitions import DeviceStatus, DeviceStatusCategory
from tplink_omada_client.devices import OmadaGatewayPortStatus, OmadaListDevice
from homeassistant.components.tplink_omada.const import DOMAIN
from homeassistant.components.tplink_omada.coordinator import POLL_DEVICES
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import (
MockConfigEntry,
async_fire_time_changed,
load_fixture,
snapshot_platform,
)
POLL_INTERVAL = timedelta(seconds=POLL_DEVICES)
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_omada_client: MagicMock,
) -> MockConfigEntry:
"""Set up the TP-Link Omada integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.tplink_omada.PLATFORMS", ["sensor"]):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
async def test_entities(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
init_integration: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the creation of the TP-Link Omada sensor entities."""
await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id)
async def test_device_specific_status(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_omada_site_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a connection status is reported from known detailed status."""
entity_id = "sensor.test_poe_switch_device_status"
entity = hass.states.get(entity_id)
assert entity is not None
assert entity.state == "connected"
_set_test_device_status(
mock_omada_site_client,
DeviceStatus.ADOPT_FAILED.value,
DeviceStatusCategory.CONNECTED.value,
)
freezer.tick(POLL_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
entity = hass.states.get(entity_id)
assert entity.state == "adopt_failed"
async def test_device_category_status(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_omada_site_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a connection status is reported, with fallback to status category."""
entity_id = "sensor.test_poe_switch_device_status"
entity = hass.states.get(entity_id)
assert entity is not None
assert entity.state == "connected"
_set_test_device_status(
mock_omada_site_client,
DeviceStatus.PENDING_WIRELESS,
DeviceStatusCategory.PENDING.value,
)
freezer.tick(POLL_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
entity = hass.states.get(entity_id)
assert entity.state == "pending"
def _set_test_device_status(
mock_omada_site_client: MagicMock,
status: int,
status_category: int,
) -> OmadaGatewayPortStatus:
devices_data = json.loads(load_fixture("devices.json", DOMAIN))
devices_data[1]["status"] = status
devices_data[1]["statusCategory"] = status_category
devices = [OmadaListDevice(d) for d in devices_data]
mock_omada_site_client.get_devices.reset_mock()
mock_omada_site_client.get_devices.return_value = devices