Home Assistant Unofficial Reference 2024.12.1
device.py
Go to the documentation of this file.
1 """Home Assistant representation of an UPnP/IGD."""
2 
3 from __future__ import annotations
4 
5 from datetime import datetime
6 from functools import partial
7 from ipaddress import ip_address
8 from typing import Any
9 from urllib.parse import urlparse
10 
11 from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester
12 from async_upnp_client.client_factory import UpnpFactory
13 from async_upnp_client.const import AddressTupleVXType
14 from async_upnp_client.exceptions import UpnpCommunicationError
15 from async_upnp_client.profiles.igd import IgdDevice, IgdStateItem
16 from async_upnp_client.utils import async_get_local_ip
17 from getmac import get_mac_address
18 
19 from homeassistant.core import HomeAssistant
20 from homeassistant.helpers.aiohttp_client import async_get_clientsession
21 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
22 
23 from .const import (
24  BYTES_RECEIVED,
25  BYTES_SENT,
26  KIBIBYTES_PER_SEC_RECEIVED,
27  KIBIBYTES_PER_SEC_SENT,
28  LOGGER as _LOGGER,
29  PACKETS_PER_SEC_RECEIVED,
30  PACKETS_PER_SEC_SENT,
31  PACKETS_RECEIVED,
32  PACKETS_SENT,
33  PORT_MAPPING_NUMBER_OF_ENTRIES_IPV4,
34  ROUTER_IP,
35  ROUTER_UPTIME,
36  TIMESTAMP,
37  WAN_STATUS,
38 )
39 
40 TYPE_STATE_ITEM_MAPPING = {
41  BYTES_RECEIVED: IgdStateItem.BYTES_RECEIVED,
42  BYTES_SENT: IgdStateItem.BYTES_SENT,
43  KIBIBYTES_PER_SEC_RECEIVED: IgdStateItem.KIBIBYTES_PER_SEC_RECEIVED,
44  KIBIBYTES_PER_SEC_SENT: IgdStateItem.KIBIBYTES_PER_SEC_SENT,
45  PACKETS_PER_SEC_RECEIVED: IgdStateItem.PACKETS_PER_SEC_RECEIVED,
46  PACKETS_PER_SEC_SENT: IgdStateItem.PACKETS_PER_SEC_SENT,
47  PACKETS_RECEIVED: IgdStateItem.PACKETS_RECEIVED,
48  PACKETS_SENT: IgdStateItem.PACKETS_SENT,
49  ROUTER_IP: IgdStateItem.EXTERNAL_IP_ADDRESS,
50  ROUTER_UPTIME: IgdStateItem.UPTIME,
51  WAN_STATUS: IgdStateItem.CONNECTION_STATUS,
52  PORT_MAPPING_NUMBER_OF_ENTRIES_IPV4: IgdStateItem.PORT_MAPPING_NUMBER_OF_ENTRIES,
53 }
54 
55 
56 def get_preferred_location(locations: set[str]) -> str:
57  """Get the preferred location (an IPv4 location) from a set of locations."""
58  # Prefer IPv4 over IPv6.
59  for location in locations:
60  if location.startswith(("http://[", "https://[")):
61  continue
62 
63  return location
64 
65  # Fallback to any.
66  for location in locations:
67  return location
68 
69  raise ValueError("No location found")
70 
71 
72 async def async_get_mac_address_from_host(hass: HomeAssistant, host: str) -> str | None:
73  """Get mac address from host."""
74  ip_addr = ip_address(host)
75  if ip_addr.version == 4:
76  mac_address = await hass.async_add_executor_job(
77  partial(get_mac_address, ip=host)
78  )
79  else:
80  mac_address = await hass.async_add_executor_job(
81  partial(get_mac_address, ip6=host)
82  )
83  return mac_address
84 
85 
87  hass: HomeAssistant, location: str, force_poll: bool
88 ) -> Device:
89  """Create UPnP/IGD device."""
90  session = async_get_clientsession(hass, verify_ssl=False)
91  requester = AiohttpSessionRequester(session, with_sleep=True, timeout=20)
92 
93  # Create UPnP device.
94  factory = UpnpFactory(requester, non_strict=True)
95  upnp_device = await factory.async_create_device(location)
96 
97  # Create notify server.
98  _, local_ip = await async_get_local_ip(location)
99  source: AddressTupleVXType = (local_ip, 0)
100  notify_server = AiohttpNotifyServer(
101  requester=requester,
102  source=source,
103  )
104  await notify_server.async_start_server()
105  _LOGGER.debug("Started event handler at %s", notify_server.callback_url)
106 
107  # Create profile wrapper.
108  igd_device = IgdDevice(upnp_device, notify_server.event_handler)
109  return Device(hass, igd_device, force_poll)
110 
111 
112 class Device:
113  """Home Assistant representation of a UPnP/IGD device."""
114 
115  def __init__(
116  self, hass: HomeAssistant, igd_device: IgdDevice, force_poll: bool
117  ) -> None:
118  """Initialize UPnP/IGD device."""
119  self.hasshass = hass
120  self._igd_device_igd_device = igd_device
121  self._force_poll_force_poll = force_poll
122 
123  self.coordinator: (
124  DataUpdateCoordinator[dict[str, str | datetime | int | float | None]] | None
125  ) = None
126  self.original_udn: str | None = None
127 
128  async def async_get_mac_address(self) -> str | None:
129  """Get mac address."""
130  if not self.hosthost:
131  return None
132 
133  return await async_get_mac_address_from_host(self.hasshass, self.hosthost)
134 
135  @property
136  def udn(self) -> str:
137  """Get the UDN."""
138  return self._igd_device_igd_device.udn
139 
140  @property
141  def name(self) -> str:
142  """Get the name."""
143  return self._igd_device_igd_device.name
144 
145  @property
146  def manufacturer(self) -> str:
147  """Get the manufacturer."""
148  return self._igd_device_igd_device.manufacturer
149 
150  @property
151  def model_name(self) -> str:
152  """Get the model name."""
153  return self._igd_device_igd_device.model_name
154 
155  @property
156  def device_type(self) -> str:
157  """Get the device type."""
158  return self._igd_device_igd_device.device_type
159 
160  @property
161  def usn(self) -> str:
162  """Get the USN."""
163  return f"{self.udn}::{self.device_type}"
164 
165  @property
166  def unique_id(self) -> str:
167  """Get the unique id."""
168  return self.usnusn
169 
170  @property
171  def host(self) -> str | None:
172  """Get the hostname."""
173  parsed = urlparse(self.device_urldevice_url)
174  return parsed.hostname
175 
176  @property
177  def device_url(self) -> str:
178  """Get the device_url of the device."""
179  return self._igd_device_igd_device.device.device_url
180 
181  @property
182  def serial_number(self) -> str | None:
183  """Get the serial number."""
184  return self._igd_device_igd_device.device.serial_number
185 
186  def __str__(self) -> str:
187  """Get string representation."""
188  return f"IGD Device: {self.name}/{self.udn}::{self.device_type}"
189 
190  @property
191  def force_poll(self) -> bool:
192  """Get force_poll."""
193  return self._force_poll_force_poll
194 
195  async def async_set_force_poll(self, force_poll: bool) -> None:
196  """Set force_poll, and (un)subscribe if needed."""
197  self._force_poll_force_poll = force_poll
198 
199  if self._force_poll_force_poll:
200  # No need for subscriptions, as eventing will never be used.
201  await self.async_unsubscribe_servicesasync_unsubscribe_services()
202  elif not self._force_poll_force_poll and not self._igd_device_igd_device.is_subscribed:
203  await self.async_subscribe_servicesasync_subscribe_services()
204 
205  async def async_subscribe_services(self) -> None:
206  """Subscribe to services."""
207  try:
208  await self._igd_device_igd_device.async_subscribe_services(auto_resubscribe=True)
209  except UpnpCommunicationError as ex:
210  _LOGGER.debug(
211  "Error subscribing to services, falling back to forced polling: %s", ex
212  )
213  await self.async_set_force_pollasync_set_force_poll(True)
214 
215  async def async_unsubscribe_services(self) -> None:
216  """Unsubscribe from services."""
217  try:
218  await self._igd_device_igd_device.async_unsubscribe_services()
219  except UpnpCommunicationError as ex:
220  _LOGGER.debug("Error unsubscribing to services: %s", ex)
221 
222  async def async_get_data(
223  self, entity_description_keys: list[str] | None
224  ) -> dict[str, str | datetime | int | float | None]:
225  """Get all data from device."""
226  if not entity_description_keys:
227  igd_state_items = None
228  else:
229  igd_state_items = {
230  TYPE_STATE_ITEM_MAPPING[key] for key in entity_description_keys
231  }
232 
233  _LOGGER.debug(
234  "Getting data for device: %s, state_items: %s, force_poll: %s",
235  self,
236  igd_state_items,
237  self._force_poll_force_poll,
238  )
239  igd_state = await self._igd_device_igd_device.async_get_traffic_and_status_data(
240  igd_state_items, force_poll=self._force_poll_force_poll
241  )
242 
243  def get_value(value: Any) -> Any:
244  if value is None or isinstance(value, BaseException):
245  return None
246 
247  return value
248 
249  return {
250  TIMESTAMP: igd_state.timestamp,
251  BYTES_RECEIVED: get_value(igd_state.bytes_received),
252  BYTES_SENT: get_value(igd_state.bytes_sent),
253  PACKETS_RECEIVED: get_value(igd_state.packets_received),
254  PACKETS_SENT: get_value(igd_state.packets_sent),
255  WAN_STATUS: get_value(igd_state.connection_status),
256  ROUTER_UPTIME: get_value(igd_state.uptime),
257  ROUTER_IP: get_value(igd_state.external_ip_address),
258  KIBIBYTES_PER_SEC_RECEIVED: igd_state.kibibytes_per_sec_received,
259  KIBIBYTES_PER_SEC_SENT: igd_state.kibibytes_per_sec_sent,
260  PACKETS_PER_SEC_RECEIVED: igd_state.packets_per_sec_received,
261  PACKETS_PER_SEC_SENT: igd_state.packets_per_sec_sent,
262  PORT_MAPPING_NUMBER_OF_ENTRIES_IPV4: get_value(
263  igd_state.port_mapping_number_of_entries
264  ),
265  }
None async_set_force_poll(self, bool force_poll)
Definition: device.py:195
dict[str, str|datetime|int|float|None] async_get_data(self, list[str]|None entity_description_keys)
Definition: device.py:224
None __init__(self, HomeAssistant hass, IgdDevice igd_device, bool force_poll)
Definition: device.py:117
float|int|str|None get_value(Sensor sensor, str field)
Definition: sensor.py:46
str get_preferred_location(set[str] locations)
Definition: device.py:56
str|None async_get_mac_address_from_host(HomeAssistant hass, str host)
Definition: device.py:72
Device async_create_device(HomeAssistant hass, str location, bool force_poll)
Definition: device.py:88
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)