Add TP-Link Omada controller device and update support (#181993)

This commit is contained in:
rubempoli
2026-09-14 12:32:05 +02:00
committed by GitHub
parent 25fc54e50e
commit ae3fae9879
13 changed files with 799 additions and 24 deletions
@@ -75,12 +75,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> boo
) from ex
site_client = await client.get_site_client(OmadaSite("", entry.data[CONF_SITE]))
controller = OmadaSiteController(hass, entry, site_client)
controller = OmadaSiteController(hass, entry, client, site_client)
await controller.initialize_first_refresh()
entry.runtime_data = controller
_remove_old_devices(hass, entry, controller.devices_coordinator.data)
_remove_old_devices(
hass,
entry,
controller.devices_coordinator.data,
controller.controller_status_coordinator.data.mac,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -96,6 +101,7 @@ def _remove_old_devices(
hass: HomeAssistant,
entry: OmadaConfigEntry,
omada_devices: dict[str, OmadaListDevice],
controller_mac: str,
) -> None:
device_registry = dr.async_get(hass)
@@ -105,7 +111,7 @@ def _remove_old_devices(
mac = next(
(i[1] for i in registered_device.identifiers if i[0] == DOMAIN), None
)
if mac and mac not in omada_devices:
if mac and mac != controller_mac and mac not in omada_devices:
device_registry.async_remove_device(registered_device.id)
@@ -84,7 +84,8 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> HubInfo:
client = await create_omada_client(hass, data)
controller_id = await client.login()
name = await client.get_controller_name()
controller_status = await client.get_controller_status()
name = controller_status.name or controller_status.model
sites = await client.get_sites()
return HubInfo(controller_id, name, sites)
@@ -3,7 +3,7 @@
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING
from tplink_omada_client import OmadaSiteClient
from tplink_omada_client import OmadaClient, OmadaSiteClient
from tplink_omada_client.devices import OmadaListDevice, OmadaSwitch
from homeassistant.core import HomeAssistant, callback
@@ -13,6 +13,8 @@ if TYPE_CHECKING:
from .coordinator import (
OmadaClientsCoordinator,
OmadaControllerStatusCoordinator,
OmadaControllerUpdateCoordinator,
OmadaDevicesCoordinator,
OmadaGatewayCoordinator,
OmadaSwitchPortCoordinator,
@@ -28,13 +30,21 @@ class OmadaSiteController:
self,
hass: HomeAssistant,
config_entry: OmadaConfigEntry,
controller_client: OmadaClient,
omada_client: OmadaSiteClient,
) -> None:
"""Create the controller."""
self._hass = hass
self._config_entry = config_entry
self._controller_client = controller_client
self._omada_client = omada_client
self._controller_status_coordinator = OmadaControllerStatusCoordinator(
hass, config_entry, controller_client
)
self._controller_update_coordinator = OmadaControllerUpdateCoordinator(
hass, config_entry, controller_client
)
self._switch_port_coordinators: dict[str, OmadaSwitchPortCoordinator] = {}
self._devices_coordinator = OmadaDevicesCoordinator(
hass, config_entry, omada_client
@@ -45,6 +55,8 @@ class OmadaSiteController:
async def initialize_first_refresh(self) -> None:
"""Initialize the all coordinators, and perform first refresh."""
await self._controller_status_coordinator.async_config_entry_first_refresh()
await self._controller_update_coordinator.async_request_refresh()
await self._devices_coordinator.async_config_entry_first_refresh()
devices = self._devices_coordinator.data.values()
@@ -69,8 +81,7 @@ class OmadaSiteController:
Args:
device_filter: Function that returns True if a device should be processed.
entity_callback: Given a discovered Omada device,
creates entities for that device.
entity_callback: Given a discovered Omada device, creates entities for that device.
"""
# Track which devices have been processed already
processed_devices: set[str] = set()
@@ -101,6 +112,11 @@ class OmadaSiteController:
# Call once on initial setup
await _async_register_entities()
@property
def controller_client(self) -> OmadaClient:
"""Get the connected client API for the Omada Controller."""
return self._controller_client
@property
def omada_client(self) -> OmadaSiteClient:
"""Get the connected client API for the site to manage."""
@@ -117,6 +133,16 @@ class OmadaSiteController:
return self._switch_port_coordinators[switch.mac]
@property
def controller_status_coordinator(self) -> OmadaControllerStatusCoordinator:
"""Get the coordinator for the Omada Controller status."""
return self._controller_status_coordinator
@property
def controller_update_coordinator(self) -> OmadaControllerUpdateCoordinator:
"""Get the coordinator for Omada Controller firmware updates."""
return self._controller_update_coordinator
@property
def gateway_coordinator(self) -> OmadaGatewayCoordinator | None:
"""Gets the coordinator for site's gateway, or None if there is no gateway."""
@@ -5,7 +5,13 @@ from datetime import timedelta
import logging
from typing import TYPE_CHECKING, NamedTuple, override
from tplink_omada_client import OmadaSiteClient, OmadaSwitchPortDetails
from tplink_omada_client import (
OmadaClient,
OmadaControllerStatus,
OmadaControllerUpdateInfo,
OmadaSiteClient,
OmadaSwitchPortDetails,
)
from tplink_omada_client.clients import OmadaWirelessClient
from tplink_omada_client.devices import (
OmadaFirmwareUpdate,
@@ -29,6 +35,8 @@ POLL_SWITCH_PORT = 30
POLL_GATEWAY = 300
POLL_CLIENTS = 300
POLL_DEVICES = 300
POLL_CONTROLLER = 300
POLL_CONTROLLER_UPDATE = 3600
POLL_UPGRADE = 60
@@ -72,6 +80,76 @@ class OmadaCoordinator[_T](DataUpdateCoordinator[dict[str, _T]]):
raise NotImplementedError("Update method not implemented")
class OmadaControllerStatusCoordinator(DataUpdateCoordinator[OmadaControllerStatus]):
"""Coordinator for getting status information about the Omada Controller."""
config_entry: OmadaConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: OmadaConfigEntry,
omada_client: OmadaClient,
) -> None:
"""Initialize the controller status coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name="Omada API Data - Controller Status",
update_interval=timedelta(seconds=POLL_CONTROLLER),
)
self.omada_client = omada_client
@override
async def _async_update_data(self) -> OmadaControllerStatus:
"""Fetch controller status from the API."""
try:
async with asyncio.timeout(10):
return await self.omada_client.get_controller_status()
except OmadaClientException as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
) from err
class OmadaControllerUpdateCoordinator(
DataUpdateCoordinator[OmadaControllerUpdateInfo]
):
"""Coordinator for controller firmware update information."""
config_entry: OmadaConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: OmadaConfigEntry,
omada_client: OmadaClient,
) -> None:
"""Initialize the controller update coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name="Omada API Data - Controller Firmware Update",
update_interval=timedelta(seconds=POLL_CONTROLLER_UPDATE),
)
self.omada_client = omada_client
@override
async def _async_update_data(self) -> OmadaControllerUpdateInfo:
"""Fetch controller firmware update information from the API."""
try:
async with asyncio.timeout(10):
return await self.omada_client.check_firmware_updates()
except OmadaClientException as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="api_error",
) from err
class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]):
"""Coordinator for getting details about ports on a switch."""
@@ -1,14 +1,16 @@
"""Base entity definitions."""
from typing import Any
from typing import Any, override
from tplink_omada_client import OmadaControllerStatus
from tplink_omada_client.devices import OmadaDevice, OmadaSwitchPortDetails
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import OmadaCoordinator
from .coordinator import OmadaControllerStatusCoordinator, OmadaCoordinator
class OmadaDeviceEntity[_T: OmadaCoordinator[Any]](CoordinatorEntity[_T]):
@@ -34,3 +36,52 @@ def get_switch_port_base_name(port: OmadaSwitchPortDetails) -> str:
if port.name == f"Port{port.port}":
return str(port.port)
return f"{port.port} ({port.name})"
class OmadaControllerEntity(CoordinatorEntity[OmadaControllerStatusCoordinator]):
"""Common base class for entities associated with the Omada Controller."""
_attr_has_entity_name = True
def __init__(self, coordinator: OmadaControllerStatusCoordinator) -> None:
"""Initialize the controller entity."""
super().__init__(coordinator)
controller: OmadaControllerStatus = coordinator.data
self._controller_identifier = (DOMAIN, controller.mac)
device_name = (
f"{controller.model} - {controller.name}"
if controller.name
else controller.model
)
self._attr_device_info = dr.DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, controller.mac)},
identifiers={(DOMAIN, controller.mac)},
manufacturer="TP-Link",
model=controller.model,
name=device_name,
sw_version=controller.current_version,
)
@callback
@override
def _handle_coordinator_update(self) -> None:
"""Handle updated controller status data."""
device_registry = dr.async_get(self.hass)
controller = self.coordinator.data
device_entry = device_registry.async_get_device_by_identifier(
self._controller_identifier,
self.coordinator.config_entry.entry_id,
)
if (
device_entry is not None
and device_entry.sw_version != controller.current_version
):
device_registry.async_update_device(
device_entry.id,
sw_version=controller.current_version,
)
super()._handle_coordinator_update()
@@ -24,9 +24,14 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import OmadaConfigEntry
from .config_flow import CONF_SITE
from .const import OmadaDeviceStatus
from .coordinator import OmadaDevicesCoordinator, OmadaSwitchPortCoordinator
from .entity import OmadaDeviceEntity, get_switch_port_base_name
from .coordinator import (
OmadaControllerStatusCoordinator,
OmadaDevicesCoordinator,
OmadaSwitchPortCoordinator,
)
from .entity import OmadaControllerEntity, OmadaDeviceEntity, get_switch_port_base_name
PARALLEL_UPDATES = 0
@@ -68,6 +73,10 @@ async def async_setup_entry(
"""Set up sensors."""
controller = config_entry.runtime_data
async_add_entities(
[OmadaControllerStatusSensor(controller.controller_status_coordinator)]
)
devices_coordinator = controller.devices_coordinator
async def _create_device_sensor_entities(
@@ -153,6 +162,22 @@ OMADA_DEVICE_SENSORS: list[OmadaDeviceSensorEntityDescription] = [
]
class OmadaControllerStatusSensor(OmadaControllerEntity, SensorEntity):
"""Status sensor for the Omada Controller."""
_attr_translation_key = "device_status"
_attr_device_class = SensorDeviceClass.ENUM
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_options = [v.value for v in OmadaDeviceStatus]
_attr_native_value = OmadaDeviceStatus.CONNECTED.value
def __init__(self, coordinator: OmadaControllerStatusCoordinator) -> None:
"""Initialize the controller status sensor."""
super().__init__(coordinator)
site_id = coordinator.config_entry.data[CONF_SITE]
self._attr_unique_id = f"{coordinator.data.mac}_{site_id}_device_status"
class OmadaDeviceSensor(OmadaDeviceEntity[OmadaDevicesCoordinator], SensorEntity):
"""Sensor for property of a generic Omada device."""
@@ -99,6 +99,11 @@
"wan_connect_ipv6": {
"name": "Port {port_name} Internet connected (IPv6)"
}
},
"update": {
"firmware": {
"name": "Firmware"
}
}
},
"exceptions": {
+154 -5
View File
@@ -1,7 +1,8 @@
"""Support for TPLink Omada device firmware updates."""
from typing import Any, override
from typing import Any, cast, override
from tplink_omada_client import OmadaControllerUpdateInfo
from tplink_omada_client.devices import OmadaListDevice
from tplink_omada_client.exceptions import OmadaClientException, RequestFailed
@@ -10,14 +11,20 @@ from homeassistant.components.update import (
UpdateEntity,
UpdateEntityFeature,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OmadaConfigEntry
from .config_flow import CONF_SITE
from .const import DOMAIN
from .coordinator import OmadaFirmwareUpdateCoordinator
from .entity import OmadaDeviceEntity
from .coordinator import (
OmadaControllerStatusCoordinator,
OmadaControllerUpdateCoordinator,
OmadaFirmwareUpdateCoordinator,
)
from .entity import OmadaControllerEntity, OmadaDeviceEntity
PARALLEL_UPDATES = 0
@@ -27,7 +34,7 @@ async def async_setup_entry(
config_entry: OmadaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches."""
"""Set up firmware updates."""
controller = config_entry.runtime_data
devices = controller.devices_coordinator.data
@@ -37,11 +44,153 @@ async def async_setup_entry(
)
async_add_entities(
OmadaDeviceUpdate(coordinator, device) for device in devices.values()
[
OmadaControllerUpdate(
controller.controller_status_coordinator,
controller.controller_update_coordinator,
),
*(OmadaDeviceUpdate(coordinator, device) for device in devices.values()),
]
)
await coordinator.async_request_refresh()
class OmadaControllerUpdate(OmadaControllerEntity, UpdateEntity):
"""Firmware update status for the Omada Controller."""
_attr_translation_key = "firmware"
_attr_device_class = UpdateDeviceClass.FIRMWARE
_attr_entity_category = EntityCategory.CONFIG
def __init__(
self,
status_coordinator: OmadaControllerStatusCoordinator,
update_coordinator: OmadaControllerUpdateCoordinator,
) -> None:
"""Initialize the controller update entity."""
super().__init__(status_coordinator)
self._update_coordinator = update_coordinator
self._omada_client = update_coordinator.omada_client
site_id = status_coordinator.config_entry.data[CONF_SITE]
self._attr_unique_id = f"{status_coordinator.data.mac}_{site_id}_firmware"
self._update_attrs()
@override
async def async_added_to_hass(self) -> None:
"""Register for controller update coordinator changes."""
await super().async_added_to_hass()
self.async_on_remove(
self._update_coordinator.async_add_listener(
self._handle_update_coordinator_update
)
)
@property
@override
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._update_coordinator.last_update_success
@callback
def _handle_update_coordinator_update(self) -> None:
"""Handle updated controller firmware information."""
self._update_attrs()
self.async_write_ha_state()
@callback
@override
def _handle_coordinator_update(self) -> None:
"""Handle updated controller status data."""
self._update_attrs()
super()._handle_coordinator_update()
@property
def _update_data(self) -> OmadaControllerUpdateInfo | None:
"""Return controller update data when the optional refresh succeeded."""
return cast(OmadaControllerUpdateInfo | None, self._update_coordinator.data)
def _update_attrs(self) -> None:
"""Update installed and latest controller versions."""
update = self._update_data
if update is None:
self._attr_installed_version = self.coordinator.data.current_version
self._attr_latest_version = self._attr_installed_version
self._attr_supported_features = UpdateEntityFeature(0)
return
active_update = update.update
self._attr_installed_version = (
active_update.current_version
if update.hardware is not None and active_update is not None
else self.coordinator.data.current_version or update.current_version
)
self._attr_latest_version = (
active_update.latest_version
if active_update is not None
else self._attr_installed_version
)
self._attr_supported_features = UpdateEntityFeature.RELEASE_NOTES
if update.hardware is not None:
self._attr_supported_features |= UpdateEntityFeature.INSTALL
@override
def release_notes(self) -> str | None:
"""Return the release notes for the latest controller update."""
if (update := self._update_data) is None:
return None
return update.release_notes
@property
@override
def extra_state_attributes(self) -> dict[str, str] | None:
"""Return the controller update download URL."""
update = self._update_data
if (
update is None
or update.update is None
or update.update.download_link is None
):
return None
return {"download_url": update.update.download_link}
@override
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install a controller firmware update."""
update = self._update_data
if update is None or update.hardware is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="firmware_update_rejected",
)
target_version = version or update.latest_version
if target_version is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="firmware_update_rejected",
)
try:
await self._omada_client.install_controller_firmware(target_version)
except RequestFailed as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="firmware_update_rejected",
) from ex
except OmadaClientException as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="firmware_update_failed",
) from ex
finally:
await self._update_coordinator.async_request_refresh()
class OmadaDeviceUpdate(
OmadaDeviceEntity[OmadaFirmwareUpdateCoordinator],
UpdateEntity,
+52 -1
View File
@@ -5,7 +5,11 @@ from functools import partial
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from tplink_omada_client import OmadaSite
from tplink_omada_client import (
OmadaControllerStatus,
OmadaControllerUpdateInfo,
OmadaSite,
)
from tplink_omada_client.clients import (
OmadaConnectedClient,
OmadaNetworkClient,
@@ -192,6 +196,28 @@ def mock_omada_client(mock_omada_site_client: AsyncMock) -> Generator[MagicMock]
client.get_site_client.return_value = mock_omada_site_client
client.login.return_value = "12345"
client.get_controller_name.return_value = "OC200"
client.get_controller_status.return_value = OmadaControllerStatus(
{
"name": "Test Omada Controller",
"macAddress": "00-11-22-33-44-55",
"upTime": 123456,
"controllerVersion": "6.2.10.17",
"model": "OC200",
}
)
client.check_firmware_updates.return_value = OmadaControllerUpdateInfo(
{
"software": {
"upgrade": True,
"currentVersion": "6.2.10.17",
"latestVersion": "6.3.0.45 Build 20260903171910",
"releaseLog": "Release notes for Omada SDN Controller.",
"releaseUrl": "https://example.com/controller-release-notes",
"downloadLink": "https://example.com/controller-update.tar.gz",
}
}
)
client.install_controller_firmware = AsyncMock()
client.get_sites.return_value = [OmadaSite("Display Name", "SiteId")]
yield client
@@ -208,6 +234,31 @@ def mock_omada_clients_only_client(
client = client_mock.return_value
client.get_site_client.return_value = mock_omada_clients_only_site_client
client.login.return_value = "12345"
client.get_controller_name.return_value = "OC200"
client.get_controller_status.return_value = OmadaControllerStatus(
{
"name": "Test Omada Controller",
"macAddress": "00-11-22-33-44-55",
"upTime": 123456,
"controllerVersion": "6.2.10.17",
"model": "OC200",
}
)
client.check_firmware_updates.return_value = OmadaControllerUpdateInfo(
{
"software": {
"upgrade": True,
"currentVersion": "6.2.10.17",
"latestVersion": "6.3.0.45 Build 20260903171910",
"releaseLog": "Release notes for Omada SDN Controller.",
"releaseUrl": "https://example.com/controller-release-notes",
"downloadLink": "https://example.com/controller-update.tar.gz",
}
}
)
client.install_controller_firmware = AsyncMock()
client.get_sites.return_value = [OmadaSite("Display Name", "SiteId")]
yield client
@@ -1,4 +1,74 @@
# serializer version: 1
# name: test_entities[sensor.oc200_test_omada_controller_device_status-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'disconnected',
'connected',
'pending',
'heartbeat_missed',
'isolated',
'adopt_failed',
'managed_externally',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.oc200_test_omada_controller_device_status',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Device status',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Device status',
'platform': 'tplink_omada',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'device_status',
'unique_id': '00-11-22-33-44-55_Default_device_status',
'unit_of_measurement': None,
})
# ---
# name: test_entities[sensor.oc200_test_omada_controller_device_status-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'enum',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'OC200 - Test Omada Controller Device status',
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'disconnected',
'connected',
'pending',
'heartbeat_missed',
'isolated',
'adopt_failed',
'managed_externally',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.oc200_test_omada_controller_device_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'connected',
})
# ---
# name: test_entities[sensor.test_poe_switch_cpu_usage-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -1,4 +1,68 @@
# serializer version: 1
# name: test_entities[update.oc200_test_omada_controller_firmware-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'update',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'update.oc200_test_omada_controller_firmware',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Firmware',
'options': dict({
}),
'original_device_class': <UpdateDeviceClass.FIRMWARE: 'firmware'>,
'original_icon': None,
'original_name': 'Firmware',
'platform': 'tplink_omada',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <UpdateEntityFeature: 16>,
'translation_key': 'firmware',
'unique_id': '00-11-22-33-44-55_Default_firmware',
'unit_of_measurement': None,
})
# ---
# name: test_entities[update.oc200_test_omada_controller_firmware-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<UpdateEntityStateAttribute.AUTO_UPDATE: 'auto_update'>: False,
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'firmware',
<UpdateEntityStateAttribute.DISPLAY_PRECISION: 'display_precision'>: 0,
'download_url': 'https://example.com/controller-update.tar.gz',
<EntityStateAttribute.ENTITY_PICTURE: 'entity_picture'>: '/api/brands/integration/tplink_omada/icon.png',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'OC200 - Test Omada Controller Firmware',
<UpdateEntityStateAttribute.IN_PROGRESS: 'in_progress'>: False,
<UpdateEntityStateAttribute.INSTALLED_VERSION: 'installed_version'>: '6.2.10.17',
<UpdateEntityStateAttribute.LATEST_VERSION: 'latest_version'>: '6.3.0.45 Build 20260903171910',
<UpdateEntityStateAttribute.RELEASE_SUMMARY: 'release_summary'>: None,
<UpdateEntityStateAttribute.RELEASE_URL: 'release_url'>: None,
<UpdateEntityStateAttribute.SKIPPED_VERSION: 'skipped_version'>: None,
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <UpdateEntityFeature: 16>,
<UpdateEntityStateAttribute.TITLE: 'title'>: None,
<UpdateEntityStateAttribute.UPDATE_PERCENTAGE: 'update_percentage'>: None,
}),
'context': <ANY>,
'entity_id': 'update.oc200_test_omada_controller_firmware',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_entities[update.test_poe_switch_firmware-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -54,7 +54,7 @@ async def test_form_single_site(
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "OC200 (Display Name)"
assert result["title"] == "Test Omada Controller (Display Name)"
assert result["data"] == MOCK_ENTRY_DATA
assert result["result"].unique_id == "12345_SiteId"
assert len(mock_setup_entry.mock_calls) == 1
@@ -94,7 +94,7 @@ async def test_form_multiple_sites(
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "OC200 (Site 2)"
assert result["title"] == "Test Omada Controller (Site 2)"
assert result["data"] == {
"host": "https://fake.omada.host",
"verify_ssl": True,
@@ -148,7 +148,7 @@ async def test_form_errors_and_recovery(
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "OC200 (Display Name)"
assert result["title"] == "Test Omada Controller (Display Name)"
assert result["data"] == MOCK_ENTRY_DATA
assert len(mock_setup_entry.mock_calls) == 1
+252 -3
View File
@@ -6,20 +6,36 @@ from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from tplink_omada_client import OmadaControllerStatus, OmadaControllerUpdateInfo
from tplink_omada_client.devices import OmadaListDevice
from tplink_omada_client.exceptions import OmadaClientException, RequestFailed
from homeassistant.components.tplink_omada.const import DOMAIN
from homeassistant.components.tplink_omada.coordinator import POLL_DEVICES
from homeassistant.components.tplink_omada.coordinator import (
POLL_CONTROLLER,
POLL_DEVICES,
)
from homeassistant.components.update import (
ATTR_IN_PROGRESS,
ATTR_INSTALLED_VERSION,
ATTR_LATEST_VERSION,
DATA_COMPONENT,
DOMAIN as UPDATE_DOMAIN,
SERVICE_INSTALL,
UpdateEntityFeature,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
Platform,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.update_coordinator import REQUEST_REFRESH_DEFAULT_COOLDOWN
from tests.common import (
MockConfigEntry,
@@ -30,6 +46,8 @@ from tests.common import (
from tests.typing import WebSocketGenerator
POLL_INTERVAL = timedelta(seconds=POLL_DEVICES)
CONTROLLER_POLL_INTERVAL = timedelta(seconds=POLL_CONTROLLER)
REFRESH_COOLDOWN = timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN)
async def _rebuild_device_list_with_update(
@@ -131,6 +149,160 @@ async def test_install_firmware_success(
assert await_args[0].mac == "54-AF-97-00-00-01"
async def test_install_controller_firmware_success(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_omada_client: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test successful controller firmware installation."""
entity_id = "update.oc200_test_omada_controller_firmware"
mock_omada_client.check_firmware_updates.return_value = OmadaControllerUpdateInfo(
{
"hardware": {
"upgrade": True,
"currentVersion": "1.0.0",
"latestVersion": "1.0.1",
"fwReleaseLog": "Fixed things.",
"releaseUrl": "https://example.com/firmware-release-notes",
"downloadLink": "https://example.com/firmware.bin",
}
}
)
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.tplink_omada.PLATFORMS", [Platform.UPDATE]):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity = hass.states.get(entity_id)
assert entity is not None
assert entity.state == STATE_ON
assert entity.attributes[ATTR_INSTALLED_VERSION] == "1.0.0"
assert entity.attributes[ATTR_LATEST_VERSION] == "1.0.1"
assert entity.attributes[ATTR_SUPPORTED_FEATURES] == (
UpdateEntityFeature.RELEASE_NOTES | UpdateEntityFeature.INSTALL
)
mock_omada_client.check_firmware_updates.reset_mock()
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
freezer.tick(REFRESH_COOLDOWN)
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_omada_client.install_controller_firmware.assert_awaited_once_with("1.0.1")
mock_omada_client.check_firmware_updates.assert_awaited_once()
async def test_controller_update_check_failure_does_not_block_setup(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_omada_client: MagicMock,
) -> None:
"""Test controller update check failures do not block setup."""
entity_id = "update.oc200_test_omada_controller_firmware"
mock_omada_client.check_firmware_updates.side_effect = OmadaClientException(
"Connection error"
)
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.tplink_omada.PLATFORMS", [Platform.UPDATE]):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity = hass.states.get(entity_id)
assert entity is not None
assert entity.state == STATE_UNAVAILABLE
async def test_controller_software_update_installed_version_prefers_status_coordinator(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_omada_client: MagicMock,
) -> None:
"""Test controller software update installed version prefers controller status."""
entity_id = "update.oc200_test_omada_controller_firmware"
mock_omada_client.get_controller_status.return_value = OmadaControllerStatus(
{
"name": "Test Omada Controller",
"macAddress": "00-11-22-33-44-55",
"upTime": 123456,
"controllerVersion": "6.3.0.45",
"model": "OC200",
}
)
mock_omada_client.check_firmware_updates.return_value = OmadaControllerUpdateInfo(
{
"software": {
"upgrade": True,
"currentVersion": "6.2.10.17",
"latestVersion": "6.3.0.45",
}
}
)
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.tplink_omada.PLATFORMS", [Platform.UPDATE]):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity = hass.states.get(entity_id)
assert entity is not None
assert entity.state == STATE_OFF
assert entity.attributes[ATTR_INSTALLED_VERSION] == "6.3.0.45"
assert entity.attributes[ATTR_LATEST_VERSION] == "6.3.0.45"
async def test_controller_device_sw_version_updates_with_status_coordinator(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_omada_client: MagicMock,
device_registry: dr.DeviceRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test controller device software version updates with controller status."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.tplink_omada.PLATFORMS", [Platform.UPDATE]):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
device_entry = device_registry.async_get_device_by_identifier(
(DOMAIN, "00-11-22-33-44-55"),
mock_config_entry.entry_id,
)
assert device_entry is not None
assert device_entry.sw_version == "6.2.10.17"
mock_omada_client.get_controller_status.return_value = OmadaControllerStatus(
{
"name": "Test Omada Controller",
"macAddress": "00-11-22-33-44-55",
"upTime": 123456,
"controllerVersion": "6.3.0.45",
"model": "OC200",
}
)
freezer.tick(CONTROLLER_POLL_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
device_entry = device_registry.async_get_device_by_identifier(
(DOMAIN, "00-11-22-33-44-55"),
mock_config_entry.entry_id,
)
assert device_entry is not None
assert device_entry.sw_version == "6.3.0.45"
@pytest.mark.parametrize(
("exception_type", "translation_key"),
[
@@ -172,9 +344,86 @@ async def test_install_firmware_exceptions(
assert err.value.translation_domain == DOMAIN
@pytest.mark.parametrize(
("exception_type", "translation_key"),
[
(
RequestFailed(500, "Update rejected"),
"firmware_update_rejected",
),
(
OmadaClientException("Connection error"),
"firmware_update_failed",
),
],
)
async def test_install_controller_firmware_exceptions(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_omada_client: MagicMock,
freezer: FrozenDateTimeFactory,
exception_type: Exception,
translation_key: str,
) -> None:
"""Test controller firmware installation exception handling."""
entity_id = "update.oc200_test_omada_controller_firmware"
mock_omada_client.check_firmware_updates.return_value = OmadaControllerUpdateInfo(
{
"hardware": {
"upgrade": True,
"currentVersion": "1.0.0",
"latestVersion": "1.0.1",
}
}
)
mock_omada_client.install_controller_firmware = AsyncMock(
side_effect=exception_type
)
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.tplink_omada.PLATFORMS", [Platform.UPDATE]):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_omada_client.check_firmware_updates.reset_mock()
with pytest.raises(HomeAssistantError) as err:
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert err.value.translation_key == translation_key
assert err.value.translation_domain == DOMAIN
freezer.tick(REFRESH_COOLDOWN)
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_omada_client.check_firmware_updates.assert_awaited_once()
async def test_install_controller_firmware_rejected_without_hardware(
hass: HomeAssistant,
init_integration: MockConfigEntry,
) -> None:
"""Test controller firmware installation rejects software-only updates."""
entity = hass.data[DATA_COMPONENT].get_entity(
"update.oc200_test_omada_controller_firmware"
)
assert entity is not None
with pytest.raises(HomeAssistantError) as err:
await entity.async_install(version=None, backup=False)
assert err.value.translation_key == "firmware_update_rejected"
assert err.value.translation_domain == DOMAIN
@pytest.mark.parametrize(
("entity_name", "expected_notes"),
[
("oc200_test_omada_controller", "Release notes for Omada SDN Controller."),
("test_router", None),
("test_poe_switch", "Bug fixes and performance improvements"),
],