diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 7890d5936fb..573df44122c 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -24,6 +24,7 @@ from .controller import OmadaSiteController PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, + Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, ] diff --git a/homeassistant/components/tplink_omada/binary_sensor.py b/homeassistant/components/tplink_omada/binary_sensor.py index da0c1dd9fc9..73d5f54b8b3 100644 --- a/homeassistant/components/tplink_omada/binary_sensor.py +++ b/homeassistant/components/tplink_omada/binary_sensor.py @@ -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, diff --git a/homeassistant/components/tplink_omada/const.py b/homeassistant/components/tplink_omada/const.py index f63d82c6bb4..bc55c76c931 100644 --- a/homeassistant/components/tplink_omada/const.py +++ b/homeassistant/components/tplink_omada/const.py @@ -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" diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index e4f15e6567c..a80bedeb65e 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -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]]): diff --git a/homeassistant/components/tplink_omada/entity.py b/homeassistant/components/tplink_omada/entity.py index 213764aaa12..54021a2ef86 100644 --- a/homeassistant/components/tplink_omada/entity.py +++ b/homeassistant/components/tplink_omada/entity.py @@ -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) diff --git a/homeassistant/components/tplink_omada/icons.json b/homeassistant/components/tplink_omada/icons.json index d0c407a9326..c681b5e1f81 100644 --- a/homeassistant/components/tplink_omada/icons.json +++ b/homeassistant/components/tplink_omada/icons.json @@ -18,6 +18,14 @@ "off": "mdi:cloud-cancel" } } + }, + "sensor": { + "cpu_usage": { + "default": "mdi:cpu-32-bit" + }, + "mem_usage": { + "default": "mdi:memory" + } } } } diff --git a/homeassistant/components/tplink_omada/sensor.py b/homeassistant/components/tplink_omada/sensor.py new file mode 100644 index 00000000000..272334d1b52 --- /dev/null +++ b/homeassistant/components/tplink_omada/sensor.py @@ -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] + ) diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json index 49873b7d088..7fcede3fb12 100644 --- a/homeassistant/components/tplink_omada/strings.json +++ b/homeassistant/components/tplink_omada/strings.json @@ -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" + } } } } diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py index 26bedc5a88e..f99d8aaedde 100644 --- a/homeassistant/components/tplink_omada/switch.py +++ b/homeassistant/components/tplink_omada/switch.py @@ -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 ] diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index d1e0a08b803..54b586794be 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -119,7 +119,6 @@ class OmadaDeviceUpdate( | UpdateEntityFeature.PROGRESS | UpdateEntityFeature.RELEASE_NOTES ) - _attr_has_entity_name = True _attr_device_class = UpdateDeviceClass.FIRMWARE def __init__( diff --git a/tests/components/tplink_omada/conftest.py b/tests/components/tplink_omada/conftest.py index 510a2e7a87c..b9bdb5ef94a 100644 --- a/tests/components/tplink_omada/conftest.py +++ b/tests/components/tplink_omada/conftest.py @@ -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) diff --git a/tests/components/tplink_omada/snapshots/test_sensor.ambr b/tests/components/tplink_omada/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..6c332eb9696 --- /dev/null +++ b/tests/components/tplink_omada/snapshots/test_sensor.ambr @@ -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': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_poe_switch_cpu_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_poe_switch_cpu_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_poe_switch_device_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + 'entity_id': 'sensor.test_poe_switch_device_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'connected', + }) +# --- +# name: test_entities[sensor.test_poe_switch_memory_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_poe_switch_memory_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_poe_switch_memory_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_entities[sensor.test_router_cpu_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_router_cpu_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_router_cpu_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_router_device_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + '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': , + 'entity_id': 'sensor.test_router_device_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'connected', + }) +# --- +# name: test_entities[sensor.test_router_memory_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_router_memory_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_router_memory_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47', + }) +# --- diff --git a/tests/components/tplink_omada/test_sensor.py b/tests/components/tplink_omada/test_sensor.py new file mode 100644 index 00000000000..54df7c5bcad --- /dev/null +++ b/tests/components/tplink_omada/test_sensor.py @@ -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