Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support for Huawei LTE routers."""
2 
3 from __future__ import annotations
4 
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
10 import logging
11 import time
12 from typing import Any, NamedTuple, cast
13 from xml.parsers.expat import ExpatError
14 
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,
22 )
23 from requests.exceptions import Timeout
24 import voluptuous as vol
25 
26 from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
27 from homeassistant.config_entries import ConfigEntry
28 from homeassistant.const import (
29  ATTR_HW_VERSION,
30  ATTR_MODEL,
31  ATTR_SW_VERSION,
32  CONF_MAC,
33  CONF_NAME,
34  CONF_PASSWORD,
35  CONF_RECIPIENT,
36  CONF_URL,
37  CONF_USERNAME,
38  CONF_VERIFY_SSL,
39  EVENT_HOMEASSISTANT_STOP,
40  Platform,
41 )
42 from homeassistant.core import HomeAssistant, ServiceCall
43 from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
44 from homeassistant.helpers import (
45  config_validation as cv,
46  device_registry as dr,
47  discovery,
48  entity_registry as er,
49 )
50 from homeassistant.helpers.device_registry import DeviceInfo
51 from homeassistant.helpers.dispatcher import dispatcher_send
52 from homeassistant.helpers.event import async_track_time_interval
53 from homeassistant.helpers.service import async_register_admin_service
54 from homeassistant.helpers.typing import ConfigType
55 
56 from .const import (
57  ADMIN_SERVICES,
58  ALL_KEYS,
59  ATTR_CONFIG_ENTRY_ID,
60  CONF_MANUFACTURER,
61  CONF_UNAUTHENTICATED_MODE,
62  CONNECTION_TIMEOUT,
63  DEFAULT_DEVICE_NAME,
64  DEFAULT_MANUFACTURER,
65  DEFAULT_NOTIFY_SERVICE_NAME,
66  DOMAIN,
67  KEY_DEVICE_BASIC_INFORMATION,
68  KEY_DEVICE_INFORMATION,
69  KEY_DEVICE_SIGNAL,
70  KEY_DIALUP_MOBILE_DATASWITCH,
71  KEY_LAN_HOST_INFO,
72  KEY_MONITORING_CHECK_NOTIFICATIONS,
73  KEY_MONITORING_MONTH_STATISTICS,
74  KEY_MONITORING_STATUS,
75  KEY_MONITORING_TRAFFIC_STATISTICS,
76  KEY_NET_CURRENT_PLMN,
77  KEY_NET_NET_MODE,
78  KEY_SMS_SMS_COUNT,
79  KEY_WLAN_HOST_LIST,
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,
85  UPDATE_SIGNAL,
86 )
87 from .utils import get_device_macs, non_verifying_requests_session
88 
89 _LOGGER = logging.getLogger(__name__)
90 
91 SCAN_INTERVAL = timedelta(seconds=10)
92 
93 NOTIFY_SCHEMA = vol.Any(
94  None,
95  vol.Schema(
96  {
97  vol.Optional(CONF_NAME): cv.string,
98  vol.Optional(CONF_RECIPIENT): vol.Any(
99  None, vol.All(cv.ensure_list, [cv.string])
100  ),
101  }
102  ),
103 )
104 
105 CONFIG_SCHEMA = vol.Schema(
106  {
107  DOMAIN: vol.All(
108  cv.ensure_list,
109  [
110  vol.Schema(
111  {
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,
116  }
117  )
118  ],
119  )
120  },
121  extra=vol.ALLOW_EXTRA,
122 )
123 
124 SERVICE_SCHEMA = vol.Schema({vol.Optional(CONF_URL): cv.url})
125 
126 PLATFORMS = [
127  Platform.BINARY_SENSOR,
128  Platform.BUTTON,
129  Platform.DEVICE_TRACKER,
130  Platform.SELECT,
131  Platform.SENSOR,
132  Platform.SWITCH,
133 ]
134 
135 
136 @dataclass
137 class Router:
138  """Class for router state."""
139 
140  hass: HomeAssistant
141  config_entry: ConfigEntry
142  connection: Connection
143  url: str
144 
145  data: dict[str, Any] = field(default_factory=dict, init=False)
146  # Values are lists rather than sets, because the same item may be used by more than
147  # one thing, such as MonthDuration for CurrentMonth{Download,Upload}.
148  subscriptions: dict[str, list[str]] = field(
149  default_factory=lambda: defaultdict(
150  list, ((x, ["initial_scan"]) for x in ALL_KEYS)
151  ),
152  init=False,
153  )
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)
158 
159  def __post_init__(self) -> None:
160  """Set up internal state on init."""
161  self.clientclient = Client(self.connection)
162 
163  @property
164  def device_name(self) -> str:
165  """Get router device name."""
166  for key, item in (
167  (KEY_DEVICE_BASIC_INFORMATION, "devicename"),
168  (KEY_DEVICE_INFORMATION, "DeviceName"),
169  ):
170  with suppress(KeyError, TypeError):
171  return cast(str, self.data[key][item])
172  return DEFAULT_DEVICE_NAME
173 
174  @property
175  def device_identifiers(self) -> set[tuple[str, str]]:
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)}
179 
180  @property
181  def device_connections(self) -> set[tuple[str, str]]:
182  """Get router connections for device registry."""
183  return {
184  (dr.CONNECTION_NETWORK_MAC, x) for x in self.config_entry.data[CONF_MAC]
185  }
186 
187  def _get_data(self, key: str, func: Callable[[], Any]) -> None:
188  if not self.subscriptions.get(key):
189  return
190  if key in self.inflight_gets:
191  _LOGGER.debug("Skipping already in-flight get for %s", key)
192  return
193  self.inflight_gets.add(key)
194  _LOGGER.debug("Getting %s for subscribers %s", key, self.subscriptions[key])
195  try:
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, ""),
203  ):
204  _LOGGER.debug(
205  "success, %s will be updated by a future periodic run",
206  key,
207  )
208  else:
209  _LOGGER.debug("failed")
210  return
211  _LOGGER.warning(
212  "%s requires authorization, excluding from future updates", key
213  )
214  self.subscriptions.pop(key)
215  except (ResponseErrorException, ExpatError) as exc:
216  # Take ResponseErrorNotSupportedException, ExpatError, and generic
217  # ResponseErrorException with a few select codes to mean the endpoint is
218  # not supported.
219  if not isinstance(
220  exc, (ResponseErrorNotSupportedException, ExpatError)
221  ) and exc.code not in (-1, 100006):
222  raise
223  _LOGGER.warning(
224  "%s apparently not supported by device, excluding from future updates",
225  key,
226  )
227  self.subscriptions.pop(key)
228  except Timeout:
229  grace_left = (
230  self.notify_last_attempt - time.monotonic() + NOTIFY_SUPPRESS_TIMEOUT
231  )
232  if grace_left > 0:
233  _LOGGER.debug(
234  "%s timed out, %.1fs notify timeout suppress grace remaining",
235  key,
236  grace_left,
237  exc_info=True,
238  )
239  else:
240  raise
241  finally:
242  self.inflight_gets.discard(key)
243  _LOGGER.debug("%s=%s", key, self.data.get(key))
244 
245  def update(self) -> None:
246  """Update router data."""
247 
248  if self.suspended:
249  _LOGGER.debug("Integration suspended, not updating data")
250  return
251 
252  self._get_data_get_data(KEY_DEVICE_INFORMATION, self.clientclient.device.information)
253  if self.data.get(KEY_DEVICE_INFORMATION):
254  # Full information includes everything in basic
255  self.subscriptions.pop(KEY_DEVICE_BASIC_INFORMATION, None)
256  self._get_data_get_data(
257  KEY_DEVICE_BASIC_INFORMATION, self.clientclient.device.basic_information
258  )
259  self._get_data_get_data(KEY_DEVICE_SIGNAL, self.clientclient.device.signal)
260  self._get_data_get_data(
261  KEY_DIALUP_MOBILE_DATASWITCH, self.clientclient.dial_up.mobile_dataswitch
262  )
263  self._get_data_get_data(
264  KEY_MONITORING_MONTH_STATISTICS, self.clientclient.monitoring.month_statistics
265  )
266  self._get_data_get_data(
267  KEY_MONITORING_CHECK_NOTIFICATIONS,
268  self.clientclient.monitoring.check_notifications,
269  )
270  self._get_data_get_data(KEY_MONITORING_STATUS, self.clientclient.monitoring.status)
271  self._get_data_get_data(
272  KEY_MONITORING_TRAFFIC_STATISTICS, self.clientclient.monitoring.traffic_statistics
273  )
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):
279  # LAN host info includes everything in WLAN host list
280  self.subscriptions.pop(KEY_WLAN_HOST_LIST, None)
281  self._get_data_get_data(KEY_WLAN_HOST_LIST, self.clientclient.wlan.host_list)
282  self._get_data_get_data(
283  KEY_WLAN_WIFI_FEATURE_SWITCH, self.clientclient.wlan.wifi_feature_switch
284  )
285  self._get_data_get_data(
286  KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH,
287  lambda: next(
288  (
289  ssid
290  for ssid in self.clientclient.wlan.multi_basic_settings()
291  .get("Ssids", {})
292  .get("Ssid", [])
293  if isinstance(ssid, dict) and ssid.get("wifiisguestnetwork") == "1"
294  ),
295  {},
296  ),
297  )
298 
299  dispatcher_send(self.hass, UPDATE_SIGNAL, self.config_entry.unique_id)
300 
301  def logout(self) -> None:
302  """Log out router session."""
303  try:
304  self.clientclient.user.logout()
305  except (
306  ResponseErrorLoginRequiredException,
307  ResponseErrorNotSupportedException,
308  ):
309  pass # Ok, normal, nothing to do
310  except Exception: # noqa: BLE001
311  _LOGGER.warning("Logout error", exc_info=True)
312 
313  def cleanup(self, *_: Any) -> None:
314  """Clean up resources."""
315 
316  self.subscriptions.clear()
317 
318  self.logoutlogout()
319  self.connection.requests_session.close()
320 
321 
322 class HuaweiLteData(NamedTuple):
323  """Shared state."""
324 
325  hass_config: ConfigType
326  routers: dict[str, Router]
327 
328 
329 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
330  """Set up Huawei LTE component from config entry."""
331  url = entry.data[CONF_URL]
332 
333  def _connect() -> Connection:
334  """Set up a connection."""
335  kwargs: dict[str, Any] = {
336  "timeout": CONNECTION_TIMEOUT,
337  }
338  if url.startswith("https://") and not entry.data.get(CONF_VERIFY_SSL):
339  kwargs["requests_session"] = non_verifying_requests_session(url)
340  if entry.options.get(CONF_UNAUTHENTICATED_MODE):
341  _LOGGER.debug("Connecting in unauthenticated mode, reduced feature set")
342  connection = Connection(url, **kwargs)
343  else:
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)
348  return connection
349 
350  try:
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
356 
357  # Set up router
358  router = Router(hass, entry, connection, url)
359 
360  # Do initial data update
361  await hass.async_add_executor_job(router.update)
362 
363  # Check that we found required information
364  router_info = router.data.get(KEY_DEVICE_INFORMATION)
365  if not entry.unique_id:
366  # Transitional from < 2021.8: update None config entry and entity unique ids
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
372  ):
373  if not entity_entry.unique_id.startswith("None-"):
374  continue
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
379  )
380  else:
381  await hass.async_add_executor_job(router.cleanup)
382  msg = (
383  "Could not resolve serial number to use as unique id for router at %s"
384  ", setup failed"
385  )
386  if not entry.data.get(CONF_PASSWORD):
387  msg += (
388  ". Try setting up credentials for the router for one startup, "
389  "unauthenticated mode can be enabled after that in integration "
390  "settings"
391  )
392  _LOGGER.error(msg, url)
393  return False
394 
395  # Store reference to router
396  hass.data[DOMAIN].routers[entry.entry_id] = router
397 
398  # Clear all subscriptions, enabled entities will push back theirs
399  router.subscriptions.clear()
400 
401  # Update device MAC addresses on record. These can change due to toggling between
402  # authenticated and unauthenticated modes, or likely also when enabling/disabling
403  # SSIDs in the router config.
404  try:
405  wlan_settings = await hass.async_add_executor_job(
406  router.client.wlan.multi_basic_settings
407  )
408  except Exception: # noqa: BLE001
409  # Assume not supported, or authentication required but in unauthenticated mode
410  wlan_settings = {}
411  macs = get_device_macs(router_info or {}, wlan_settings)
412  # Be careful not to overwrite a previous, more complete set with a partial one
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)
417 
418  # Set up device registry
419  if router.device_identifiers or router.device_connections:
420  device_info = DeviceInfo(
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,
426  )
427  hw_version = None
428  sw_version = None
429  if router_info:
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(
436  "SoftwareVersion"
437  )
438  if hw_version:
439  device_info[ATTR_HW_VERSION] = hw_version
440  if sw_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,
445  **device_info,
446  )
447 
448  # Forward config entry setup to platforms
449  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
450 
451  # Notify doesn't support config entry setup yet, load with discovery for now
452  await discovery.async_load_platform(
453  hass,
454  Platform.NOTIFY,
455  DOMAIN,
456  {
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),
460  },
461  hass.data[DOMAIN].hass_config,
462  )
463 
464  def _update_router(*_: Any) -> None:
465  """Update router data.
466 
467  Separate passthrough function because lambdas don't work with track_time_interval.
468  """
469  router.update()
470 
471  # Set up periodic update
472  entry.async_on_unload(
473  async_track_time_interval(hass, _update_router, SCAN_INTERVAL)
474  )
475 
476  # Clean up at end
477  entry.async_on_unload(
478  hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, router.cleanup)
479  )
480 
481  return True
482 
483 
484 async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
485  """Unload config entry."""
486 
487  # Forward config entry unload to platforms
488  await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
489 
490  # Forget about the router and invoke its cleanup
491  router = hass.data[DOMAIN].routers.pop(config_entry.entry_id)
492  await hass.async_add_executor_job(router.cleanup)
493 
494  return True
495 
496 
497 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
498  """Set up Huawei LTE component."""
499 
500  if DOMAIN not in hass.data:
501  hass.data[DOMAIN] = HuaweiLteData(hass_config=config, routers={})
502 
503  def service_handler(service: ServiceCall) -> None:
504  """Apply a service.
505 
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.
508  """
509  routers = hass.data[DOMAIN].routers
510  if url := service.data.get(CONF_URL):
511  router = next(
512  (router for router in routers.values() if router.url == url), None
513  )
514  elif not routers:
515  _LOGGER.error("%s: no routers configured", service.service)
516  return
517  elif len(routers) == 1:
518  router = next(iter(routers.values()))
519  else:
520  _LOGGER.error(
521  "%s: more than one router configured, must specify one of URLs %s",
522  service.service,
523  sorted(router.url for router in routers.values()),
524  )
525  return
526  if not router:
527  _LOGGER.error("%s: router %s unavailable", service.service, url)
528  return
529 
530  if service.service == SERVICE_RESUME_INTEGRATION:
531  # Login will be handled automatically on demand
532  router.suspended = False
533  _LOGGER.debug("%s: %s", service.service, "done")
534  elif service.service == SERVICE_SUSPEND_INTEGRATION:
535  router.logout()
536  router.suspended = True
537  _LOGGER.debug("%s: %s", service.service, "done")
538  else:
539  _LOGGER.error("%s: unsupported service", service.service)
540 
541  for service in ADMIN_SERVICES:
543  hass,
544  DOMAIN,
545  service,
546  service_handler,
547  schema=SERVICE_SCHEMA,
548  )
549 
550  return True
551 
552 
553 async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
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)
564  data[CONF_MAC] = []
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)
567  # There can be no longer needed *_from_yaml data and options things left behind
568  # from pre-2022.4ish; they can be removed while at it when/if we eventually bump and
569  # migrate to version > 3 for some other reason.
570  return True
set[tuple[str, str]] device_connections(self)
Definition: __init__.py:181
set[tuple[str, str]] device_identifiers(self)
Definition: __init__.py:175
None _get_data(self, str key, Callable[[], Any] func)
Definition: __init__.py:187
bool add(self, _T matcher)
Definition: match.py:185
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
requests.Session non_verifying_requests_session(str url)
Definition: utils.py:36
list[str] get_device_macs(GetResponseType device_info, GetResponseType wlan_settings)
Definition: utils.py:19
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:329
bool async_unload_entry(HomeAssistant hass, ConfigEntry config_entry)
Definition: __init__.py:484
bool async_migrate_entry(HomeAssistant hass, ConfigEntry config_entry)
Definition: __init__.py:553
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:497
tuple[str, dict[str, Any]] _connect(JellyfinClient client, str url, str username, str password)
None dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:137
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)
Definition: event.py:1679
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))
Definition: service.py:1121