From 9da236976203befe77dde16031360d39581c601b Mon Sep 17 00:00:00 2001 From: Vladimir Makeev Date: Sat, 22 Apr 2023 22:16:00 +0400 Subject: [PATCH] Added auto-detection of PoE function availability --- README.md | 10 ++- .../tplink_easy_smart/client/const.py | 11 +++ .../tplink_easy_smart/client/coreapi.py | 18 ++++- .../tplink_easy_smart/client/tplink_api.py | 60 ++++++++++---- .../tplink_easy_smart/client/utils.py | 80 +++++++++++++++++++ custom_components/tplink_easy_smart/sensor.py | 32 ++++---- custom_components/tplink_easy_smart/switch.py | 7 +- .../tplink_easy_smart/translations/en.json | 2 +- .../tplink_easy_smart/update_coordinator.py | 13 +++ docs/controls.md | 2 + docs/sensors.md | 8 +- 11 files changed, 200 insertions(+), 43 deletions(-) create mode 100644 custom_components/tplink_easy_smart/client/const.py create mode 100644 custom_components/tplink_easy_smart/client/utils.py diff --git a/README.md b/README.md index 5a45b40..a3a49e6 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,15 @@ Home Assistant custom component for control TP-Link Easy Smart switches over LAN - obtaining hardware and firmware version of the switch - obtaining information about total PoE consumption - setting limits on total PoE consumption +- automatic detection of available functions ## Supported models -| Name | Revision | Confirmed | Notes | -|---------------------------------------------------------------------------------------|-----------|-----------|-----------------------------------------| -| [TL-SG1016PE](https://www.tp-link.com/en/business-networking/poe-switch/tl-sg1016pe/) | V1, V3 | Yes | All features are available | -| Other Easy Smart switches with web-based user interface | --------- | No | Will most likely work +| Name | Revision | Confirmed | Notes | +|------------------------------------------------------------------------------------------|-----------|-----------|-----------------------------------------| +| [TL-SG1016PE](https://www.tp-link.com/en/business-networking/poe-switch/tl-sg1016pe/) | V1, V3 | Yes | All features are available | +| [TL-SG105E](https://www.tp-link.com/en/business-networking/easy-smart-switch/tl-sg105e/) | V5 | Yes | PoE is not supported by device | +| Other Easy Smart switches with web-based user interface | --------- | No | Will most likely work ## Installation diff --git a/custom_components/tplink_easy_smart/client/const.py b/custom_components/tplink_easy_smart/client/const.py new file mode 100644 index 0000000..596ded3 --- /dev/null +++ b/custom_components/tplink_easy_smart/client/const.py @@ -0,0 +1,11 @@ +from typing import Final + +URL_DEVICE_INFO: Final = "SystemInfoRpm.htm" +URL_PORTS_SETTINGS_GET: Final = "PortSettingRpm.htm" +URL_POE_SETTINGS_GET: Final = "PoeConfigRpm.htm" + +URL_PORT_SETTINGS_SET: Final = "port_setting.cgi" +URL_POE_SETTINGS_SET: Final = "poe_global_config.cgi" +URL_POE_PORT_SETTINGS_SET: Final = "poe_port_config.cgi" + +FEATURE_POE: Final = "feature_poe" diff --git a/custom_components/tplink_easy_smart/client/coreapi.py b/custom_components/tplink_easy_smart/client/coreapi.py index e07288d..f858266 100644 --- a/custom_components/tplink_easy_smart/client/coreapi.py +++ b/custom_components/tplink_easy_smart/client/coreapi.py @@ -1,23 +1,25 @@ """TP-Link web api core functions.""" import asyncio -from enum import Enum import logging import re +from enum import Enum from typing import Callable, Dict, Final, Iterable, Tuple, TypeAlias import aiohttp -from aiohttp import ClientResponse import json5 +from aiohttp import ClientResponse, ServerDisconnectedError TIMEOUT: Final = 5.0 APICALL_ERRCODE_UNAUTHORIZED: Final = -2 APICALL_ERRCODE_REQUEST: Final = -3 +APICALL_ERRCODE_DISCONNECTED: Final = -4 APICALL_ERRCAT_CREDENTIALS: Final = "user_pass_err" APICALL_ERRCAT_REQUEST: Final = "request_error" APICALL_ERRCAT_UNAUTHORIZED: Final = "unauthorized" +APICALL_ERRCAT_DISCONNECTED: Final = "disconnected" AUTH_FAILURE_GENERAL: Final = "auth_general" AUTH_FAILURE_CREDENTIALS: Final = "auth_invalid_credentials" @@ -249,6 +251,12 @@ async def _get_raw(self, path: str) -> ClientResponse: ) _LOGGER.debug("GET %s performed, status: %s", path, response.status) return response + except ServerDisconnectedError as sde: + raise ApiCallError( + f"Can not perform GET request at {path} cause of {repr(sde)}", + APICALL_ERRCODE_DISCONNECTED, + APICALL_ERRCAT_DISCONNECTED, + ) except Exception as ex: _LOGGER.error("GET %s failed: %s", path, str(ex)) raise ApiCallError( @@ -269,6 +277,12 @@ async def _post_raw(self, path: str, data: Dict) -> ClientResponse: ) _LOGGER.debug("POST to %s performed, status: %s", path, response.status) return response + except ServerDisconnectedError as sde: + raise ApiCallError( + f"Can not perform POST request at {path} cause of {repr(sde)}", + APICALL_ERRCODE_DISCONNECTED, + APICALL_ERRCAT_DISCONNECTED, + ) except Exception as ex: _LOGGER.error("POST %s failed: %s", path, str(ex)) raise ApiCallError( diff --git a/custom_components/tplink_easy_smart/client/tplink_api.py b/custom_components/tplink_easy_smart/client/tplink_api.py index 551a29e..d5d1520 100644 --- a/custom_components/tplink_easy_smart/client/tplink_api.py +++ b/custom_components/tplink_easy_smart/client/tplink_api.py @@ -1,7 +1,7 @@ """TP-Link api.""" import logging -from typing import Final, Tuple +from typing import Tuple from .classes import ( PoeClass, @@ -14,18 +14,20 @@ PortState, TpLinkSystemInfo, ) +from .const import ( + FEATURE_POE, + URL_DEVICE_INFO, + URL_POE_PORT_SETTINGS_SET, + URL_POE_SETTINGS_GET, + URL_POE_SETTINGS_SET, + URL_PORT_SETTINGS_SET, + URL_PORTS_SETTINGS_GET, +) from .coreapi import TpLinkWebApi, VariableType +from .utils import TpLinkFeaturesDetector _LOGGER = logging.getLogger(__name__) -_URL_DEVICE_INFO: Final = "SystemInfoRpm.htm" -_URL_PORTS_SETTINGS_GET: Final = "PortSettingRpm.htm" -_URL_POE_SETTINGS_GET: Final = "PoeConfigRpm.htm" - -_URL_PORT_SETTINGS_SET: Final = "port_setting.cgi" -_URL_POE_SETTINGS_SET: Final = "poe_global_config.cgi" -_URL_POE_PORT_SETTINGS_SET: Final = "poe_port_config.cgi" - _POE_PRIORITIES_SET_MAP: dict[PoePriority, int] = { PoePriority.HIGH: 1, PoePriority.MIDDLE: 2, @@ -74,8 +76,22 @@ def __init__( ) -> None: """Initialize.""" self._core_api = TpLinkWebApi(host, port, use_ssl, user, password, verify_ssl) + self._is_features_updated = False + self._features = TpLinkFeaturesDetector(self._core_api) _LOGGER.debug("New instance of TpLinkApi created") + async def _ensure_features_updated(self): + if not self._is_features_updated: + _LOGGER.debug("Updating available features") + await self._features.update() + self._is_features_updated = True + _LOGGER.debug("Available features updated") + + async def is_feature_available(self, feature: str) -> bool: + """Return true if specified feature is known and available.""" + await self._ensure_features_updated() + return self._features.is_available(feature) + async def authenticate(self) -> None: """Perform authentication.""" await self._core_api.authenticate() @@ -92,7 +108,7 @@ def device_url(self) -> str: async def get_device_info(self) -> TpLinkSystemInfo: """Return the device information.""" data = await self._core_api.get_variable( - _URL_DEVICE_INFO, "info_ds", VariableType.Dict + URL_DEVICE_INFO, "info_ds", VariableType.Dict ) def get_value(key: str) -> str | None: @@ -116,7 +132,7 @@ def get_value(key: str) -> str | None: async def get_port_states(self) -> list[PortState]: """Return the port states.""" data = await self._core_api.get_variables( - _URL_PORTS_SETTINGS_GET, + URL_PORTS_SETTINGS_GET, [ ("all_info", VariableType.Dict), ("max_port_num", VariableType.Int), @@ -154,8 +170,11 @@ async def get_port_states(self) -> list[PortState]: async def get_port_poe_states(self) -> list[PortPoeState]: """Return the port states.""" + if not await self.is_feature_available(FEATURE_POE): + return [] + data = await self._core_api.get_variables( - _URL_POE_SETTINGS_GET, + URL_POE_SETTINGS_GET, [ ("portConfig", VariableType.Dict), ("poe_port_num", VariableType.Int), @@ -202,11 +221,13 @@ async def get_port_poe_states(self) -> list[PortPoeState]: async def get_poe_state(self) -> PoeState | None: """Return the port states.""" + if not await self.is_feature_available(FEATURE_POE): + return None _LOGGER.debug("Begin fetching POE states") poe_config = await self._core_api.get_variable( - _URL_POE_SETTINGS_GET, "globalConfig", VariableType.Dict + URL_POE_SETTINGS_GET, "globalConfig", VariableType.Dict ) if not poe_config: _LOGGER.debug("No globalConfig found, returning") @@ -235,10 +256,13 @@ async def set_port_state( f"flowcontrol={1 if flow_control_config else 0}&" f"apply=Apply" ) - await self._core_api.get(_URL_PORT_SETTINGS_SET, query=query) + await self._core_api.get(URL_PORT_SETTINGS_SET, query=query) async def set_poe_limit(self, limit: float) -> None: """Change poe limit.""" + if not await self.is_feature_available(FEATURE_POE): + raise ActionError("POE feature is not supported by device") + current_state = await self.get_poe_state() if not current_state: raise ActionError("Can not get actual PoE state") @@ -258,7 +282,7 @@ async def set_poe_limit(self, limit: float) -> None: "name_powerremain": current_state.power_remain, "applay": "Apply", } - result = await self._core_api.post(_URL_POE_SETTINGS_SET, data) + result = await self._core_api.post(URL_POE_SETTINGS_SET, data) _LOGGER.debug("POE_SET_RESULT: %s", result) async def set_port_poe_settings( @@ -268,12 +292,14 @@ async def set_port_poe_settings( priority: PoePriority, power_limit: PoePowerLimit | float, ) -> None: + if not await self.is_feature_available(FEATURE_POE): + raise ActionError("POE feature is not supported by device") """Change port poe settings.""" if port_number < 1: raise ActionError("Port number should be greater than or equals to 1") poe_ports_count = await self._core_api.get_variable( - _URL_POE_SETTINGS_GET, "poe_port_num", VariableType.Int + URL_POE_SETTINGS_GET, "poe_port_num", VariableType.Int ) if not poe_ports_count: raise ActionError("Can not get PoE ports count") @@ -310,5 +336,5 @@ async def set_port_poe_settings( f"sel_{port_number}": 1, "applay": "Apply", } - result = await self._core_api.post(_URL_POE_PORT_SETTINGS_SET, data) + result = await self._core_api.post(URL_POE_PORT_SETTINGS_SET, data) _LOGGER.debug("POE_PORT_SETTINGS_SET_RESULT: %s", result) diff --git a/custom_components/tplink_easy_smart/client/utils.py b/custom_components/tplink_easy_smart/client/utils.py new file mode 100644 index 0000000..c142b9f --- /dev/null +++ b/custom_components/tplink_easy_smart/client/utils.py @@ -0,0 +1,80 @@ +import logging +from functools import wraps + +from .const import FEATURE_POE, URL_POE_SETTINGS_GET +from .coreapi import ( + ApiCallError, + TpLinkWebApi, + VariableType, + APICALL_ERRCAT_DISCONNECTED, +) + +_LOGGER = logging.getLogger(__name__) + + +# --------------------------- +# TpLinkFeaturesDetector +# --------------------------- +class TpLinkFeaturesDetector: + def __init__(self, core_api: TpLinkWebApi): + """Initialize.""" + self._core_api = core_api + self._available_features = set() + self._is_initialized = False + + @staticmethod + def disconnected_as_false(func): + @wraps(func) + async def wrapper(*args, **kwargs) -> bool: + try: + return await func(*args, **kwargs) + except ApiCallError as ace: + if ace.category == APICALL_ERRCAT_DISCONNECTED: + return False + raise + + return wrapper + + @staticmethod + def log_feature(feature_name: str): + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + try: + _LOGGER.debug("Check feature '%s' availability", feature_name) + result = await func(*args, **kwargs) + if result: + _LOGGER.debug("Feature '%s' is available", feature_name) + else: + _LOGGER.debug("Feature '%s' is not available", feature_name) + return result + except Exception: + _LOGGER.debug( + "Feature availability check failed on %s", feature_name + ) + raise + + return wrapper + + return decorator + + @log_feature(FEATURE_POE) + @disconnected_as_false + async def _is_poe_available(self) -> bool: + data = await self._core_api.get_variables( + URL_POE_SETTINGS_GET, + [ + ("portConfig", VariableType.Dict), + ("poe_port_num", VariableType.Int), + ], + ) + return data.get("portConfig") is not None and data.get("poe_port_num") > 0 + + async def update(self) -> None: + """Update the available features list.""" + if await self._is_poe_available(): + self._available_features.add(FEATURE_POE) + + def is_available(self, feature: str) -> bool: + """Return true if feature is available.""" + return feature in self._available_features diff --git a/custom_components/tplink_easy_smart/sensor.py b/custom_components/tplink_easy_smart/sensor.py index 4366b71..2f67f49 100644 --- a/custom_components/tplink_easy_smart/sensor.py +++ b/custom_components/tplink_easy_smart/sensor.py @@ -15,8 +15,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .client.const import FEATURE_POE -from .const import DATA_KEY_COORDINATOR, DOMAIN from .helpers import ( generate_entity_id, generate_entity_name, @@ -74,21 +74,25 @@ async def async_setup_entry( function_name=_FUNCTION_DISPLAYED_NAME_NETWORK_INFO, ), ), - TpLinkPoeInfoSensor( - coordinator, - TpLinkSensorEntityDescription( - key="poe_consumption", - icon="mdi:lightning-bolt", - device_class=SensorDeviceClass.POWER, - native_unit_of_measurement=POWER_WATT, - state_class=SensorStateClass.MEASUREMENT, - device_name=coordinator.get_switch_info().name, - function_uid=_FUNCTION_UID_POE_INFO, - function_name=_FUNCTION_DISPLAYED_NAME_POE_INFO, - ), - ), ] + if await coordinator.is_feature_available(FEATURE_POE): + sensors.append( + TpLinkPoeInfoSensor( + coordinator, + TpLinkSensorEntityDescription( + key="poe_consumption", + icon="mdi:lightning-bolt", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_WATT, + state_class=SensorStateClass.MEASUREMENT, + device_name=coordinator.get_switch_info().name, + function_uid=_FUNCTION_UID_POE_INFO, + function_name=_FUNCTION_DISPLAYED_NAME_POE_INFO, + ), + ) + ) + async_add_entities(sensors) diff --git a/custom_components/tplink_easy_smart/switch.py b/custom_components/tplink_easy_smart/switch.py index cb5b70e..c8ed54a 100644 --- a/custom_components/tplink_easy_smart/switch.py +++ b/custom_components/tplink_easy_smart/switch.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .client.const import FEATURE_POE from .const import ( DEFAULT_POE_STATE_SWITCHES, @@ -97,7 +98,9 @@ async def async_setup_entry( ) ) - if config_entry.options.get(OPT_POE_STATE_SWITCHES, DEFAULT_POE_STATE_SWITCHES): + if config_entry.options.get( + OPT_POE_STATE_SWITCHES, DEFAULT_POE_STATE_SWITCHES + ) and await coordinator.is_feature_available(FEATURE_POE): for port_number in range(1, coordinator.ports_poe_count + 1): sensors.append( TpLinkPortPoeStateSwitch( @@ -194,7 +197,6 @@ def turn_off(self, **kwargs: any) -> None: # TpLinkPortStateSwitch # --------------------------- class TpLinkPortStateSwitch(TpLinkSwitch): - entity_description: TpLinkPortSwitchEntityDescription def __init__( @@ -230,7 +232,6 @@ def _handle_coordinator_update(self) -> None: # TpLinkPortPoeStateSwitch # --------------------------- class TpLinkPortPoeStateSwitch(TpLinkSwitch): - entity_description: TpLinkPortSwitchEntityDescription def __init__( diff --git a/custom_components/tplink_easy_smart/translations/en.json b/custom_components/tplink_easy_smart/translations/en.json index b5b012f..fdc14ad 100644 --- a/custom_components/tplink_easy_smart/translations/en.json +++ b/custom_components/tplink_easy_smart/translations/en.json @@ -21,7 +21,7 @@ "auth_invalid_credentials": "Invalid username or password.", "auth_user_blocked": "The user is not allowed to login.", "auth_too_many_users": "The number of the user that allowed to login has been full.", - "auth_session_timeout": "The session is timeout." + "auth_session_timeout": "The session timeout has expired." } }, "options": { diff --git a/custom_components/tplink_easy_smart/update_coordinator.py b/custom_components/tplink_easy_smart/update_coordinator.py index bddbe57..7495cb2 100644 --- a/custom_components/tplink_easy_smart/update_coordinator.py +++ b/custom_components/tplink_easy_smart/update_coordinator.py @@ -18,6 +18,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .client.classes import PoePowerLimit, PoePriority, TpLinkSystemInfo +from .client.const import FEATURE_POE from .client.tplink_api import PoeState, PortPoeState, PortSpeed, PortState, TpLinkApi from .const import ATTR_MANUFACTURER, DEFAULT_SCAN_INTERVAL, DOMAIN @@ -110,6 +111,10 @@ def _safe_disconnect(self, api: TpLinkApi) -> None: except Exception as ex: _LOGGER.warning("Can not schedule disconnect: %s", str(ex)) + async def is_feature_available(self, feature: str) -> bool: + """Return true if specified feature is known and available.""" + return await self._api.is_feature_available(feature) + async def async_update(self) -> None: """Asynchronous update of all data.""" _LOGGER.debug("Update started") @@ -137,6 +142,10 @@ async def _update_port_states(self): async def _update_poe_state(self): """Update the switch PoE state.""" + + if not await self.is_feature_available(FEATURE_POE): + return + try: self._poe_state = await self._api.get_poe_state() except Exception as ex: @@ -144,6 +153,10 @@ async def _update_poe_state(self): async def _update_port_poe_states(self): """Update port PoE states.""" + + if not await self.is_feature_available(FEATURE_POE): + return + try: self._port_poe_states = await self._api.get_port_poe_states() except Exception as ex: diff --git a/docs/controls.md b/docs/controls.md index 5936a03..d2607e6 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -19,6 +19,8 @@ The component allows you to enable and disable PoE for each supported port. By default, adding these switches are disabled, but you can add them via [options](../README.md#advanced-options). +These switches will not be added to Home Assistant if the device does not support PoE. + There are several switches: * `switch._port__poe_enabled` diff --git a/docs/sensors.md b/docs/sensors.md index 9b1262e..a691ff6 100644 --- a/docs/sensors.md +++ b/docs/sensors.md @@ -24,6 +24,8 @@ The sensor exposes the following attributes: The component allows you to get the PoE information of the switch. The sensor value is the actual PoE consumption of the switch in watts. +This sensor will not be added to Home Assistant if the device does not support PoE. + ![PoE consumption sensor](images/sensor_poe_consumption.png) There is one sensor that is always present: @@ -41,7 +43,7 @@ The sensor exposes the following attributes: The component allows you to get the status of each port. -![PoE consumption sensor](images/sensor_port_state.png) +![Prt status sensor](images/sensor_port_state.png) There are several sensors that are always present: @@ -75,7 +77,9 @@ _Note: The sensor will be unavailable if the port is not enabled (see [port stat The component allows you to get the PoE status of each port. -![PoE consumption sensor](images/sensor_port_poe_state.png) +![PoE status sensor](images/sensor_port_poe_state.png) + +These sensors will not be added to Home Assistant if the device does not support PoE. There are several sensors that are always present: * `binary_sensor._port__poe_state`