1 """Support for AVM FRITZ!Box classes."""
3 from __future__
import annotations
5 from collections.abc
import Callable, ValuesView
6 from dataclasses
import dataclass, field
7 from datetime
import datetime, timedelta
8 from functools
import partial
11 from types
import MappingProxyType
12 from typing
import Any, TypedDict, cast
14 from fritzconnection
import FritzConnection
15 from fritzconnection.core.exceptions
import (
17 FritzConnectionException,
21 from fritzconnection.lib.fritzhosts
import FritzHosts
22 from fritzconnection.lib.fritzstatus
import FritzStatus
23 from fritzconnection.lib.fritzwlan
import DEFAULT_PASSWORD_LENGTH, FritzGuestWLAN
28 DEFAULT_CONSIDER_HOME,
29 DOMAIN
as DEVICE_TRACKER_DOMAIN,
43 DEFAULT_CONF_OLD_DISCOVERY,
49 SERVICE_SET_GUEST_WIFI_PW,
53 _LOGGER = logging.getLogger(__name__)
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)
64 current_devices: ValuesView,
66 """Check if device should be filtered out from trackers."""
67 reason: str |
None =
None
68 if device.ip_address ==
"":
71 reason =
"Already tracked"
75 "Skip adding device %s [%s], reason: %s", device.hostname, mac, reason
81 """Inform that HA is stopping."""
82 _LOGGER.warning(
"Cannot execute %s: HomeAssistant is shutting down", activity)
86 """Raised when a Class func is called before setup."""
89 """Init custom exception."""
90 super().
__init__(
"Function called before Class setup")
95 """FRITZ!Box device class."""
103 wan_access: bool |
None =
None
107 """Interface details."""
116 HostAttributes = TypedDict(
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,
147 """FRITZ!Box host info class."""
156 """Update coordinator data type."""
158 call_deflections: dict[int, dict]
159 entity_states: dict[str, StateType | bool]
163 """FritzBoxTools class."""
165 config_entry: ConfigEntry
172 username: str = DEFAULT_USERNAME,
173 host: str = DEFAULT_HOST,
174 use_tls: bool = DEFAULT_SSL,
176 """Initialize FritzboxTools class."""
180 name=f
"{DOMAIN}-{host}-coordinator",
184 self._devices: dict[str, FritzDevice] = {}
185 self.
_options_options: MappingProxyType[str, Any] |
None =
None
187 self.
connectionconnection: FritzConnection =
None
201 self.
_model_model: 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]
211 self, options: MappingProxyType[str, Any] |
None =
None
213 """Wrap up FritzboxTools class setup."""
215 await self.
hasshasshass.async_add_executor_job(self.
setupsetup)
218 """Set up FritzboxTools class."""
221 address=self.
hosthost,
231 _LOGGER.error(
"Unable to establish a connection with %s", self.
hosthost)
235 "detected services on %s %s",
246 "gathered device info of %s %s",
250 "NewDeviceLog":
"***omitted***",
251 "NewSerialNumber":
"***omitted***",
260 version_normalized := re.search(
r"^\d+\.[0]?(.*)", info.software_version)
267 self._update_available,
268 self._latest_firmware,
274 self.
fritz_statusfritz_status.get_default_connection_service().connection_service
281 self, key: str, update_fn: Callable[[FritzStatus, StateType], Any]
282 ) -> Callable[[],
None]:
283 """Register an entity to be updated by coordinator."""
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)
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
295 self.
datadata[
"entity_states"][
297 ] = await self.
hasshasshass.async_add_executor_job(
300 return unregister_entity_updates
303 """Run registered entity update calls."""
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(
314 """Update FritzboxTools data."""
315 entity_data: UpdateCoordinatorDataType = {
316 "call_deflections": {},
321 entity_data[
"entity_states"] = await self.
hasshasshass.async_add_executor_job(
328 except FRITZ_EXCEPTIONS
as ex:
330 translation_domain=DOMAIN,
331 translation_key=
"update_failed",
332 translation_placeholders={
"error":
str(ex)},
335 _LOGGER.debug(
"enity_data: %s", entity_data)
340 """Return unique id."""
342 raise ClassSetupMissing
347 """Return device model."""
349 raise ClassSetupMissing
354 """Return current SW version."""
356 raise ClassSetupMissing
361 """Return latest SW version."""
362 return self._latest_firmware
366 """Return if new SW version is available."""
367 return self._update_available
371 """Return the info URL for latest firmware."""
372 return self._release_url
376 """Return device Mac address."""
378 raise ClassSetupMissing
379 return dr.format_mac(self.
_unique_id_unique_id)
383 """Return devices."""
388 """Event specific per FRITZ!Box entry to signal new device."""
389 return f
"{DOMAIN}-device-new-{self._unique_id}"
393 """Event specific per FRITZ!Box entry to signal updates in devices."""
394 return f
"{DOMAIN}-device-update-{self._unique_id}"
397 """Get WAN access rule for given IP address."""
399 wan_access = await self.
hasshasshass.async_add_executor_job(
402 "X_AVM-DE_HostFilter:1",
404 NewIPv4Address=ip_address,
407 return not wan_access.get(
"NewDisallow")
408 except FRITZ_EXCEPTIONS
as ex:
411 "could not get WAN access rule for client device with IP '%s',"
420 """Retrieve latest hosts information from the FRITZ!Box."""
421 hosts_attributes: list[HostAttributes] = []
422 hosts_info: list[HostInfo] = []
425 hosts_attributes = await self.
hasshasshass.async_add_executor_job(
428 except FritzActionError:
429 hosts_info = await self.
hasshasshass.async_add_executor_job(
432 except Exception
as ex:
433 if not self.
hasshasshass.is_stopping:
435 translation_domain=DOMAIN,
436 translation_key=
"error_refresh_hosts_info",
439 hosts: dict[str, Device] = {}
441 for attributes
in hosts_attributes:
442 if not attributes.get(
"MACAddress"):
445 if (wan_access := attributes.get(
"X_AVM-DE_WANAccess"))
is not None:
446 wan_access_result =
"granted" in wan_access
448 wan_access_result =
None
450 hosts[attributes[
"MACAddress"]] =
Device(
451 name=attributes[
"HostName"],
452 connected=attributes[
"Active"],
455 ip_address=attributes[
"IPAddress"],
457 wan_access=wan_access_result,
460 for info
in hosts_info:
461 if not info.get(
"mac"):
467 wan_access_result =
None
469 hosts[info[
"mac"]] =
Device(
471 connected=info[
"status"],
474 ip_address=info[
"ip"],
476 wan_access=wan_access_result,
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
488 """Retrieve latest device information from the FRITZ!Box."""
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")
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):
505 return {
int(item[
"DeflectionId"]): item
for item
in items}
509 self, dev_info: Device, dev_mac: str, consider_home: bool
511 """Update device lists."""
512 _LOGGER.debug(
"Client dev_info: %s", dev_info)
514 if dev_mac
in self._devices:
515 self._devices[dev_mac].
update(dev_info, consider_home)
519 device.update(dev_info, consider_home)
520 self._devices[dev_mac] = device
524 """Signal device data updated."""
530 """Scan for new devices and return a list of found device ids."""
536 _LOGGER.debug(
"Checking host info for FRITZ!Box device %s", self.
hosthost)
538 self._update_available,
539 self._latest_firmware,
543 _LOGGER.debug(
"Checking devices for FRITZ!Box device %s", self.
hosthost)
544 _default_consider_home = DEFAULT_CONSIDER_HOME.total_seconds()
547 CONF_CONSIDER_HOME, _default_consider_home
550 consider_home = _default_consider_home
555 if not self.
fritz_statusfritz_status.device_has_mesh_support
or (
557 and self.
_options_options.
get(CONF_OLD_DISCOVERY, DEFAULT_CONF_OLD_DISCOVERY)
560 "Using old hosts discovery method. (Mesh not supported or user option)"
563 for mac, info
in hosts.items():
571 topology := await self.
hasshasshass.async_add_executor_job(
575 raise Exception(
"Mesh supported but empty topology reported")
576 except FritzActionError:
577 self.
mesh_rolemesh_role = MeshRoles.SLAVE
583 for node
in topology.get(
"nodes", []):
584 if not node[
"is_meshed"]:
587 for interf
in node[
"node_interfaces"]:
588 int_mac = interf[
"mac_address"]
590 device=node[
"device_name"],
592 op_mode=interf.get(
"op_mode",
""),
593 ssid=interf.get(
"ssid",
""),
596 if dr.format_mac(int_mac) == self.
macmac:
600 for node
in topology.get(
"nodes", []):
601 if node[
"is_meshed"]:
604 for interf
in node[
"node_interfaces"]:
605 dev_mac = interf[
"mac_address"]
607 if dev_mac
not in hosts:
610 dev_info: Device = hosts[dev_mac]
612 for link
in interf[
"node_links"]:
613 if link.get(
"state") !=
"CONNECTED":
616 intf = mesh_intf.get(link[
"node_interface_1_uid"])
618 if intf[
"op_mode"] ==
"AP_GUEST":
619 dev_info.wan_access =
None
621 dev_info.connected_to = intf[
"device"]
622 dev_info.connection_type = intf[
"type"]
623 dev_info.ssid = intf.get(
"ssid")
631 """Trigger firmware update."""
632 results = await self.
hasshasshass.async_add_executor_job(
633 self.
connectionconnection.call_action,
"UserInterface:1",
"X_AVM-DE_DoUpdate"
635 return cast(bool, results[
"NewX_AVM-DE_UpdateState"])
638 """Trigger device reboot."""
642 """Trigger device reconnect."""
646 self, password: str |
None, length: int
648 """Trigger service to set a new guest wifi password."""
649 await self.
hasshasshass.async_add_executor_job(
654 """Trigger device trackers cleanup."""
656 entity_reg: er.EntityRegistry = er.async_get(self.
hasshasshass)
659 entities: list[er.RegistryEntry] = er.async_entries_for_config_entry(
660 entity_reg, config_entry.entry_id
662 for entity
in entities:
663 entry_mac = entity.unique_id.split(
"_")[0]
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)
671 device_reg = dr.async_get(self.
hasshasshass)
672 valid_connections = {
673 (CONNECTION_NETWORK_MAC, dr.format_mac(mac))
for mac
in device_hosts
675 for device
in dr.async_entries_for_config_entry(
676 device_reg, config_entry.entry_id
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
685 self, service_call: ServiceCall, config_entry: ConfigEntry
687 """Define FRITZ!Box services."""
688 _LOGGER.debug(
"FRITZ!Box service: %s", service_call.service)
692 translation_domain=DOMAIN, translation_key=
"unable_to_connect"
696 if service_call.service == SERVICE_SET_GUEST_WIFI_PW:
698 service_call.data.get(
"password"),
699 service_call.data.get(
"length", DEFAULT_PASSWORD_LENGTH),
703 except (FritzServiceError, FritzActionError)
as ex:
705 translation_domain=DOMAIN, translation_key=
"service_parameter_unknown"
707 except FritzConnectionException
as ex:
709 translation_domain=DOMAIN, translation_key=
"service_not_supported"
714 """Setup AVM wrapper for API calls."""
723 """Return service details."""
729 if f
"{service_name}{service_suffix}" not in self.
connectionconnection.services:
733 result: dict = await self.
hasshasshass.async_add_executor_job(
736 f
"{service_name}:{service_suffix}",
741 except FritzSecurityError:
743 "Authorization Error: Please check the provided credentials and"
744 " verify that you can log into the web interface"
747 except FRITZ_EXCEPTIONS:
749 "Service/Action Error: cannot execute service %s with action %s",
754 except FritzConnectionException:
756 "Connection Error: Please check the device is properly configured"
763 """Call X_AVM-DE_UPnP service."""
768 """Call WANCommonInterfaceConfig service."""
771 "WANCommonInterfaceConfig",
773 "GetCommonLinkProperties",
777 """Check ip an ipv6 is active on the WAn interface."""
779 def wrap_external_ipv6() -> str:
785 return bool(await self.
hasshasshass.async_add_executor_job(wrap_external_ipv6))
788 """Return ConnectionInfo data."""
792 connection=link_properties.get(
"NewWANAccessType",
"").lower(),
798 "ConnectionInfo for FritzBox %s: %s",
802 return connection_info
805 """Call GetPortMappingNumberOfEntries action."""
808 con_type,
"1",
"GetPortMappingNumberOfEntries"
812 """Call GetGenericPortMappingEntry action."""
815 con_type,
"1",
"GetGenericPortMappingEntry", NewPortMappingIndex=index
819 """Call WLANConfiguration service."""
822 "WLANConfiguration",
str(index),
"GetInfo"
826 self, index: int, turn_on: bool
828 """Call SetEnable action from WLANConfiguration service."""
834 NewEnable=
"1" if turn_on
else "0",
838 self, index: int, turn_on: bool
840 """Call SetDeflectionEnable service."""
845 "SetDeflectionEnable",
846 NewDeflectionId=index,
847 NewEnable=
"1" if turn_on
else "0",
851 self, con_type: str, port_mapping: Any
853 """Call AddPortMapping service."""
863 self, ip_address: str, turn_on: bool
865 """Call X_AVM-DE_HostFilter service."""
868 "X_AVM-DE_HostFilter",
870 "DisallowWANAccessByIP",
871 NewIPv4Address=ip_address,
872 NewDisallow=
"0" if turn_on
else "1",
876 """Call X_AVM-DE_WakeOnLANByMACAddress service."""
881 "X_AVM-DE_WakeOnLANByMACAddress",
882 NewMACAddress=mac_address,
888 """Storage class for platform global data."""
890 tracked: dict = field(default_factory=dict)
891 profile_switches: dict = field(default_factory=dict)
892 wol_buttons: dict = field(default_factory=dict)
896 """Representation of a device connected to the FRITZ!Box."""
899 """Initialize device info."""
907 self.
_ssid_ssid: str |
None =
None
910 def update(self, dev_info: Device, consider_home: float) ->
None:
911 """Update device info."""
912 utc_point_in_time = dt_util.utcnow()
915 consider_home_evaluated = (
917 ).total_seconds() < consider_home
919 consider_home_evaluated = dev_info.connected
921 if not self.
_name_name:
922 self.
_name_name = dev_info.name
or self.
_mac_mac.replace(
":",
"_")
924 self.
_connected_connected = dev_info.connected
or consider_home_evaluated
926 if dev_info.connected:
937 """Return connected status."""
942 """Return connected status."""
947 """Return connected status."""
952 """Get MAC address."""
958 return self.
_name_name
962 """Get IP address."""
967 """Return device last activity."""
972 """Return device connected SSID."""
973 return self.
_ssid_ssid
977 """Return device wan access."""
982 """FRITZ!Box switch info class."""
988 callback_update: Callable
989 callback_switch: Callable
995 """Fritz sensor connection information class."""
bool async_ipv6_active(self)
dict _async_service_call(self, str service_name, str service_suffix, str action_name, **Any kwargs)
dict[str, Any] async_get_wan_link_properties(self)
dict[str, Any] async_add_port_mapping(self, str con_type, Any port_mapping)
dict[str, Any] async_set_allow_wan_access(self, str ip_address, bool turn_on)
dict[str, Any] async_get_port_mapping(self, str con_type, int index)
dict[str, Any] async_wake_on_lan(self, str mac_address)
dict[str, Any] async_set_wlan_configuration(self, int index, bool turn_on)
dict[str, Any] async_get_upnp_configuration(self)
dict[str, Any] async_get_num_port_mapping(self, str con_type)
dict[str, Any] async_set_deflection_enable(self, int index, bool turn_on)
dict[str, Any] async_get_wlan_configuration(self, int index)
ConnectionInfo async_get_connection_info(self)
None __init__(self, str mac, str name)
str|None connection_type(self)
None update(self, Device dev_info, float consider_home)
str|None connected_to(self)
str|None ip_address(self)
datetime|None last_activity(self)
bool|None wan_access(self)
web.Response get(self, web.Request request, str config_key)
bool _is_tracked(str mac, ValuesView current_devices)
None _ha_is_stopping(str activity)
bool device_filter_out_from_trackers(str mac, FritzDevice device, ValuesView current_devices)
IssData update(pyiss.ISS iss)
DeviceInfo get_device_info(str coordinates, str name)
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)