Support wired clients in Huawei LTE device tracker (#48987)

This commit is contained in:
Ville Skyttä 2021-04-21 01:26:09 +03:00 committed by GitHub
parent cf16e651cf
commit c825f88888
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 137 additions and 30 deletions

View file

@ -64,6 +64,7 @@ from .const import (
KEY_DEVICE_INFORMATION, KEY_DEVICE_INFORMATION,
KEY_DEVICE_SIGNAL, KEY_DEVICE_SIGNAL,
KEY_DIALUP_MOBILE_DATASWITCH, KEY_DIALUP_MOBILE_DATASWITCH,
KEY_LAN_HOST_INFO,
KEY_MONITORING_CHECK_NOTIFICATIONS, KEY_MONITORING_CHECK_NOTIFICATIONS,
KEY_MONITORING_MONTH_STATISTICS, KEY_MONITORING_MONTH_STATISTICS,
KEY_MONITORING_STATUS, KEY_MONITORING_STATUS,
@ -130,6 +131,7 @@ CONFIG_ENTRY_PLATFORMS = (
class Router: class Router:
"""Class for router state.""" """Class for router state."""
config_entry: ConfigEntry = attr.ib()
connection: Connection = attr.ib() connection: Connection = attr.ib()
url: str = attr.ib() url: str = attr.ib()
mac: str = attr.ib() mac: str = attr.ib()
@ -261,6 +263,10 @@ class Router:
self._get_data(KEY_NET_CURRENT_PLMN, self.client.net.current_plmn) self._get_data(KEY_NET_CURRENT_PLMN, self.client.net.current_plmn)
self._get_data(KEY_NET_NET_MODE, self.client.net.net_mode) self._get_data(KEY_NET_NET_MODE, self.client.net.net_mode)
self._get_data(KEY_SMS_SMS_COUNT, self.client.sms.sms_count) self._get_data(KEY_SMS_SMS_COUNT, self.client.sms.sms_count)
self._get_data(KEY_LAN_HOST_INFO, self.client.lan.host_info)
if self.data.get(KEY_LAN_HOST_INFO):
# LAN host info includes everything in WLAN host list
self.subscriptions.pop(KEY_WLAN_HOST_LIST, None)
self._get_data(KEY_WLAN_HOST_LIST, self.client.wlan.host_list) self._get_data(KEY_WLAN_HOST_LIST, self.client.wlan.host_list)
self._get_data( self._get_data(
KEY_WLAN_WIFI_FEATURE_SWITCH, self.client.wlan.wifi_feature_switch KEY_WLAN_WIFI_FEATURE_SWITCH, self.client.wlan.wifi_feature_switch
@ -382,7 +388,7 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry)
raise ConfigEntryNotReady from ex raise ConfigEntryNotReady from ex
# Set up router and store reference to it # Set up router and store reference to it
router = Router(connection, url, mac, signal_update) router = Router(config_entry, connection, url, mac, signal_update)
hass.data[DOMAIN].routers[url] = router hass.data[DOMAIN].routers[url] = router
# Do initial data update # Do initial data update

View file

@ -33,9 +33,11 @@ from homeassistant.data_entry_flow import FlowResultDict
from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.helpers.typing import DiscoveryInfoType
from .const import ( from .const import (
CONF_TRACK_WIRED_CLIENTS,
CONNECTION_TIMEOUT, CONNECTION_TIMEOUT,
DEFAULT_DEVICE_NAME, DEFAULT_DEVICE_NAME,
DEFAULT_NOTIFY_SERVICE_NAME, DEFAULT_NOTIFY_SERVICE_NAME,
DEFAULT_TRACK_WIRED_CLIENTS,
DOMAIN, DOMAIN,
) )
@ -284,6 +286,12 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
self.config_entry.options.get(CONF_RECIPIENT, []) self.config_entry.options.get(CONF_RECIPIENT, [])
), ),
): str, ): str,
vol.Optional(
CONF_TRACK_WIRED_CLIENTS,
default=self.config_entry.options.get(
CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS
),
): bool,
} }
) )
return self.async_show_form(step_id="init", data_schema=data_schema) return self.async_show_form(step_id="init", data_schema=data_schema)

