1 """Support for Huawei LTE routers."""
3 from __future__
import annotations
5 from collections
import defaultdict
6 from collections.abc
import Callable
7 from contextlib
import suppress
8 from dataclasses
import dataclass, field
9 from datetime
import timedelta
12 from typing
import Any, NamedTuple, cast
13 from xml.parsers.expat
import ExpatError
15 from huawei_lte_api.Client
import Client
16 from huawei_lte_api.Connection
import Connection
17 from huawei_lte_api.exceptions
import (
18 LoginErrorInvalidCredentialsException,
19 ResponseErrorException,
20 ResponseErrorLoginRequiredException,
21 ResponseErrorNotSupportedException,
23 from requests.exceptions
import Timeout
24 import voluptuous
as vol
39 EVENT_HOMEASSISTANT_STOP,
45 config_validation
as cv,
46 device_registry
as dr,
48 entity_registry
as er,
61 CONF_UNAUTHENTICATED_MODE,
65 DEFAULT_NOTIFY_SERVICE_NAME,
67 KEY_DEVICE_BASIC_INFORMATION,
68 KEY_DEVICE_INFORMATION,
70 KEY_DIALUP_MOBILE_DATASWITCH,
72 KEY_MONITORING_CHECK_NOTIFICATIONS,
73 KEY_MONITORING_MONTH_STATISTICS,
74 KEY_MONITORING_STATUS,
75 KEY_MONITORING_TRAFFIC_STATISTICS,
80 KEY_WLAN_WIFI_FEATURE_SWITCH,
81 KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH,
82 NOTIFY_SUPPRESS_TIMEOUT,
83 SERVICE_RESUME_INTEGRATION,
84 SERVICE_SUSPEND_INTEGRATION,
87 from .utils
import get_device_macs, non_verifying_requests_session
89 _LOGGER = logging.getLogger(__name__)
93 NOTIFY_SCHEMA = vol.Any(
97 vol.Optional(CONF_NAME): cv.string,
98 vol.Optional(CONF_RECIPIENT): vol.Any(
99 None, vol.All(cv.ensure_list, [cv.string])
105 CONFIG_SCHEMA = vol.Schema(
112 vol.Required(CONF_URL): cv.url,
113 vol.Optional(CONF_USERNAME): cv.string,
114 vol.Optional(CONF_PASSWORD): cv.string,
115 vol.Optional(NOTIFY_DOMAIN): NOTIFY_SCHEMA,
121 extra=vol.ALLOW_EXTRA,
124 SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_URL): cv.url})
127 Platform.BINARY_SENSOR,
129 Platform.DEVICE_TRACKER,
138 """Class for router state."""
141 config_entry: ConfigEntry
142 connection: Connection
145 data: dict[str, Any] = field(default_factory=dict, init=
False)
148 subscriptions: dict[str, list[str]] = field(
149 default_factory=
lambda: defaultdict(
150 list, ((x, [
"initial_scan"])
for x
in ALL_KEYS)
154 inflight_gets: set[str] = field(default_factory=set, init=
False)
155 client: Client = field(init=
False)
156 suspended: bool = field(default=
False, init=
False)
157 notify_last_attempt: float = field(default=-1, init=
False)
160 """Set up internal state on init."""
165 """Get router device name."""
167 (KEY_DEVICE_BASIC_INFORMATION,
"devicename"),
168 (KEY_DEVICE_INFORMATION,
"DeviceName"),
170 with suppress(KeyError, TypeError):
171 return cast(str, self.data[key][item])
172 return DEFAULT_DEVICE_NAME
176 """Get router identifiers for device registry."""
177 assert self.config_entry.unique_id
is not None
178 return {(DOMAIN, self.config_entry.unique_id)}
182 """Get router connections for device registry."""
184 (dr.CONNECTION_NETWORK_MAC, x)
for x
in self.config_entry.data[CONF_MAC]
187 def _get_data(self, key: str, func: Callable[[], Any]) ->
None:
188 if not self.subscriptions.
get(key):
190 if key
in self.inflight_gets:
191 _LOGGER.debug(
"Skipping already in-flight get for %s", key)
193 self.inflight_gets.
add(key)
194 _LOGGER.debug(
"Getting %s for subscribers %s", key, self.subscriptions[key])
196 self.data[key] = func()
197 except ResponseErrorLoginRequiredException:
198 if not self.config_entry.options.get(CONF_UNAUTHENTICATED_MODE):
199 _LOGGER.debug(
"Trying to authorize again")
200 if self.
clientclient.user.login(
201 self.config_entry.data.get(CONF_USERNAME,
""),
202 self.config_entry.data.get(CONF_PASSWORD,
""),
205 "success, %s will be updated by a future periodic run",
209 _LOGGER.debug(
"failed")
212 "%s requires authorization, excluding from future updates", key
214 self.subscriptions.pop(key)
215 except (ResponseErrorException, ExpatError)
as exc:
220 exc, (ResponseErrorNotSupportedException, ExpatError)
221 )
and exc.code
not in (-1, 100006):
224 "%s apparently not supported by device, excluding from future updates",
227 self.subscriptions.pop(key)
230 self.notify_last_attempt - time.monotonic() + NOTIFY_SUPPRESS_TIMEOUT
234 "%s timed out, %.1fs notify timeout suppress grace remaining",
242 self.inflight_gets.discard(key)
243 _LOGGER.debug(
"%s=%s", key, self.data.
get(key))
246 """Update router data."""
249 _LOGGER.debug(
"Integration suspended, not updating data")
252 self.
_get_data_get_data(KEY_DEVICE_INFORMATION, self.
clientclient.device.information)
253 if self.data.
get(KEY_DEVICE_INFORMATION):
255 self.subscriptions.pop(KEY_DEVICE_BASIC_INFORMATION,
None)
257 KEY_DEVICE_BASIC_INFORMATION, self.
clientclient.device.basic_information
259 self.
_get_data_get_data(KEY_DEVICE_SIGNAL, self.
clientclient.device.signal)
261 KEY_DIALUP_MOBILE_DATASWITCH, self.
clientclient.dial_up.mobile_dataswitch
264 KEY_MONITORING_MONTH_STATISTICS, self.
clientclient.monitoring.month_statistics
267 KEY_MONITORING_CHECK_NOTIFICATIONS,
268 self.
clientclient.monitoring.check_notifications,
270 self.
_get_data_get_data(KEY_MONITORING_STATUS, self.
clientclient.monitoring.status)
272 KEY_MONITORING_TRAFFIC_STATISTICS, self.
clientclient.monitoring.traffic_statistics
274 self.
_get_data_get_data(KEY_NET_CURRENT_PLMN, self.
clientclient.net.current_plmn)
275 self.
_get_data_get_data(KEY_NET_NET_MODE, self.
clientclient.net.net_mode)
276 self.
_get_data_get_data(KEY_SMS_SMS_COUNT, self.
clientclient.sms.sms_count)
277 self.
_get_data_get_data(KEY_LAN_HOST_INFO, self.
clientclient.lan.host_info)
278 if self.data.
get(KEY_LAN_HOST_INFO):
280 self.subscriptions.pop(KEY_WLAN_HOST_LIST,
None)
281 self.
_get_data_get_data(KEY_WLAN_HOST_LIST, self.
clientclient.wlan.host_list)
283 KEY_WLAN_WIFI_FEATURE_SWITCH, self.
clientclient.wlan.wifi_feature_switch
286 KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH,
290 for ssid
in self.
clientclient.wlan.multi_basic_settings()
293 if isinstance(ssid, dict)
and ssid.get(
"wifiisguestnetwork") ==
"1"
302 """Log out router session."""
304 self.
clientclient.user.logout()
306 ResponseErrorLoginRequiredException,
307 ResponseErrorNotSupportedException,
311 _LOGGER.warning(
"Logout error", exc_info=
True)
314 """Clean up resources."""
316 self.subscriptions.clear()
319 self.connection.requests_session.close()
325 hass_config: ConfigType
326 routers: dict[str, Router]
330 """Set up Huawei LTE component from config entry."""
331 url = entry.data[CONF_URL]
334 """Set up a connection."""
335 kwargs: dict[str, Any] = {
336 "timeout": CONNECTION_TIMEOUT,
338 if url.startswith(
"https://")
and not entry.data.get(CONF_VERIFY_SSL):
340 if entry.options.get(CONF_UNAUTHENTICATED_MODE):
341 _LOGGER.debug(
"Connecting in unauthenticated mode, reduced feature set")
342 connection = Connection(url, **kwargs)
344 _LOGGER.debug(
"Connecting in authenticated mode, full feature set")
345 username = entry.data.get(CONF_USERNAME)
or ""
346 password = entry.data.get(CONF_PASSWORD)
or ""
347 connection = Connection(url, username=username, password=password, **kwargs)
351 connection = await hass.async_add_executor_job(_connect)
352 except LoginErrorInvalidCredentialsException
as ex:
353 raise ConfigEntryAuthFailed
from ex
354 except Timeout
as ex:
355 raise ConfigEntryNotReady
from ex
358 router =
Router(hass, entry, connection, url)
361 await hass.async_add_executor_job(router.update)
364 router_info = router.data.get(KEY_DEVICE_INFORMATION)
365 if not entry.unique_id:
367 if router_info
and (serial_number := router_info.get(
"SerialNumber")):
368 hass.config_entries.async_update_entry(entry, unique_id=serial_number)
369 ent_reg = er.async_get(hass)
370 for entity_entry
in er.async_entries_for_config_entry(
371 ent_reg, entry.entry_id
373 if not entity_entry.unique_id.startswith(
"None-"):
375 new_unique_id = entity_entry.unique_id.removeprefix(
"None-")
376 new_unique_id = f
"{serial_number}-{new_unique_id}"
377 ent_reg.async_update_entity(
378 entity_entry.entity_id, new_unique_id=new_unique_id
381 await hass.async_add_executor_job(router.cleanup)
383 "Could not resolve serial number to use as unique id for router at %s"
386 if not entry.data.get(CONF_PASSWORD):
388 ". Try setting up credentials for the router for one startup, "
389 "unauthenticated mode can be enabled after that in integration "
392 _LOGGER.error(msg, url)
396 hass.data[DOMAIN].routers[entry.entry_id] = router
399 router.subscriptions.clear()
405 wlan_settings = await hass.async_add_executor_job(
406 router.client.wlan.multi_basic_settings
413 if macs
and (
not entry.data[CONF_MAC]
or (router_info
and wlan_settings)):
414 new_data =
dict(entry.data)
415 new_data[CONF_MAC] = macs
416 hass.config_entries.async_update_entry(entry, data=new_data)
419 if router.device_identifiers
or router.device_connections:
421 configuration_url=router.url,
422 connections=router.device_connections,
423 identifiers=router.device_identifiers,
424 manufacturer=entry.data.get(CONF_MANUFACTURER, DEFAULT_MANUFACTURER),
425 name=router.device_name,
430 hw_version = router_info.get(
"HardwareVersion")
431 sw_version = router_info.get(
"SoftwareVersion")
432 if router_info.get(
"DeviceName"):
433 device_info[ATTR_MODEL] = router_info[
"DeviceName"]
434 if not sw_version
and router.data.get(KEY_DEVICE_BASIC_INFORMATION):
435 sw_version = router.data[KEY_DEVICE_BASIC_INFORMATION].
get(
439 device_info[ATTR_HW_VERSION] = hw_version
441 device_info[ATTR_SW_VERSION] = sw_version
442 device_registry = dr.async_get(hass)
443 device_registry.async_get_or_create(
444 config_entry_id=entry.entry_id,
449 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
452 await discovery.async_load_platform(
457 ATTR_CONFIG_ENTRY_ID: entry.entry_id,
458 CONF_NAME: entry.options.get(CONF_NAME, DEFAULT_NOTIFY_SERVICE_NAME),
459 CONF_RECIPIENT: entry.options.get(CONF_RECIPIENT),
461 hass.data[DOMAIN].hass_config,
464 def _update_router(*_: Any) ->
None:
465 """Update router data.
467 Separate passthrough function because lambdas don't work with track_time_interval.
472 entry.async_on_unload(
477 entry.async_on_unload(
478 hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, router.cleanup)
485 """Unload config entry."""
488 await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
491 router = hass.data[DOMAIN].routers.pop(config_entry.entry_id)
492 await hass.async_add_executor_job(router.cleanup)
497 async
def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
498 """Set up Huawei LTE component."""
500 if DOMAIN
not in hass.data:
501 hass.data[DOMAIN] =
HuaweiLteData(hass_config=config, routers={})
503 def service_handler(service: ServiceCall) ->
None:
506 We key this using the router URL instead of its unique id / serial number,
507 because the latter is not available anywhere in the UI.
509 routers = hass.data[DOMAIN].routers
510 if url := service.data.get(CONF_URL):
512 (router
for router
in routers.values()
if router.url == url),
None
515 _LOGGER.error(
"%s: no routers configured", service.service)
517 elif len(routers) == 1:
518 router = next(iter(routers.values()))
521 "%s: more than one router configured, must specify one of URLs %s",
523 sorted(router.url
for router
in routers.values()),
527 _LOGGER.error(
"%s: router %s unavailable", service.service, url)
530 if service.service == SERVICE_RESUME_INTEGRATION:
532 router.suspended =
False
533 _LOGGER.debug(
"%s: %s", service.service,
"done")
534 elif service.service == SERVICE_SUSPEND_INTEGRATION:
536 router.suspended =
True
537 _LOGGER.debug(
"%s: %s", service.service,
"done")
539 _LOGGER.error(
"%s: unsupported service", service.service)
541 for service
in ADMIN_SERVICES:
547 schema=SERVICE_SCHEMA,
554 """Migrate config entry to new version."""
555 if config_entry.version == 1:
556 options =
dict(config_entry.options)
557 recipient = options.get(CONF_RECIPIENT)
558 if isinstance(recipient, str):
559 options[CONF_RECIPIENT] = [x.strip()
for x
in recipient.split(
",")]
560 hass.config_entries.async_update_entry(config_entry, options=options, version=2)
561 _LOGGER.debug(
"Migrated config entry to version %d", config_entry.version)
562 if config_entry.version == 2:
563 data =
dict(config_entry.data)
565 hass.config_entries.async_update_entry(config_entry, data=data, version=3)
566 _LOGGER.debug(
"Migrated config entry to version %d", config_entry.version)
set[tuple[str, str]] device_connections(self)
set[tuple[str, str]] device_identifiers(self)
None cleanup(self, *Any _)
None _get_data(self, str key, Callable[[], Any] func)
bool add(self, _T matcher)
web.Response get(self, web.Request request, str config_key)
requests.Session non_verifying_requests_session(str url)
list[str] get_device_macs(GetResponseType device_info, GetResponseType wlan_settings)
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
bool async_unload_entry(HomeAssistant hass, ConfigEntry config_entry)
bool async_migrate_entry(HomeAssistant hass, ConfigEntry config_entry)
bool async_setup(HomeAssistant hass, ConfigType config)
tuple[str, dict[str, Any]] _connect(JellyfinClient client, str url, str username, str password)
None dispatcher_send(HomeAssistant hass, str signal, *Any args)
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
None async_register_admin_service(HomeAssistant hass, str domain, str service, Callable[[ServiceCall], Awaitable[None]|None] service_func, VolSchemaType schema=vol.Schema({}, extra=vol.PREVENT_EXTRA))