"""Open ports in your router for Home Assistant and provide statistics.""" from __future__ import annotations import asyncio from collections.abc import Mapping from dataclasses import dataclass from datetime import timedelta from ipaddress import ip_address from typing import Any from async_upnp_client.exceptions import UpnpConnectionError import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.components.binary_sensor import BinarySensorEntityDescription from homeassistant.components.sensor import SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) from .const import ( CONF_LOCAL_IP, CONFIG_ENTRY_HOSTNAME, CONFIG_ENTRY_SCAN_INTERVAL, CONFIG_ENTRY_ST, CONFIG_ENTRY_UDN, DEFAULT_SCAN_INTERVAL, DOMAIN, DOMAIN_DEVICES, LOGGER, ) from .device import Device NOTIFICATION_ID = "upnp_notification" NOTIFICATION_TITLE = "UPnP/IGD Setup" PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] CONFIG_SCHEMA = vol.Schema( vol.All( cv.deprecated(DOMAIN), { DOMAIN: vol.Schema( vol.All( cv.deprecated(CONF_LOCAL_IP), { vol.Optional(CONF_LOCAL_IP): vol.All(ip_address, cv.string), }, ) ) }, ), extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up UPnP component.""" hass.data[DOMAIN] = { DOMAIN_DEVICES: {}, } # Only start if set up via configuration.yaml. if DOMAIN in config: hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT} ) ) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up UPnP/IGD device from a config entry.""" LOGGER.debug("Setting up config entry: %s", entry.unique_id) udn = entry.data[CONFIG_ENTRY_UDN] st = entry.data[CONFIG_ENTRY_ST] # pylint: disable=invalid-name usn = f"{udn}::{st}" # Register device discovered-callback. device_discovered_event = asyncio.Event() discovery_info: ssdp.SsdpServiceInfo | None = None async def device_discovered( headers: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange ) -> None: if change == ssdp.SsdpChange.BYEBYE: return nonlocal discovery_info LOGGER.debug("Device discovered: %s, at: %s", usn, headers.ssdp_location) discovery_info = headers device_discovered_event.set() cancel_discovered_callback = await ssdp.async_register_callback( hass, device_discovered, { "usn": usn, }, ) try: await asyncio.wait_for(device_discovered_event.wait(), timeout=10) except asyncio.TimeoutError as err: LOGGER.debug("Device not discovered: %s", usn) raise ConfigEntryNotReady from err finally: cancel_discovered_callback() # Create device. location = discovery_info.ssdp_location try: device = await Device.async_create_device(hass, location) except UpnpConnectionError as err: LOGGER.debug("Error connecting to device %s", location) raise ConfigEntryNotReady from err # Ensure entry has a unique_id. if not entry.unique_id: LOGGER.debug( "Setting unique_id: %s, for config_entry: %s", device.unique_id, entry, ) hass.config_entries.async_update_entry( entry=entry, unique_id=device.unique_id, ) # Ensure entry has a hostname, for older entries. if ( CONFIG_ENTRY_HOSTNAME not in entry.data or entry.data[CONFIG_ENTRY_HOSTNAME] != device.hostname ): hass.config_entries.async_update_entry( entry=entry, data={CONFIG_ENTRY_HOSTNAME: device.hostname, **entry.data}, ) # Create device registry entry. device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, connections={(dr.CONNECTION_UPNP, device.udn)}, identifiers={(DOMAIN, device.udn)}, name=device.name, manufacturer=device.manufacturer, model=device.model_name, ) update_interval_sec = entry.options.get( CONFIG_ENTRY_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ) update_interval = timedelta(seconds=update_interval_sec) LOGGER.debug("update_interval: %s", update_interval) coordinator = UpnpDataUpdateCoordinator( hass, device=device, update_interval=update_interval, ) # Save coordinator. hass.data[DOMAIN][entry.entry_id] = coordinator await coordinator.async_config_entry_first_refresh() # Create sensors. LOGGER.debug("Enabling sensors") hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a UPnP/IGD device from a config entry.""" LOGGER.debug("Unloading config entry: %s", config_entry.unique_id) LOGGER.debug("Deleting sensors") return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) @dataclass class UpnpBinarySensorEntityDescription(BinarySensorEntityDescription): """A class that describes UPnP entities.""" format: str = "s" unique_id: str | None = None @dataclass class UpnpSensorEntityDescription(SensorEntityDescription): """A class that describes a sensor UPnP entities.""" format: str = "s" unique_id: str | None = None class UpnpDataUpdateCoordinator(DataUpdateCoordinator): """Define an object to update data from UPNP device.""" def __init__( self, hass: HomeAssistant, device: Device, update_interval: timedelta ) -> None: """Initialize.""" self.device = device super().__init__( hass, LOGGER, name=device.name, update_interval=update_interval ) async def _async_update_data(self) -> Mapping[str, Any]: """Update data.""" update_values = await asyncio.gather( self.device.async_get_traffic_data(), self.device.async_get_status(), ) return { **update_values[0], **update_values[1], } class UpnpEntity(CoordinatorEntity): """Base class for UPnP/IGD entities.""" coordinator: UpnpDataUpdateCoordinator entity_description: UpnpSensorEntityDescription | UpnpBinarySensorEntityDescription def __init__( self, coordinator: UpnpDataUpdateCoordinator, entity_description: UpnpSensorEntityDescription | UpnpBinarySensorEntityDescription, ) -> None: """Initialize the base entities.""" super().__init__(coordinator) self._device = coordinator.device self.entity_description = entity_description self._attr_name = f"{coordinator.device.name} {entity_description.name}" self._attr_unique_id = f"{coordinator.device.udn}_{entity_description.unique_id or entity_description.key}" self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_UPNP, coordinator.device.udn)}, name=coordinator.device.name, manufacturer=coordinator.device.manufacturer, model=coordinator.device.model_name, configuration_url=f"http://{coordinator.device.hostname}", ) @property def available(self) -> bool: """Return if entity is available.""" return super().available and ( self.coordinator.data.get(self.entity_description.key) is not None )