core/homeassistant/components/upnp/device.py

263 lines
8.6 KiB
Python

"""Home Assistant representation of an UPnP/IGD."""
from __future__ import annotations
from datetime import datetime
from functools import partial
from ipaddress import ip_address
from typing import Any
from urllib.parse import urlparse
from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester
from async_upnp_client.client_factory import UpnpFactory
from async_upnp_client.const import AddressTupleVXType
from async_upnp_client.exceptions import UpnpConnectionError
from async_upnp_client.profiles.igd import IgdDevice, IgdStateItem
from async_upnp_client.utils import async_get_local_ip
from getmac import get_mac_address
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
BYTES_RECEIVED,
BYTES_SENT,
KIBIBYTES_PER_SEC_RECEIVED,
KIBIBYTES_PER_SEC_SENT,
LOGGER as _LOGGER,
PACKETS_PER_SEC_RECEIVED,
PACKETS_PER_SEC_SENT,
PACKETS_RECEIVED,
PACKETS_SENT,
PORT_MAPPING_NUMBER_OF_ENTRIES_IPV4,
ROUTER_IP,
ROUTER_UPTIME,
TIMESTAMP,
WAN_STATUS,
)
TYPE_STATE_ITEM_MAPPING = {
BYTES_RECEIVED: IgdStateItem.BYTES_RECEIVED,
BYTES_SENT: IgdStateItem.BYTES_SENT,
KIBIBYTES_PER_SEC_RECEIVED: IgdStateItem.KIBIBYTES_PER_SEC_RECEIVED,
KIBIBYTES_PER_SEC_SENT: IgdStateItem.KIBIBYTES_PER_SEC_SENT,
PACKETS_PER_SEC_RECEIVED: IgdStateItem.PACKETS_PER_SEC_RECEIVED,
PACKETS_PER_SEC_SENT: IgdStateItem.PACKETS_PER_SEC_SENT,
PACKETS_RECEIVED: IgdStateItem.PACKETS_RECEIVED,
PACKETS_SENT: IgdStateItem.PACKETS_SENT,
ROUTER_IP: IgdStateItem.EXTERNAL_IP_ADDRESS,
ROUTER_UPTIME: IgdStateItem.UPTIME,
WAN_STATUS: IgdStateItem.CONNECTION_STATUS,
PORT_MAPPING_NUMBER_OF_ENTRIES_IPV4: IgdStateItem.PORT_MAPPING_NUMBER_OF_ENTRIES,
}
def get_preferred_location(locations: set[str]) -> str:
"""Get the preferred location (an IPv4 location) from a set of locations."""
# Prefer IPv4 over IPv6.
for location in locations:
if location.startswith(("http://[", "https://[")):
continue
return location
# Fallback to any.
for location in locations:
return location
raise ValueError("No location found")
async def async_get_mac_address_from_host(hass: HomeAssistant, host: str) -> str | None:
"""Get mac address from host."""
ip_addr = ip_address(host)
if ip_addr.version == 4:
mac_address = await hass.async_add_executor_job(
partial(get_mac_address, ip=host)
)
else:
mac_address = await hass.async_add_executor_job(
partial(get_mac_address, ip6=host)
)
return mac_address
async def async_create_device(
hass: HomeAssistant, location: str, force_poll: bool
) -> Device:
"""Create UPnP/IGD device."""
session = async_get_clientsession(hass, verify_ssl=False)
requester = AiohttpSessionRequester(session, with_sleep=True, timeout=20)
# Create UPnP device.
factory = UpnpFactory(requester, non_strict=True)
upnp_device = await factory.async_create_device(location)
# Create notify server.
_, local_ip = await async_get_local_ip(location)
source: AddressTupleVXType = (local_ip, 0)
notify_server = AiohttpNotifyServer(
requester=requester,
source=source,
)
await notify_server.async_start_server()
_LOGGER.debug("Started event handler at %s", notify_server.callback_url)
# Create profile wrapper.
igd_device = IgdDevice(upnp_device, notify_server.event_handler)
return Device(hass, igd_device, force_poll)
class Device:
"""Home Assistant representation of a UPnP/IGD device."""
def __init__(
self, hass: HomeAssistant, igd_device: IgdDevice, force_poll: bool
) -> None:
"""Initialize UPnP/IGD device."""
self.hass = hass
self._igd_device = igd_device
self._force_poll = force_poll
self.coordinator: (
DataUpdateCoordinator[dict[str, str | datetime | int | float | None]] | None
) = None
self.original_udn: str | None = None
async def async_get_mac_address(self) -> str | None:
"""Get mac address."""
if not self.host:
return None
return await async_get_mac_address_from_host(self.hass, self.host)
@property
def udn(self) -> str:
"""Get the UDN."""
return self._igd_device.udn
@property
def name(self) -> str:
"""Get the name."""
return self._igd_device.name
@property
def manufacturer(self) -> str:
"""Get the manufacturer."""
return self._igd_device.manufacturer
@property
def model_name(self) -> str:
"""Get the model name."""
return self._igd_device.model_name
@property
def device_type(self) -> str:
"""Get the device type."""
return self._igd_device.device_type
@property
def usn(self) -> str:
"""Get the USN."""
return f"{self.udn}::{self.device_type}"
@property
def unique_id(self) -> str:
"""Get the unique id."""
return self.usn
@property
def host(self) -> str | None:
"""Get the hostname."""
parsed = urlparse(self.device_url)
return parsed.hostname
@property
def device_url(self) -> str:
"""Get the device_url of the device."""
return self._igd_device.device.device_url
@property
def serial_number(self) -> str | None:
"""Get the serial number."""
return self._igd_device.device.serial_number
def __str__(self) -> str:
"""Get string representation."""
return f"IGD Device: {self.name}/{self.udn}::{self.device_type}"
@property
def force_poll(self) -> bool:
"""Get force_poll."""
return self._force_poll
async def async_set_force_poll(self, force_poll: bool) -> None:
"""Set force_poll, and (un)subscribe if needed."""
self._force_poll = force_poll
if self._force_poll:
# No need for subscriptions, as eventing will never be used.
await self.async_unsubscribe_services()
elif not self._force_poll and not self._igd_device.is_subscribed:
await self.async_subscribe_services()
async def async_subscribe_services(self) -> None:
"""Subscribe to services."""
try:
await self._igd_device.async_subscribe_services(auto_resubscribe=True)
except UpnpConnectionError as ex:
_LOGGER.debug(
"Error subscribing to services, falling back to forced polling: %s", ex
)
await self.async_set_force_poll(True)
async def async_unsubscribe_services(self) -> None:
"""Unsubscribe from services."""
await self._igd_device.async_unsubscribe_services()
async def async_get_data(
self, entity_description_keys: list[str] | None
) -> dict[str, str | datetime | int | float | None]:
"""Get all data from device."""
if not entity_description_keys:
igd_state_items = None
else:
igd_state_items = {
TYPE_STATE_ITEM_MAPPING[key] for key in entity_description_keys
}
_LOGGER.debug(
"Getting data for device: %s, state_items: %s, force_poll: %s",
self,
igd_state_items,
self._force_poll,
)
igd_state = await self._igd_device.async_get_traffic_and_status_data(
igd_state_items, force_poll=self._force_poll
)
def get_value(value: Any) -> Any:
if value is None or isinstance(value, BaseException):
return None
return value
return {
TIMESTAMP: igd_state.timestamp,
BYTES_RECEIVED: get_value(igd_state.bytes_received),
BYTES_SENT: get_value(igd_state.bytes_sent),
PACKETS_RECEIVED: get_value(igd_state.packets_received),
PACKETS_SENT: get_value(igd_state.packets_sent),
WAN_STATUS: get_value(igd_state.connection_status),
ROUTER_UPTIME: get_value(igd_state.uptime),
ROUTER_IP: get_value(igd_state.external_ip_address),
KIBIBYTES_PER_SEC_RECEIVED: igd_state.kibibytes_per_sec_received,
KIBIBYTES_PER_SEC_SENT: igd_state.kibibytes_per_sec_sent,
PACKETS_PER_SEC_RECEIVED: igd_state.packets_per_sec_received,
PACKETS_PER_SEC_SENT: igd_state.packets_per_sec_sent,
PORT_MAPPING_NUMBER_OF_ENTRIES_IPV4: get_value(
igd_state.port_mapping_number_of_entries
),
}