Home Assistant Unofficial Reference 2024.12.1
coordinator.py
Go to the documentation of this file.
1 """Support for AVM FRITZ!Box classes."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable, ValuesView
6 from dataclasses import dataclass, field
7 from datetime import datetime, timedelta
8 from functools import partial
9 import logging
10 import re
11 from types import MappingProxyType
12 from typing import Any, TypedDict, cast
13 
14 from fritzconnection import FritzConnection
15 from fritzconnection.core.exceptions import (
16  FritzActionError,
17  FritzConnectionException,
18  FritzSecurityError,
19  FritzServiceError,
20 )
21 from fritzconnection.lib.fritzhosts import FritzHosts
22 from fritzconnection.lib.fritzstatus import FritzStatus
23 from fritzconnection.lib.fritzwlan import DEFAULT_PASSWORD_LENGTH, FritzGuestWLAN
24 import xmltodict
25 
27  CONF_CONSIDER_HOME,
28  DEFAULT_CONSIDER_HOME,
29  DOMAIN as DEVICE_TRACKER_DOMAIN,
30 )
31 from homeassistant.config_entries import ConfigEntry
32 from homeassistant.core import HomeAssistant, ServiceCall
33 from homeassistant.exceptions import HomeAssistantError
34 from homeassistant.helpers import device_registry as dr, entity_registry as er
35 from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
36 from homeassistant.helpers.dispatcher import async_dispatcher_send
37 from homeassistant.helpers.typing import StateType
38 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
39 from homeassistant.util import dt as dt_util
40 
41 from .const import (
42  CONF_OLD_DISCOVERY,
43  DEFAULT_CONF_OLD_DISCOVERY,
44  DEFAULT_HOST,
45  DEFAULT_SSL,
46  DEFAULT_USERNAME,
47  DOMAIN,
48  FRITZ_EXCEPTIONS,
49  SERVICE_SET_GUEST_WIFI_PW,
50  MeshRoles,
51 )
52 
53 _LOGGER = logging.getLogger(__name__)
54 
55 
56 def _is_tracked(mac: str, current_devices: ValuesView) -> bool:
57  """Check if device is already tracked."""
58  return any(mac in tracked for tracked in current_devices)
59 
60 
62  mac: str,
63  device: FritzDevice,
64  current_devices: ValuesView,
65 ) -> bool:
66  """Check if device should be filtered out from trackers."""
67  reason: str | None = None
68  if device.ip_address == "":
69  reason = "Missing IP"
70  elif _is_tracked(mac, current_devices):
71  reason = "Already tracked"
72 
73  if reason:
74  _LOGGER.debug(
75  "Skip adding device %s [%s], reason: %s", device.hostname, mac, reason
76  )
77  return bool(reason)
78 
79 
80 def _ha_is_stopping(activity: str) -> None:
81  """Inform that HA is stopping."""
82  _LOGGER.warning("Cannot execute %s: HomeAssistant is shutting down", activity)
83 
84 
85 class ClassSetupMissing(Exception):
86  """Raised when a Class func is called before setup."""
87 
88  def __init__(self) -> None:
89  """Init custom exception."""
90  super().__init__("Function called before Class setup")
91 
92 
93 @dataclass
94 class Device:
95  """FRITZ!Box device class."""
96 
97  connected: bool
98  connected_to: str
99  connection_type: str
100  ip_address: str
101  name: str
102  ssid: str | None
103  wan_access: bool | None = None
104 
105 
106 class Interface(TypedDict):
107  """Interface details."""
108 
109  device: str
110  mac: str
111  op_mode: str
112  ssid: str | None
113  type: str
114 
115 
116 HostAttributes = TypedDict(
117  "HostAttributes",
118  {
119  "Index": int,
120  "IPAddress": str,
121  "MACAddress": str,
122  "Active": bool,
123  "HostName": str,
124  "InterfaceType": str,
125  "X_AVM-DE_Port": int,
126  "X_AVM-DE_Speed": int,
127  "X_AVM-DE_UpdateAvailable": bool,
128  "X_AVM-DE_UpdateSuccessful": str,
129  "X_AVM-DE_InfoURL": str | None,
130  "X_AVM-DE_MACAddressList": str | None,
131  "X_AVM-DE_Model": str | None,
132  "X_AVM-DE_URL": str | None,
133  "X_AVM-DE_Guest": bool,
134  "X_AVM-DE_RequestClient": str,
135  "X_AVM-DE_VPN": bool,
136  "X_AVM-DE_WANAccess": str,
137  "X_AVM-DE_Disallow": bool,
138  "X_AVM-DE_IsMeshable": str,
139  "X_AVM-DE_Priority": str,
140  "X_AVM-DE_FriendlyName": str,
141  "X_AVM-DE_FriendlyNameIsWriteable": str,
142  },
143 )
144 
145 
146 class HostInfo(TypedDict):
147  """FRITZ!Box host info class."""
148 
149  mac: str
150  name: str
151  ip: str
152  status: bool
153 
154 
155 class UpdateCoordinatorDataType(TypedDict):
156  """Update coordinator data type."""
157 
158  call_deflections: dict[int, dict]
159  entity_states: dict[str, StateType | bool]
160 
161 
162 class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
163  """FritzBoxTools class."""
164 
165  config_entry: ConfigEntry
166 
167  def __init__(
168  self,
169  hass: HomeAssistant,
170  password: str,
171  port: int,
172  username: str = DEFAULT_USERNAME,
173  host: str = DEFAULT_HOST,
174  use_tls: bool = DEFAULT_SSL,
175  ) -> None:
176  """Initialize FritzboxTools class."""
177  super().__init__(
178  hass=hass,
179  logger=_LOGGER,
180  name=f"{DOMAIN}-{host}-coordinator",
181  update_interval=timedelta(seconds=30),
182  )
183 
184  self._devices: dict[str, FritzDevice] = {}
185  self._options_options: MappingProxyType[str, Any] | None = None
186  self._unique_id_unique_id: str | None = None
187  self.connectionconnection: FritzConnection = None
188  self.fritz_guest_wififritz_guest_wifi: FritzGuestWLAN = None
189  self.fritz_hostsfritz_hosts: FritzHosts = None
190  self.fritz_statusfritz_status: FritzStatus = None
191  self.hasshasshass = hass
192  self.hosthost = host
193  self.mesh_rolemesh_role = MeshRoles.NONE
194  self.device_conn_typedevice_conn_type: str | None = None
195  self.device_is_routerdevice_is_router: bool = False
196  self.passwordpassword = password
197  self.portport = port
198  self.usernameusername = username
199  self.use_tlsuse_tls = use_tls
200  self.has_call_deflectionshas_call_deflections: bool = False
201  self._model_model: str | None = None
202  self._current_firmware_current_firmware: str | None = None
203  self._latest_firmware: str | None = None
204  self._update_available: bool = False
205  self._release_url: str | None = None
206  self._entity_update_functions: dict[
207  str, Callable[[FritzStatus, StateType], Any]
208  ] = {}
209 
210  async def async_setup(
211  self, options: MappingProxyType[str, Any] | None = None
212  ) -> None:
213  """Wrap up FritzboxTools class setup."""
214  self._options_options = options
215  await self.hasshasshass.async_add_executor_job(self.setupsetup)
216 
217  def setup(self) -> None:
218  """Set up FritzboxTools class."""
219 
220  self.connectionconnection = FritzConnection(
221  address=self.hosthost,
222  port=self.portport,
223  user=self.usernameusername,
224  password=self.passwordpassword,
225  use_tls=self.use_tlsuse_tls,
226  timeout=60.0,
227  pool_maxsize=30,
228  )
229 
230  if not self.connectionconnection:
231  _LOGGER.error("Unable to establish a connection with %s", self.hosthost)
232  return
233 
234  _LOGGER.debug(
235  "detected services on %s %s",
236  self.hosthost,
237  list(self.connectionconnection.services.keys()),
238  )
239 
240  self.fritz_hostsfritz_hosts = FritzHosts(fc=self.connectionconnection)
241  self.fritz_guest_wififritz_guest_wifi = FritzGuestWLAN(fc=self.connectionconnection)
242  self.fritz_statusfritz_status = FritzStatus(fc=self.connectionconnection)
243  info = self.fritz_statusfritz_status.get_device_info()
244 
245  _LOGGER.debug(
246  "gathered device info of %s %s",
247  self.hosthost,
248  {
249  **vars(info),
250  "NewDeviceLog": "***omitted***",
251  "NewSerialNumber": "***omitted***",
252  },
253  )
254 
255  if not self._unique_id_unique_id:
256  self._unique_id_unique_id = info.serial_number
257 
258  self._model_model = info.model_name
259  if (
260  version_normalized := re.search(r"^\d+\.[0]?(.*)", info.software_version)
261  ) is not None:
262  self._current_firmware_current_firmware = version_normalized.group(1)
263  else:
264  self._current_firmware_current_firmware = info.software_version
265 
266  (
267  self._update_available,
268  self._latest_firmware,
269  self._release_url,
270  ) = self._update_device_info_update_device_info()
271 
272  if self.fritz_statusfritz_status.has_wan_support:
273  self.device_conn_typedevice_conn_type = (
274  self.fritz_statusfritz_status.get_default_connection_service().connection_service
275  )
276  self.device_is_routerdevice_is_router = self.fritz_statusfritz_status.has_wan_enabled
277 
278  self.has_call_deflectionshas_call_deflections = "X_AVM-DE_OnTel1" in self.connectionconnection.services
279 
281  self, key: str, update_fn: Callable[[FritzStatus, StateType], Any]
282  ) -> Callable[[], None]:
283  """Register an entity to be updated by coordinator."""
284 
285  def unregister_entity_updates() -> None:
286  """Unregister an entity to be updated by coordinator."""
287  if key in self._entity_update_functions:
288  _LOGGER.debug("unregister entity %s from updates", key)
289  self._entity_update_functions.pop(key)
290 
291  if key not in self._entity_update_functions:
292  _LOGGER.debug("register entity %s for updates", key)
293  self._entity_update_functions[key] = update_fn
294  if self.fritz_statusfritz_status:
295  self.datadata["entity_states"][
296  key
297  ] = await self.hasshasshass.async_add_executor_job(
298  update_fn, self.fritz_statusfritz_status, self.datadata["entity_states"].get(key)
299  )
300  return unregister_entity_updates
301 
302  def _entity_states_update(self) -> dict:
303  """Run registered entity update calls."""
304  entity_states = {}
305  for key in list(self._entity_update_functions):
306  if (update_fn := self._entity_update_functions.get(key)) is not None:
307  _LOGGER.debug("update entity %s", key)
308  entity_states[key] = update_fn(
309  self.fritz_statusfritz_status, self.datadata["entity_states"].get(key)
310  )
311  return entity_states
312 
313  async def _async_update_data(self) -> UpdateCoordinatorDataType:
314  """Update FritzboxTools data."""
315  entity_data: UpdateCoordinatorDataType = {
316  "call_deflections": {},
317  "entity_states": {},
318  }
319  try:
320  await self.async_scan_devicesasync_scan_devices()
321  entity_data["entity_states"] = await self.hasshasshass.async_add_executor_job(
322  self._entity_states_update_entity_states_update
323  )
324  if self.has_call_deflectionshas_call_deflections:
325  entity_data[
326  "call_deflections"
327  ] = await self.async_update_call_deflectionsasync_update_call_deflections()
328  except FRITZ_EXCEPTIONS as ex:
329  raise UpdateFailed(
330  translation_domain=DOMAIN,
331  translation_key="update_failed",
332  translation_placeholders={"error": str(ex)},
333  ) from ex
334 
335  _LOGGER.debug("enity_data: %s", entity_data)
336  return entity_data
337 
338  @property
339  def unique_id(self) -> str:
340  """Return unique id."""
341  if not self._unique_id_unique_id:
342  raise ClassSetupMissing
343  return self._unique_id_unique_id
344 
345  @property
346  def model(self) -> str:
347  """Return device model."""
348  if not self._model_model:
349  raise ClassSetupMissing
350  return self._model_model
351 
352  @property
353  def current_firmware(self) -> str:
354  """Return current SW version."""
355  if not self._current_firmware_current_firmware:
356  raise ClassSetupMissing
357  return self._current_firmware_current_firmware
358 
359  @property
360  def latest_firmware(self) -> str | None:
361  """Return latest SW version."""
362  return self._latest_firmware
363 
364  @property
365  def update_available(self) -> bool:
366  """Return if new SW version is available."""
367  return self._update_available
368 
369  @property
370  def release_url(self) -> str | None:
371  """Return the info URL for latest firmware."""
372  return self._release_url
373 
374  @property
375  def mac(self) -> str:
376  """Return device Mac address."""
377  if not self._unique_id_unique_id:
378  raise ClassSetupMissing
379  return dr.format_mac(self._unique_id_unique_id)
380 
381  @property
382  def devices(self) -> dict[str, FritzDevice]:
383  """Return devices."""
384  return self._devices
385 
386  @property
387  def signal_device_new(self) -> str:
388  """Event specific per FRITZ!Box entry to signal new device."""
389  return f"{DOMAIN}-device-new-{self._unique_id}"
390 
391  @property
392  def signal_device_update(self) -> str:
393  """Event specific per FRITZ!Box entry to signal updates in devices."""
394  return f"{DOMAIN}-device-update-{self._unique_id}"
395 
396  async def _async_get_wan_access(self, ip_address: str) -> bool | None:
397  """Get WAN access rule for given IP address."""
398  try:
399  wan_access = await self.hasshasshass.async_add_executor_job(
400  partial(
401  self.connectionconnection.call_action,
402  "X_AVM-DE_HostFilter:1",
403  "GetWANAccessByIP",
404  NewIPv4Address=ip_address,
405  )
406  )
407  return not wan_access.get("NewDisallow")
408  except FRITZ_EXCEPTIONS as ex:
409  _LOGGER.debug(
410  (
411  "could not get WAN access rule for client device with IP '%s',"
412  " error: %s"
413  ),
414  ip_address,
415  ex,
416  )
417  return None
418 
419  async def _async_update_hosts_info(self) -> dict[str, Device]:
420  """Retrieve latest hosts information from the FRITZ!Box."""
421  hosts_attributes: list[HostAttributes] = []
422  hosts_info: list[HostInfo] = []
423  try:
424  try:
425  hosts_attributes = await self.hasshasshass.async_add_executor_job(
426  self.fritz_hostsfritz_hosts.get_hosts_attributes
427  )
428  except FritzActionError:
429  hosts_info = await self.hasshasshass.async_add_executor_job(
430  self.fritz_hostsfritz_hosts.get_hosts_info
431  )
432  except Exception as ex: # noqa: BLE001
433  if not self.hasshasshass.is_stopping:
434  raise HomeAssistantError(
435  translation_domain=DOMAIN,
436  translation_key="error_refresh_hosts_info",
437  ) from ex
438 
439  hosts: dict[str, Device] = {}
440  if hosts_attributes:
441  for attributes in hosts_attributes:
442  if not attributes.get("MACAddress"):
443  continue
444 
445  if (wan_access := attributes.get("X_AVM-DE_WANAccess")) is not None:
446  wan_access_result = "granted" in wan_access
447  else:
448  wan_access_result = None
449 
450  hosts[attributes["MACAddress"]] = Device(
451  name=attributes["HostName"],
452  connected=attributes["Active"],
453  connected_to="",
454  connection_type="",
455  ip_address=attributes["IPAddress"],
456  ssid=None,
457  wan_access=wan_access_result,
458  )
459  else:
460  for info in hosts_info:
461  if not info.get("mac"):
462  continue
463 
464  if info["ip"]:
465  wan_access_result = await self._async_get_wan_access_async_get_wan_access(info["ip"])
466  else:
467  wan_access_result = None
468 
469  hosts[info["mac"]] = Device(
470  name=info["name"],
471  connected=info["status"],
472  connected_to="",
473  connection_type="",
474  ip_address=info["ip"],
475  ssid=None,
476  wan_access=wan_access_result,
477  )
478  return hosts
479 
480  def _update_device_info(self) -> tuple[bool, str | None, str | None]:
481  """Retrieve latest device information from the FRITZ!Box."""
482  info = self.connectionconnection.call_action("UserInterface1", "GetInfo")
483  version = info.get("NewX_AVM-DE_Version")
484  release_url = info.get("NewX_AVM-DE_InfoURL")
485  return bool(version), version, release_url
486 
487  async def _async_update_device_info(self) -> tuple[bool, str | None, str | None]:
488  """Retrieve latest device information from the FRITZ!Box."""
489  return await self.hasshasshass.async_add_executor_job(self._update_device_info_update_device_info)
490 
492  self,
493  ) -> dict[int, dict[str, Any]]:
494  """Call GetDeflections action from X_AVM-DE_OnTel service."""
495  raw_data = await self.hasshasshass.async_add_executor_job(
496  partial(self.connectionconnection.call_action, "X_AVM-DE_OnTel1", "GetDeflections")
497  )
498  if not raw_data:
499  return {}
500 
501  xml_data = xmltodict.parse(raw_data["NewDeflectionList"])
502  if xml_data.get("List") and (items := xml_data["List"].get("Item")) is not None:
503  if not isinstance(items, list):
504  items = [items]
505  return {int(item["DeflectionId"]): item for item in items}
506  return {}
507 
509  self, dev_info: Device, dev_mac: str, consider_home: bool
510  ) -> bool:
511  """Update device lists."""
512  _LOGGER.debug("Client dev_info: %s", dev_info)
513 
514  if dev_mac in self._devices:
515  self._devices[dev_mac].update(dev_info, consider_home)
516  return False
517 
518  device = FritzDevice(dev_mac, dev_info.name)
519  device.update(dev_info, consider_home)
520  self._devices[dev_mac] = device
521  return True
522 
523  async def async_send_signal_device_update(self, new_device: bool) -> None:
524  """Signal device data updated."""
525  async_dispatcher_send(self.hasshasshass, self.signal_device_updatesignal_device_update)
526  if new_device:
527  async_dispatcher_send(self.hasshasshass, self.signal_device_newsignal_device_new)
528 
529  async def async_scan_devices(self, now: datetime | None = None) -> None:
530  """Scan for new devices and return a list of found device ids."""
531 
532  if self.hasshasshass.is_stopping:
533  _ha_is_stopping("scan devices")
534  return
535 
536  _LOGGER.debug("Checking host info for FRITZ!Box device %s", self.hosthost)
537  (
538  self._update_available,
539  self._latest_firmware,
540  self._release_url,
541  ) = await self._async_update_device_info_async_update_device_info()
542 
543  _LOGGER.debug("Checking devices for FRITZ!Box device %s", self.hosthost)
544  _default_consider_home = DEFAULT_CONSIDER_HOME.total_seconds()
545  if self._options_options:
546  consider_home = self._options_options.get(
547  CONF_CONSIDER_HOME, _default_consider_home
548  )
549  else:
550  consider_home = _default_consider_home
551 
552  new_device = False
553  hosts = await self._async_update_hosts_info_async_update_hosts_info()
554 
555  if not self.fritz_statusfritz_status.device_has_mesh_support or (
556  self._options_options
557  and self._options_options.get(CONF_OLD_DISCOVERY, DEFAULT_CONF_OLD_DISCOVERY)
558  ):
559  _LOGGER.debug(
560  "Using old hosts discovery method. (Mesh not supported or user option)"
561  )
562  self.mesh_rolemesh_role = MeshRoles.NONE
563  for mac, info in hosts.items():
564  if self.manage_device_infomanage_device_info(info, mac, consider_home):
565  new_device = True
566  await self.async_send_signal_device_updateasync_send_signal_device_update(new_device)
567  return
568 
569  try:
570  if not (
571  topology := await self.hasshasshass.async_add_executor_job(
572  self.fritz_hostsfritz_hosts.get_mesh_topology
573  )
574  ):
575  raise Exception("Mesh supported but empty topology reported") # noqa: TRY002
576  except FritzActionError:
577  self.mesh_rolemesh_role = MeshRoles.SLAVE
578  # Avoid duplicating device trackers
579  return
580 
581  mesh_intf = {}
582  # first get all meshed devices
583  for node in topology.get("nodes", []):
584  if not node["is_meshed"]:
585  continue
586 
587  for interf in node["node_interfaces"]:
588  int_mac = interf["mac_address"]
589  mesh_intf[interf["uid"]] = Interface(
590  device=node["device_name"],
591  mac=int_mac,
592  op_mode=interf.get("op_mode", ""),
593  ssid=interf.get("ssid", ""),
594  type=interf["type"],
595  )
596  if dr.format_mac(int_mac) == self.macmac:
597  self.mesh_rolemesh_role = MeshRoles(node["mesh_role"])
598 
599  # second get all client devices
600  for node in topology.get("nodes", []):
601  if node["is_meshed"]:
602  continue
603 
604  for interf in node["node_interfaces"]:
605  dev_mac = interf["mac_address"]
606 
607  if dev_mac not in hosts:
608  continue
609 
610  dev_info: Device = hosts[dev_mac]
611 
612  for link in interf["node_links"]:
613  if link.get("state") != "CONNECTED":
614  continue # ignore orphan node links
615 
616  intf = mesh_intf.get(link["node_interface_1_uid"])
617  if intf is not None:
618  if intf["op_mode"] == "AP_GUEST":
619  dev_info.wan_access = None
620 
621  dev_info.connected_to = intf["device"]
622  dev_info.connection_type = intf["type"]
623  dev_info.ssid = intf.get("ssid")
624 
625  if self.manage_device_infomanage_device_info(dev_info, dev_mac, consider_home):
626  new_device = True
627 
628  await self.async_send_signal_device_updateasync_send_signal_device_update(new_device)
629 
630  async def async_trigger_firmware_update(self) -> bool:
631  """Trigger firmware update."""
632  results = await self.hasshasshass.async_add_executor_job(
633  self.connectionconnection.call_action, "UserInterface:1", "X_AVM-DE_DoUpdate"
634  )
635  return cast(bool, results["NewX_AVM-DE_UpdateState"])
636 
637  async def async_trigger_reboot(self) -> None:
638  """Trigger device reboot."""
639  await self.hasshasshass.async_add_executor_job(self.connectionconnection.reboot)
640 
641  async def async_trigger_reconnect(self) -> None:
642  """Trigger device reconnect."""
643  await self.hasshasshass.async_add_executor_job(self.connectionconnection.reconnect)
644 
646  self, password: str | None, length: int
647  ) -> None:
648  """Trigger service to set a new guest wifi password."""
649  await self.hasshasshass.async_add_executor_job(
650  self.fritz_guest_wififritz_guest_wifi.set_password, password, length
651  )
652 
653  async def async_trigger_cleanup(self) -> None:
654  """Trigger device trackers cleanup."""
655  device_hosts = await self._async_update_hosts_info_async_update_hosts_info()
656  entity_reg: er.EntityRegistry = er.async_get(self.hasshasshass)
657  config_entry = self.config_entryconfig_entry
658 
659  entities: list[er.RegistryEntry] = er.async_entries_for_config_entry(
660  entity_reg, config_entry.entry_id
661  )
662  for entity in entities:
663  entry_mac = entity.unique_id.split("_")[0]
664  if (
665  entity.domain == DEVICE_TRACKER_DOMAIN
666  or "_internet_access" in entity.unique_id
667  ) and entry_mac not in device_hosts:
668  _LOGGER.debug("Removing orphan entity entry %s", entity.entity_id)
669  entity_reg.async_remove(entity.entity_id)
670 
671  device_reg = dr.async_get(self.hasshasshass)
672  valid_connections = {
673  (CONNECTION_NETWORK_MAC, dr.format_mac(mac)) for mac in device_hosts
674  }
675  for device in dr.async_entries_for_config_entry(
676  device_reg, config_entry.entry_id
677  ):
678  if not any(con in device.connections for con in valid_connections):
679  _LOGGER.debug("Removing obsolete device entry %s", device.name)
680  device_reg.async_update_device(
681  device.id, remove_config_entry_id=config_entry.entry_id
682  )
683 
684  async def service_fritzbox(
685  self, service_call: ServiceCall, config_entry: ConfigEntry
686  ) -> None:
687  """Define FRITZ!Box services."""
688  _LOGGER.debug("FRITZ!Box service: %s", service_call.service)
689 
690  if not self.connectionconnection:
691  raise HomeAssistantError(
692  translation_domain=DOMAIN, translation_key="unable_to_connect"
693  )
694 
695  try:
696  if service_call.service == SERVICE_SET_GUEST_WIFI_PW:
697  await self.async_trigger_set_guest_passwordasync_trigger_set_guest_password(
698  service_call.data.get("password"),
699  service_call.data.get("length", DEFAULT_PASSWORD_LENGTH),
700  )
701  return
702 
703  except (FritzServiceError, FritzActionError) as ex:
704  raise HomeAssistantError(
705  translation_domain=DOMAIN, translation_key="service_parameter_unknown"
706  ) from ex
707  except FritzConnectionException as ex:
708  raise HomeAssistantError(
709  translation_domain=DOMAIN, translation_key="service_not_supported"
710  ) from ex
711 
712 
714  """Setup AVM wrapper for API calls."""
715 
717  self,
718  service_name: str,
719  service_suffix: str,
720  action_name: str,
721  **kwargs: Any,
722  ) -> dict:
723  """Return service details."""
724 
725  if self.hasshasshass.is_stopping:
726  _ha_is_stopping(f"{service_name}/{action_name}")
727  return {}
728 
729  if f"{service_name}{service_suffix}" not in self.connectionconnection.services:
730  return {}
731 
732  try:
733  result: dict = await self.hasshasshass.async_add_executor_job(
734  partial(
735  self.connectionconnection.call_action,
736  f"{service_name}:{service_suffix}",
737  action_name,
738  **kwargs,
739  )
740  )
741  except FritzSecurityError:
742  _LOGGER.exception(
743  "Authorization Error: Please check the provided credentials and"
744  " verify that you can log into the web interface"
745  )
746  return {}
747  except FRITZ_EXCEPTIONS:
748  _LOGGER.exception(
749  "Service/Action Error: cannot execute service %s with action %s",
750  service_name,
751  action_name,
752  )
753  return {}
754  except FritzConnectionException:
755  _LOGGER.exception(
756  "Connection Error: Please check the device is properly configured"
757  " for remote login"
758  )
759  return {}
760  return result
761 
762  async def async_get_upnp_configuration(self) -> dict[str, Any]:
763  """Call X_AVM-DE_UPnP service."""
764 
765  return await self._async_service_call_async_service_call("X_AVM-DE_UPnP", "1", "GetInfo")
766 
767  async def async_get_wan_link_properties(self) -> dict[str, Any]:
768  """Call WANCommonInterfaceConfig service."""
769 
770  return await self._async_service_call_async_service_call(
771  "WANCommonInterfaceConfig",
772  "1",
773  "GetCommonLinkProperties",
774  )
775 
776  async def async_ipv6_active(self) -> bool:
777  """Check ip an ipv6 is active on the WAn interface."""
778 
779  def wrap_external_ipv6() -> str:
780  return str(self.fritz_statusfritz_status.external_ipv6)
781 
782  if not self.device_is_routerdevice_is_router:
783  return False
784 
785  return bool(await self.hasshasshass.async_add_executor_job(wrap_external_ipv6))
786 
787  async def async_get_connection_info(self) -> ConnectionInfo:
788  """Return ConnectionInfo data."""
789 
790  link_properties = await self.async_get_wan_link_propertiesasync_get_wan_link_properties()
791  connection_info = ConnectionInfo(
792  connection=link_properties.get("NewWANAccessType", "").lower(),
793  mesh_role=self.mesh_rolemesh_role,
794  wan_enabled=self.device_is_routerdevice_is_router,
795  ipv6_active=await self.async_ipv6_activeasync_ipv6_active(),
796  )
797  _LOGGER.debug(
798  "ConnectionInfo for FritzBox %s: %s",
799  self.hosthost,
800  connection_info,
801  )
802  return connection_info
803 
804  async def async_get_num_port_mapping(self, con_type: str) -> dict[str, Any]:
805  """Call GetPortMappingNumberOfEntries action."""
806 
807  return await self._async_service_call_async_service_call(
808  con_type, "1", "GetPortMappingNumberOfEntries"
809  )
810 
811  async def async_get_port_mapping(self, con_type: str, index: int) -> dict[str, Any]:
812  """Call GetGenericPortMappingEntry action."""
813 
814  return await self._async_service_call_async_service_call(
815  con_type, "1", "GetGenericPortMappingEntry", NewPortMappingIndex=index
816  )
817 
818  async def async_get_wlan_configuration(self, index: int) -> dict[str, Any]:
819  """Call WLANConfiguration service."""
820 
821  return await self._async_service_call_async_service_call(
822  "WLANConfiguration", str(index), "GetInfo"
823  )
824 
826  self, index: int, turn_on: bool
827  ) -> dict[str, Any]:
828  """Call SetEnable action from WLANConfiguration service."""
829 
830  return await self._async_service_call_async_service_call(
831  "WLANConfiguration",
832  str(index),
833  "SetEnable",
834  NewEnable="1" if turn_on else "0",
835  )
836 
838  self, index: int, turn_on: bool
839  ) -> dict[str, Any]:
840  """Call SetDeflectionEnable service."""
841 
842  return await self._async_service_call_async_service_call(
843  "X_AVM-DE_OnTel",
844  "1",
845  "SetDeflectionEnable",
846  NewDeflectionId=index,
847  NewEnable="1" if turn_on else "0",
848  )
849 
851  self, con_type: str, port_mapping: Any
852  ) -> dict[str, Any]:
853  """Call AddPortMapping service."""
854 
855  return await self._async_service_call_async_service_call(
856  con_type,
857  "1",
858  "AddPortMapping",
859  **port_mapping,
860  )
861 
863  self, ip_address: str, turn_on: bool
864  ) -> dict[str, Any]:
865  """Call X_AVM-DE_HostFilter service."""
866 
867  return await self._async_service_call_async_service_call(
868  "X_AVM-DE_HostFilter",
869  "1",
870  "DisallowWANAccessByIP",
871  NewIPv4Address=ip_address,
872  NewDisallow="0" if turn_on else "1",
873  )
874 
875  async def async_wake_on_lan(self, mac_address: str) -> dict[str, Any]:
876  """Call X_AVM-DE_WakeOnLANByMACAddress service."""
877 
878  return await self._async_service_call_async_service_call(
879  "Hosts",
880  "1",
881  "X_AVM-DE_WakeOnLANByMACAddress",
882  NewMACAddress=mac_address,
883  )
884 
885 
886 @dataclass
887 class FritzData:
888  """Storage class for platform global data."""
889 
890  tracked: dict = field(default_factory=dict)
891  profile_switches: dict = field(default_factory=dict)
892  wol_buttons: dict = field(default_factory=dict)
893 
894 
896  """Representation of a device connected to the FRITZ!Box."""
897 
898  def __init__(self, mac: str, name: str) -> None:
899  """Initialize device info."""
900  self._connected_connected = False
901  self._connected_to_connected_to: str | None = None
902  self._connection_type_connection_type: str | None = None
903  self._ip_address_ip_address: str | None = None
904  self._last_activity_last_activity: datetime | None = None
905  self._mac_mac = mac
906  self._name_name = name
907  self._ssid_ssid: str | None = None
908  self._wan_access_wan_access: bool | None = False
909 
910  def update(self, dev_info: Device, consider_home: float) -> None:
911  """Update device info."""
912  utc_point_in_time = dt_util.utcnow()
913 
914  if self._last_activity_last_activity:
915  consider_home_evaluated = (
916  utc_point_in_time - self._last_activity_last_activity
917  ).total_seconds() < consider_home
918  else:
919  consider_home_evaluated = dev_info.connected
920 
921  if not self._name_name:
922  self._name_name = dev_info.name or self._mac_mac.replace(":", "_")
923 
924  self._connected_connected = dev_info.connected or consider_home_evaluated
925 
926  if dev_info.connected:
927  self._last_activity_last_activity = utc_point_in_time
928 
929  self._connected_to_connected_to = dev_info.connected_to
930  self._connection_type_connection_type = dev_info.connection_type
931  self._ip_address_ip_address = dev_info.ip_address
932  self._ssid_ssid = dev_info.ssid
933  self._wan_access_wan_access = dev_info.wan_access
934 
935  @property
936  def connected_to(self) -> str | None:
937  """Return connected status."""
938  return self._connected_to_connected_to
939 
940  @property
941  def connection_type(self) -> str | None:
942  """Return connected status."""
943  return self._connection_type_connection_type
944 
945  @property
946  def is_connected(self) -> bool:
947  """Return connected status."""
948  return self._connected_connected
949 
950  @property
951  def mac_address(self) -> str:
952  """Get MAC address."""
953  return self._mac_mac
954 
955  @property
956  def hostname(self) -> str:
957  """Get Name."""
958  return self._name_name
959 
960  @property
961  def ip_address(self) -> str | None:
962  """Get IP address."""
963  return self._ip_address_ip_address
964 
965  @property
966  def last_activity(self) -> datetime | None:
967  """Return device last activity."""
968  return self._last_activity_last_activity
969 
970  @property
971  def ssid(self) -> str | None:
972  """Return device connected SSID."""
973  return self._ssid_ssid
974 
975  @property
976  def wan_access(self) -> bool | None:
977  """Return device wan access."""
978  return self._wan_access_wan_access
979 
980 
981 class SwitchInfo(TypedDict):
982  """FRITZ!Box switch info class."""
983 
984  description: str
985  friendly_name: str
986  icon: str
987  type: str
988  callback_update: Callable
989  callback_switch: Callable
990  init_state: bool
991 
992 
993 @dataclass
995  """Fritz sensor connection information class."""
996 
997  connection: str
998  mesh_role: MeshRoles
999  wan_enabled: bool
1000  ipv6_active: bool
dict _async_service_call(self, str service_name, str service_suffix, str action_name, **Any kwargs)
Definition: coordinator.py:722
dict[str, Any] async_add_port_mapping(self, str con_type, Any port_mapping)
Definition: coordinator.py:852
dict[str, Any] async_set_allow_wan_access(self, str ip_address, bool turn_on)
Definition: coordinator.py:864
dict[str, Any] async_get_port_mapping(self, str con_type, int index)
Definition: coordinator.py:811
dict[str, Any] async_wake_on_lan(self, str mac_address)
Definition: coordinator.py:875
dict[str, Any] async_set_wlan_configuration(self, int index, bool turn_on)
Definition: coordinator.py:827
dict[str, Any] async_get_num_port_mapping(self, str con_type)
Definition: coordinator.py:804
dict[str, Any] async_set_deflection_enable(self, int index, bool turn_on)
Definition: coordinator.py:839
dict[str, Any] async_get_wlan_configuration(self, int index)
Definition: coordinator.py:818
bool|None _async_get_wan_access(self, str ip_address)
Definition: coordinator.py:396
tuple[bool, str|None, str|None] _update_device_info(self)
Definition: coordinator.py:480
None async_send_signal_device_update(self, bool new_device)
Definition: coordinator.py:523
dict[int, dict[str, Any]] async_update_call_deflections(self)
Definition: coordinator.py:493
None async_setup(self, MappingProxyType[str, Any]|None options=None)
Definition: coordinator.py:212
Callable[[], None] async_register_entity_updates(self, str key, Callable[[FritzStatus, StateType], Any] update_fn)
Definition: coordinator.py:282
UpdateCoordinatorDataType _async_update_data(self)
Definition: coordinator.py:313
None service_fritzbox(self, ServiceCall service_call, ConfigEntry config_entry)
Definition: coordinator.py:686
bool manage_device_info(self, Device dev_info, str dev_mac, bool consider_home)
Definition: coordinator.py:510
None async_scan_devices(self, datetime|None now=None)
Definition: coordinator.py:529
tuple[bool, str|None, str|None] _async_update_device_info(self)
Definition: coordinator.py:487
None __init__(self, HomeAssistant hass, str password, int port, str username=DEFAULT_USERNAME, str host=DEFAULT_HOST, bool use_tls=DEFAULT_SSL)
Definition: coordinator.py:175
None async_trigger_set_guest_password(self, str|None password, int length)
Definition: coordinator.py:647
None update(self, Device dev_info, float consider_home)
Definition: coordinator.py:910
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
bool _is_tracked(str mac, ValuesView current_devices)
Definition: coordinator.py:56
bool device_filter_out_from_trackers(str mac, FritzDevice device, ValuesView current_devices)
Definition: coordinator.py:65
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
DeviceInfo get_device_info(str coordinates, str name)
Definition: __init__.py:156
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193