Skip to content

Commit 9da2369

Browse files
committed
Added auto-detection of PoE function availability
1 parent 62c0a9c commit 9da2369

11 files changed

Lines changed: 200 additions & 43 deletions

File tree

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@ Home Assistant custom component for control TP-Link Easy Smart switches over LAN
2424
- obtaining hardware and firmware version of the switch
2525
- obtaining information about total PoE consumption
2626
- setting limits on total PoE consumption
27+
- automatic detection of available functions
2728

2829
## Supported models
2930

30-
| Name | Revision | Confirmed | Notes |
31-
|---------------------------------------------------------------------------------------|-----------|-----------|-----------------------------------------|
32-
| [TL-SG1016PE](https://www.tp-link.com/en/business-networking/poe-switch/tl-sg1016pe/) | V1, V3 | Yes | All features are available |
33-
| Other Easy Smart switches with web-based user interface | --------- | No | Will most likely work
31+
| Name | Revision | Confirmed | Notes |
32+
|------------------------------------------------------------------------------------------|-----------|-----------|-----------------------------------------|
33+
| [TL-SG1016PE](https://www.tp-link.com/en/business-networking/poe-switch/tl-sg1016pe/) | V1, V3 | Yes | All features are available |
34+
| [TL-SG105E](https://www.tp-link.com/en/business-networking/easy-smart-switch/tl-sg105e/) | V5 | Yes | PoE is not supported by device |
35+
| Other Easy Smart switches with web-based user interface | --------- | No | Will most likely work
3436

3537
## Installation
3638

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from typing import Final
2+
3+
URL_DEVICE_INFO: Final = "SystemInfoRpm.htm"
4+
URL_PORTS_SETTINGS_GET: Final = "PortSettingRpm.htm"
5+
URL_POE_SETTINGS_GET: Final = "PoeConfigRpm.htm"
6+
7+
URL_PORT_SETTINGS_SET: Final = "port_setting.cgi"
8+
URL_POE_SETTINGS_SET: Final = "poe_global_config.cgi"
9+
URL_POE_PORT_SETTINGS_SET: Final = "poe_port_config.cgi"
10+
11+
FEATURE_POE: Final = "feature_poe"

custom_components/tplink_easy_smart/client/coreapi.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
11
"""TP-Link web api core functions."""
22

33
import asyncio
4-
from enum import Enum
54
import logging
65
import re
6+
from enum import Enum
77
from typing import Callable, Dict, Final, Iterable, Tuple, TypeAlias
88

99
import aiohttp
10-
from aiohttp import ClientResponse
1110
import json5
11+
from aiohttp import ClientResponse, ServerDisconnectedError
1212

1313
TIMEOUT: Final = 5.0
1414

1515
APICALL_ERRCODE_UNAUTHORIZED: Final = -2
1616
APICALL_ERRCODE_REQUEST: Final = -3
17+
APICALL_ERRCODE_DISCONNECTED: Final = -4
1718

1819
APICALL_ERRCAT_CREDENTIALS: Final = "user_pass_err"
1920
APICALL_ERRCAT_REQUEST: Final = "request_error"
2021
APICALL_ERRCAT_UNAUTHORIZED: Final = "unauthorized"
22+
APICALL_ERRCAT_DISCONNECTED: Final = "disconnected"
2123

2224
AUTH_FAILURE_GENERAL: Final = "auth_general"
2325
AUTH_FAILURE_CREDENTIALS: Final = "auth_invalid_credentials"
@@ -249,6 +251,12 @@ async def _get_raw(self, path: str) -> ClientResponse:
249251
)
250252
_LOGGER.debug("GET %s performed, status: %s", path, response.status)
251253
return response
254+
except ServerDisconnectedError as sde:
255+
raise ApiCallError(
256+
f"Can not perform GET request at {path} cause of {repr(sde)}",
257+
APICALL_ERRCODE_DISCONNECTED,
258+
APICALL_ERRCAT_DISCONNECTED,
259+
)
252260
except Exception as ex:
253261
_LOGGER.error("GET %s failed: %s", path, str(ex))
254262
raise ApiCallError(
@@ -269,6 +277,12 @@ async def _post_raw(self, path: str, data: Dict) -> ClientResponse:
269277
)
270278
_LOGGER.debug("POST to %s performed, status: %s", path, response.status)
271279
return response
280+
except ServerDisconnectedError as sde:
281+
raise ApiCallError(
282+
f"Can not perform POST request at {path} cause of {repr(sde)}",
283+
APICALL_ERRCODE_DISCONNECTED,
284+
APICALL_ERRCAT_DISCONNECTED,
285+
)
272286
except Exception as ex:
273287
_LOGGER.error("POST %s failed: %s", path, str(ex))
274288
raise ApiCallError(

custom_components/tplink_easy_smart/client/tplink_api.py

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""TP-Link api."""
22

33
import logging
4-
from typing import Final, Tuple
4+
from typing import Tuple
55

66
from .classes import (
77
PoeClass,
@@ -14,18 +14,20 @@
1414
PortState,
1515
TpLinkSystemInfo,
1616
)
17+
from .const import (
18+
FEATURE_POE,
19+
URL_DEVICE_INFO,
20+
URL_POE_PORT_SETTINGS_SET,
21+
URL_POE_SETTINGS_GET,
22+
URL_POE_SETTINGS_SET,
23+
URL_PORT_SETTINGS_SET,
24+
URL_PORTS_SETTINGS_GET,
25+
)
1726
from .coreapi import TpLinkWebApi, VariableType
27+
from .utils import TpLinkFeaturesDetector
1828

1929
_LOGGER = logging.getLogger(__name__)
2030

21-
_URL_DEVICE_INFO: Final = "SystemInfoRpm.htm"
22-
_URL_PORTS_SETTINGS_GET: Final = "PortSettingRpm.htm"
23-
_URL_POE_SETTINGS_GET: Final = "PoeConfigRpm.htm"
24-
25-
_URL_PORT_SETTINGS_SET: Final = "port_setting.cgi"
26-
_URL_POE_SETTINGS_SET: Final = "poe_global_config.cgi"
27-
_URL_POE_PORT_SETTINGS_SET: Final = "poe_port_config.cgi"
28-
2931
_POE_PRIORITIES_SET_MAP: dict[PoePriority, int] = {
3032
PoePriority.HIGH: 1,
3133
PoePriority.MIDDLE: 2,
@@ -74,8 +76,22 @@ def __init__(
7476
) -> None:
7577
"""Initialize."""
7678
self._core_api = TpLinkWebApi(host, port, use_ssl, user, password, verify_ssl)
79+
self._is_features_updated = False
80+
self._features = TpLinkFeaturesDetector(self._core_api)
7781
_LOGGER.debug("New instance of TpLinkApi created")
7882

83+
async def _ensure_features_updated(self):
84+
if not self._is_features_updated:
85+
_LOGGER.debug("Updating available features")
86+
await self._features.update()
87+
self._is_features_updated = True
88+
_LOGGER.debug("Available features updated")
89+
90+
async def is_feature_available(self, feature: str) -> bool:
91+
"""Return true if specified feature is known and available."""
92+
await self._ensure_features_updated()
93+
return self._features.is_available(feature)
94+
7995
async def authenticate(self) -> None:
8096
"""Perform authentication."""
8197
await self._core_api.authenticate()
@@ -92,7 +108,7 @@ def device_url(self) -> str:
92108
async def get_device_info(self) -> TpLinkSystemInfo:
93109
"""Return the device information."""
94110
data = await self._core_api.get_variable(
95-
_URL_DEVICE_INFO, "info_ds", VariableType.Dict
111+
URL_DEVICE_INFO, "info_ds", VariableType.Dict
96112
)
97113

98114
def get_value(key: str) -> str | None:
@@ -116,7 +132,7 @@ def get_value(key: str) -> str | None:
116132
async def get_port_states(self) -> list[PortState]:
117133
"""Return the port states."""
118134
data = await self._core_api.get_variables(
119-
_URL_PORTS_SETTINGS_GET,
135+
URL_PORTS_SETTINGS_GET,
120136
[
121137
("all_info", VariableType.Dict),
122138
("max_port_num", VariableType.Int),
@@ -154,8 +170,11 @@ async def get_port_states(self) -> list[PortState]:
154170

155171
async def get_port_poe_states(self) -> list[PortPoeState]:
156172
"""Return the port states."""
173+
if not await self.is_feature_available(FEATURE_POE):
174+
return []
175+
157176
data = await self._core_api.get_variables(
158-
_URL_POE_SETTINGS_GET,
177+
URL_POE_SETTINGS_GET,
159178
[
160179
("portConfig", VariableType.Dict),
161180
("poe_port_num", VariableType.Int),
@@ -202,11 +221,13 @@ async def get_port_poe_states(self) -> list[PortPoeState]:
202221

203222
async def get_poe_state(self) -> PoeState | None:
204223
"""Return the port states."""
224+
if not await self.is_feature_available(FEATURE_POE):
225+
return None
205226

206227
_LOGGER.debug("Begin fetching POE states")
207228

208229
poe_config = await self._core_api.get_variable(
209-
_URL_POE_SETTINGS_GET, "globalConfig", VariableType.Dict
230+
URL_POE_SETTINGS_GET, "globalConfig", VariableType.Dict
210231
)
211232
if not poe_config:
212233
_LOGGER.debug("No globalConfig found, returning")
@@ -235,10 +256,13 @@ async def set_port_state(
235256
f"flowcontrol={1 if flow_control_config else 0}&"
236257
f"apply=Apply"
237258
)
238-
await self._core_api.get(_URL_PORT_SETTINGS_SET, query=query)
259+
await self._core_api.get(URL_PORT_SETTINGS_SET, query=query)
239260

240261
async def set_poe_limit(self, limit: float) -> None:
241262
"""Change poe limit."""
263+
if not await self.is_feature_available(FEATURE_POE):
264+
raise ActionError("POE feature is not supported by device")
265+
242266
current_state = await self.get_poe_state()
243267
if not current_state:
244268
raise ActionError("Can not get actual PoE state")
@@ -258,7 +282,7 @@ async def set_poe_limit(self, limit: float) -> None:
258282
"name_powerremain": current_state.power_remain,
259283
"applay": "Apply",
260284
}
261-
result = await self._core_api.post(_URL_POE_SETTINGS_SET, data)
285+
result = await self._core_api.post(URL_POE_SETTINGS_SET, data)
262286
_LOGGER.debug("POE_SET_RESULT: %s", result)
263287

264288
async def set_port_poe_settings(
@@ -268,12 +292,14 @@ async def set_port_poe_settings(
268292
priority: PoePriority,
269293
power_limit: PoePowerLimit | float,
270294
) -> None:
295+
if not await self.is_feature_available(FEATURE_POE):
296+
raise ActionError("POE feature is not supported by device")
271297
"""Change port poe settings."""
272298
if port_number < 1:
273299
raise ActionError("Port number should be greater than or equals to 1")
274300

275301
poe_ports_count = await self._core_api.get_variable(
276-
_URL_POE_SETTINGS_GET, "poe_port_num", VariableType.Int
302+
URL_POE_SETTINGS_GET, "poe_port_num", VariableType.Int
277303
)
278304
if not poe_ports_count:
279305
raise ActionError("Can not get PoE ports count")
@@ -310,5 +336,5 @@ async def set_port_poe_settings(
310336
f"sel_{port_number}": 1,
311337
"applay": "Apply",
312338
}
313-
result = await self._core_api.post(_URL_POE_PORT_SETTINGS_SET, data)
339+
result = await self._core_api.post(URL_POE_PORT_SETTINGS_SET, data)
314340
_LOGGER.debug("POE_PORT_SETTINGS_SET_RESULT: %s", result)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import logging
2+
from functools import wraps
3+
4+
from .const import FEATURE_POE, URL_POE_SETTINGS_GET
5+
from .coreapi import (
6+
ApiCallError,
7+
TpLinkWebApi,
8+
VariableType,
9+
APICALL_ERRCAT_DISCONNECTED,
10+
)
11+
12+
_LOGGER = logging.getLogger(__name__)
13+
14+
15+
# ---------------------------
16+
# TpLinkFeaturesDetector
17+
# ---------------------------
18+
class TpLinkFeaturesDetector:
19+
def __init__(self, core_api: TpLinkWebApi):
20+
"""Initialize."""
21+
self._core_api = core_api
22+
self._available_features = set()
23+
self._is_initialized = False
24+
25+
@staticmethod
26+
def disconnected_as_false(func):
27+
@wraps(func)
28+
async def wrapper(*args, **kwargs) -> bool:
29+
try:
30+
return await func(*args, **kwargs)
31+
except ApiCallError as ace:
32+
if ace.category == APICALL_ERRCAT_DISCONNECTED:
33+
return False
34+
raise
35+
36+
return wrapper
37+
38+
@staticmethod
39+
def log_feature(feature_name: str):
40+
def decorator(func):
41+
@wraps(func)
42+
async def wrapper(*args, **kwargs):
43+
try:
44+
_LOGGER.debug("Check feature '%s' availability", feature_name)
45+
result = await func(*args, **kwargs)
46+
if result:
47+
_LOGGER.debug("Feature '%s' is available", feature_name)
48+
else:
49+
_LOGGER.debug("Feature '%s' is not available", feature_name)
50+
return result
51+
except Exception:
52+
_LOGGER.debug(
53+
"Feature availability check failed on %s", feature_name
54+
)
55+
raise
56+
57+
return wrapper
58+
59+
return decorator
60+
61+
@log_feature(FEATURE_POE)
62+
@disconnected_as_false
63+
async def _is_poe_available(self) -> bool:
64+
data = await self._core_api.get_variables(
65+
URL_POE_SETTINGS_GET,
66+
[
67+
("portConfig", VariableType.Dict),
68+
("poe_port_num", VariableType.Int),
69+
],
70+
)
71+
return data.get("portConfig") is not None and data.get("poe_port_num") > 0
72+
73+
async def update(self) -> None:
74+
"""Update the available features list."""
75+
if await self._is_poe_available():
76+
self._available_features.add(FEATURE_POE)
77+
78+
def is_available(self, feature: str) -> bool:
79+
"""Return true if feature is available."""
80+
return feature in self._available_features

custom_components/tplink_easy_smart/sensor.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
from homeassistant.core import HomeAssistant, callback
1616
from homeassistant.helpers.entity_platform import AddEntitiesCallback
1717
from homeassistant.helpers.update_coordinator import CoordinatorEntity
18+
from .client.const import FEATURE_POE
1819

19-
from .const import DATA_KEY_COORDINATOR, DOMAIN
2020
from .helpers import (
2121
generate_entity_id,
2222
generate_entity_name,
@@ -74,21 +74,25 @@ async def async_setup_entry(
7474
function_name=_FUNCTION_DISPLAYED_NAME_NETWORK_INFO,
7575
),
7676
),
77-
TpLinkPoeInfoSensor(
78-
coordinator,
79-
TpLinkSensorEntityDescription(
80-
key="poe_consumption",
81-
icon="mdi:lightning-bolt",
82-
device_class=SensorDeviceClass.POWER,
83-
native_unit_of_measurement=POWER_WATT,
84-
state_class=SensorStateClass.MEASUREMENT,
85-
device_name=coordinator.get_switch_info().name,
86-
function_uid=_FUNCTION_UID_POE_INFO,
87-
function_name=_FUNCTION_DISPLAYED_NAME_POE_INFO,
88-
),
89-
),
9077
]
9178

79+
if await coordinator.is_feature_available(FEATURE_POE):
80+
sensors.append(
81+
TpLinkPoeInfoSensor(
82+
coordinator,
83+
TpLinkSensorEntityDescription(
84+
key="poe_consumption",
85+
icon="mdi:lightning-bolt",
86+
device_class=SensorDeviceClass.POWER,
87+
native_unit_of_measurement=POWER_WATT,
88+
state_class=SensorStateClass.MEASUREMENT,
89+
device_name=coordinator.get_switch_info().name,
90+
function_uid=_FUNCTION_UID_POE_INFO,
91+
function_name=_FUNCTION_DISPLAYED_NAME_POE_INFO,
92+
),
93+
)
94+
)
95+
9296
async_add_entities(sensors)
9397

9498

0 commit comments

Comments
 (0)