View file

@ -2,8 +2,11 @@
DOMAIN = "huawei_lte" DOMAIN = "huawei_lte"
CONF_TRACK_WIRED_CLIENTS = "track_wired_clients"
DEFAULT_DEVICE_NAME = "LTE" DEFAULT_DEVICE_NAME = "LTE"
DEFAULT_NOTIFY_SERVICE_NAME = DOMAIN DEFAULT_NOTIFY_SERVICE_NAME = DOMAIN
DEFAULT_TRACK_WIRED_CLIENTS = True
UPDATE_SIGNAL = f"{DOMAIN}_update" UPDATE_SIGNAL = f"{DOMAIN}_update"
@ -26,6 +29,7 @@ KEY_DEVICE_BASIC_INFORMATION = "device_basic_information"
KEY_DEVICE_INFORMATION = "device_information" KEY_DEVICE_INFORMATION = "device_information"
KEY_DEVICE_SIGNAL = "device_signal" KEY_DEVICE_SIGNAL = "device_signal"
KEY_DIALUP_MOBILE_DATASWITCH = "dialup_mobile_dataswitch" KEY_DIALUP_MOBILE_DATASWITCH = "dialup_mobile_dataswitch"
KEY_LAN_HOST_INFO = "lan_host_info"
KEY_MONITORING_CHECK_NOTIFICATIONS = "monitoring_check_notifications" KEY_MONITORING_CHECK_NOTIFICATIONS = "monitoring_check_notifications"
KEY_MONITORING_MONTH_STATISTICS = "monitoring_month_statistics" KEY_MONITORING_MONTH_STATISTICS = "monitoring_month_statistics"
KEY_MONITORING_STATUS = "monitoring_status" KEY_MONITORING_STATUS = "monitoring_status"
@ -42,7 +46,10 @@ BINARY_SENSOR_KEYS = {
KEY_WLAN_WIFI_FEATURE_SWITCH, KEY_WLAN_WIFI_FEATURE_SWITCH,
} }
DEVICE_TRACKER_KEYS = {KEY_WLAN_HOST_LIST} DEVICE_TRACKER_KEYS = {
KEY_LAN_HOST_INFO,
KEY_WLAN_HOST_LIST,
}
SENSOR_KEYS = { SENSOR_KEYS = {
KEY_DEVICE_INFORMATION, KEY_DEVICE_INFORMATION,

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import logging import logging
import re import re
from typing import Any, Callable, cast from typing import Any, Callable, Dict, List, cast
import attr import attr
from stringcase import snakecase from stringcase import snakecase
@ -21,13 +21,35 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.typing import HomeAssistantType
from . import HuaweiLteBaseEntity from . import HuaweiLteBaseEntity, Router
from .const import DOMAIN, KEY_WLAN_HOST_LIST, UPDATE_SIGNAL from .const import (
CONF_TRACK_WIRED_CLIENTS,
DEFAULT_TRACK_WIRED_CLIENTS,
DOMAIN,
KEY_LAN_HOST_INFO,
KEY_WLAN_HOST_LIST,
UPDATE_SIGNAL,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_DEVICE_SCAN = f"{DEVICE_TRACKER_DOMAIN}/device_scan" _DEVICE_SCAN = f"{DEVICE_TRACKER_DOMAIN}/device_scan"
_HostType = Dict[str, Any]
def _get_hosts(
router: Router, ignore_subscriptions: bool = False
) -> list[_HostType] | None:
for key in KEY_LAN_HOST_INFO, KEY_WLAN_HOST_LIST:
if not ignore_subscriptions and key not in router.subscriptions:
continue
try:
return cast(List[_HostType], router.data[key]["Hosts"]["Host"])
except KeyError:
_LOGGER.debug("%s[%s][%s] not in data", key, "Hosts", "Host")
return None
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistantType, hass: HomeAssistantType,
@ -40,28 +62,36 @@ async def async_setup_entry(
# us, i.e. if wlan host list is supported. Only set up a subscription and proceed # us, i.e. if wlan host list is supported. Only set up a subscription and proceed
# with adding and tracking entities if it is. # with adding and tracking entities if it is.
router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]]
try: if (hosts := _get_hosts(router, True)) is None:
_ = router.data[KEY_WLAN_HOST_LIST]["Hosts"]["Host"]
except KeyError:
_LOGGER.debug("%s[%s][%s] not in data", KEY_WLAN_HOST_LIST, "Hosts", "Host")
return return
# Initialize already tracked entities # Initialize already tracked entities
tracked: set[str] = set() tracked: set[str] = set()
registry = await entity_registry.async_get_registry(hass) registry = await entity_registry.async_get_registry(hass)
known_entities: list[Entity] = [] known_entities: list[Entity] = []
track_wired_clients = router.config_entry.options.get(
CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS
)
for entity in registry.entities.values(): for entity in registry.entities.values():
if ( if (
entity.domain == DEVICE_TRACKER_DOMAIN entity.domain == DEVICE_TRACKER_DOMAIN
and entity.config_entry_id == config_entry.entry_id and entity.config_entry_id == config_entry.entry_id
): ):
tracked.add(entity.unique_id) mac = entity.unique_id.partition("-")[2]
known_entities.append( # Do not add known wired clients if not tracking them (any more)
HuaweiLteScannerEntity(router, entity.unique_id.partition("-")[2]) skip = False
) if not track_wired_clients:
for host in hosts:
if host.get("MacAddress") == mac:
skip = not _is_wireless(host)
break
if not skip:
tracked.add(entity.unique_id)
known_entities.append(HuaweiLteScannerEntity(router, mac))
async_add_entities(known_entities, True) async_add_entities(known_entities, True)
# Tell parent router to poll hosts list to gather new devices # Tell parent router to poll hosts list to gather new devices
router.subscriptions[KEY_LAN_HOST_INFO].add(_DEVICE_SCAN)
router.subscriptions[KEY_WLAN_HOST_LIST].add(_DEVICE_SCAN) router.subscriptions[KEY_WLAN_HOST_LIST].add(_DEVICE_SCAN)
async def _async_maybe_add_new_entities(url: str) -> None: async def _async_maybe_add_new_entities(url: str) -> None:
@ -79,6 +109,24 @@ async def async_setup_entry(
async_add_new_entities(hass, router.url, async_add_entities, tracked) async_add_new_entities(hass, router.url, async_add_entities, tracked)
def _is_wireless(host: _HostType) -> bool:
# LAN host info entries have an "InterfaceType" property, "Ethernet" / "Wireless".
# WLAN host list ones don't, but they're expected to be all wireless.
return cast(str, host.get("InterfaceType", "Wireless")) != "Ethernet"
def _is_connected(host: _HostType | None) -> bool:
# LAN host info entries have an "Active" property, "1" or "0".
# WLAN host list ones don't, but that call appears to return active hosts only.
return False if host is None else cast(str, host.get("Active", "1")) != "0"
def _is_us(host: _HostType) -> bool:
"""Try to determine if the host entry is us, the HA instance."""
# LAN host info entries have an "isLocalDevice" property, "1" / "0"; WLAN host list ones don't.
return cast(str, host.get("isLocalDevice", "0")) == "1"
@callback @callback
def async_add_new_entities( def async_add_new_entities(
hass: HomeAssistantType, hass: HomeAssistantType,
@ -88,14 +136,23 @@ def async_add_new_entities(
) -> None: ) -> None:
"""Add new entities that are not already being tracked.""" """Add new entities that are not already being tracked."""
router = hass.data[DOMAIN].routers[router_url] router = hass.data[DOMAIN].routers[router_url]
try: hosts = _get_hosts(router)
hosts = router.data[KEY_WLAN_HOST_LIST]["Hosts"]["Host"] if not hosts:
except KeyError:
_LOGGER.debug("%s[%s][%s] not in data", KEY_WLAN_HOST_LIST, "Hosts", "Host")
return return
track_wired_clients = router.config_entry.options.get(
CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS
)
new_entities: list[Entity] = [] new_entities: list[Entity] = []
for host in (x for x in hosts if x.get("MacAddress")): for host in (
x
for x in hosts
if not _is_us(x)
and _is_connected(x)
and x.get("MacAddress")
and (track_wired_clients or _is_wireless(x))
):
entity = HuaweiLteScannerEntity(router, host["MacAddress"]) entity = HuaweiLteScannerEntity(router, host["MacAddress"])
if entity.unique_id in tracked: if entity.unique_id in tracked:
continue continue
@ -124,29 +181,41 @@ def _better_snakecase(text: str) -> str:
class HuaweiLteScannerEntity(HuaweiLteBaseEntity, ScannerEntity): class HuaweiLteScannerEntity(HuaweiLteBaseEntity, ScannerEntity):
"""Huawei LTE router scanner entity.""" """Huawei LTE router scanner entity."""
mac: str = attr.ib() _mac_address: str = attr.ib()
_ip_address: str | None = attr.ib(init=False, default=None)
_is_connected: bool = attr.ib(init=False, default=False) _is_connected: bool = attr.ib(init=False, default=False)
_hostname: str | None = attr.ib(init=False, default=None) _hostname: str | None = attr.ib(init=False, default=None)
_extra_state_attributes: dict[str, Any] = attr.ib(init=False, factory=dict) _extra_state_attributes: dict[str, Any] = attr.ib(init=False, factory=dict)
def __attrs_post_init__(self) -> None:
"""Initialize internal state."""
self._extra_state_attributes["mac_address"] = self.mac
@property @property
def _entity_name(self) -> str: def _entity_name(self) -> str:
return self._hostname or self.mac return self.hostname or self.mac_address
@property @property
def _device_unique_id(self) -> str: def _device_unique_id(self) -> str:
return self.mac return self.mac_address
@property @property
def source_type(self) -> str: def source_type(self) -> str:
"""Return SOURCE_TYPE_ROUTER.""" """Return SOURCE_TYPE_ROUTER."""
return SOURCE_TYPE_ROUTER return SOURCE_TYPE_ROUTER
@property
def ip_address(self) -> str | None:
"""Return the primary ip address of the device."""
return self._ip_address
@property
def mac_address(self) -> str:
"""Return the mac address of the device."""
return self._mac_address
@property
def hostname(self) -> str | None:
"""Return hostname of the device."""
return self._hostname
@property @property
def is_connected(self) -> bool: def is_connected(self) -> bool:
"""Get whether the entity is connected.""" """Get whether the entity is connected."""
@ -159,11 +228,27 @@ class HuaweiLteScannerEntity(HuaweiLteBaseEntity, ScannerEntity):
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update state.""" """Update state."""
hosts = self.router.data[KEY_WLAN_HOST_LIST]["Hosts"]["Host"] hosts = _get_hosts(self.router)
host = next((x for x in hosts if x.get("MacAddress") == self.mac), None) if hosts is None:
self._is_connected = host is not None self._available = False
return
self._available = True
host = next(
(x for x in hosts if x.get("MacAddress") == self._mac_address), None
)
self._is_connected = _is_connected(host)
if host is not None: if host is not None:
# IpAddress can contain multiple semicolon separated addresses.
# Pick one for model sanity; e.g. the dhcp component to which it is fed, parses and expects to see just one.
self._ip_address = (host.get("IpAddress") or "").split(";", 2)[0] or None
self._hostname = host.get("HostName") self._hostname = host.get("HostName")
self._extra_state_attributes = { self._extra_state_attributes = {
_better_snakecase(k): v for k, v in host.items() if k != "HostName" _better_snakecase(k): v
for k, v in host.items()
if k
in {
"AddressSource",
"AssociatedSsid",
"InterfaceType",
}
} }

View file

@ -33,7 +33,8 @@
"init": { "init": {
"data": { "data": {
"name": "Notification service name (change requires restart)", "name": "Notification service name (change requires restart)",
"recipient": "SMS notification recipients" "recipient": "SMS notification recipients",
"track_wired_clients": "Track wired network clients"
} }
} }
} }