Merge pull request #16300 from StevenLooman/igd

Adds discovery and config flow
Breaking change: no longer possible to map ports other than the port used by Home Assistant
This commit is contained in:
Diogo Gomes 2018-10-03 22:55:01 +01:00 committed by GitHub
commit cf0147098a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1210 additions and 382 deletions

View file

@ -695,6 +695,7 @@ omit =
homeassistant/components/sensor/haveibeenpwned.py
homeassistant/components/sensor/hp_ilo.py
homeassistant/components/sensor/htu21d.py
homeassistant/components/sensor/upnp.py
homeassistant/components/sensor/imap_email_content.py
homeassistant/components/sensor/imap.py
homeassistant/components/sensor/influxdb.py
@ -781,7 +782,6 @@ omit =
homeassistant/components/sensor/travisci.py
homeassistant/components/sensor/twitch.py
homeassistant/components/sensor/uber.py
homeassistant/components/sensor/upnp.py
homeassistant/components/sensor/ups.py
homeassistant/components/sensor/uscis.py
homeassistant/components/sensor/vasttrafik.py

View file

@ -82,7 +82,6 @@ homeassistant/components/sensor/sma.py @kellerza
homeassistant/components/sensor/sql.py @dgomes
homeassistant/components/sensor/sytadin.py @gautric
homeassistant/components/sensor/tibber.py @danielhiversen
homeassistant/components/sensor/upnp.py @dgomes
homeassistant/components/sensor/waqi.py @andrey-git
homeassistant/components/switch/tplink.py @rytilahti
homeassistant/components/vacuum/roomba.py @pschmitt

View file

@ -50,6 +50,7 @@ CONFIG_ENTRY_HANDLERS = {
SERVICE_HUE: 'hue',
SERVICE_IKEA_TRADFRI: 'tradfri',
'sonos': 'sonos',
'igd': 'upnp',
}
SERVICE_HANDLERS = {

View file

@ -1,87 +1,268 @@
"""
Support for UPnP Sensors (IGD).
Support for UPnP/IGD Sensors.
For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/sensor.upnp/
"""
from datetime import datetime
import logging
from homeassistant.components.upnp import DATA_UPNP, UNITS, CIC_SERVICE
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.components.upnp.const import DOMAIN as DATA_UPNP
from homeassistant.components.upnp.const import SIGNAL_REMOVE_SENSOR
_LOGGER = logging.getLogger(__name__)
DEPENDENCIES = ['upnp']
BYTES_RECEIVED = 1
BYTES_SENT = 2
PACKETS_RECEIVED = 3
PACKETS_SENT = 4
BYTES_RECEIVED = 'bytes_received'
BYTES_SENT = 'bytes_sent'
PACKETS_RECEIVED = 'packets_received'
PACKETS_SENT = 'packets_sent'
# sensor_type: [friendly_name, convert_unit, icon]
SENSOR_TYPES = {
BYTES_RECEIVED: ['received bytes', True, 'mdi:server-network'],
BYTES_SENT: ['sent bytes', True, 'mdi:server-network'],
PACKETS_RECEIVED: ['packets received', False, 'mdi:server-network'],
PACKETS_SENT: ['packets sent', False, 'mdi:server-network'],
BYTES_RECEIVED: {
'name': 'bytes received',
'unit': 'bytes',
},
BYTES_SENT: {
'name': 'bytes sent',
'unit': 'bytes',
},
PACKETS_RECEIVED: {
'name': 'packets received',
'unit': 'packets',
},
PACKETS_SENT: {
'name': 'packets sent',
'unit': 'packets',
},
}
IN = 'received'
OUT = 'sent'
KBYTE = 1024
async def async_setup_platform(hass, config, async_add_entities,
discovery_info=None):
"""Set up the IGD sensors."""
if discovery_info is None:
return
device = hass.data[DATA_UPNP]
service = device.find_first_service(CIC_SERVICE)
unit = discovery_info['unit']
async_add_entities([
IGDSensor(service, t, unit if SENSOR_TYPES[t][1] else '#')
for t in SENSOR_TYPES], True)
"""Old way of setting up UPnP/IGD sensors."""
_LOGGER.debug('async_setup_platform: config: %s, discovery: %s',
config, discovery_info)
class IGDSensor(Entity):
"""Representation of a UPnP IGD sensor."""
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the UPnP/IGD sensor."""
@callback
def async_add_sensor(device):
"""Add sensors from UPnP/IGD device."""
# raw sensors + per-second sensors
sensors = [
RawUPnPIGDSensor(device, name, sensor_type)
for name, sensor_type in SENSOR_TYPES.items()
]
sensors += [
KBytePerSecondUPnPIGDSensor(device, IN),
KBytePerSecondUPnPIGDSensor(device, OUT),
PacketsPerSecondUPnPIGDSensor(device, IN),
PacketsPerSecondUPnPIGDSensor(device, OUT),
]
async_add_entities(sensors, True)
def __init__(self, service, sensor_type, unit=None):
"""Initialize the IGD sensor."""
self._service = service
self.type = sensor_type
self.unit = unit
self.unit_factor = UNITS[unit] if unit in UNITS else 1
self._name = 'IGD {}'.format(SENSOR_TYPES[sensor_type][0])
data = config_entry.data
udn = data['udn']
device = hass.data[DATA_UPNP]['devices'][udn]
async_add_sensor(device)
class UpnpSensor(Entity):
"""Base class for UPnP/IGD sensors."""
def __init__(self, device):
"""Initialize the base sensor."""
self._device = device
async def async_added_to_hass(self):
"""Subscribe to sensors events."""
async_dispatcher_connect(self.hass,
SIGNAL_REMOVE_SENSOR,
self._upnp_remove_sensor)
@callback
def _upnp_remove_sensor(self, device):
"""Remove sensor."""
if self._device != device:
# not for us
return
self.hass.async_create_task(self.async_remove())
class RawUPnPIGDSensor(UpnpSensor):
"""Representation of a UPnP/IGD sensor."""
def __init__(self, device, sensor_type_name, sensor_type):
"""Initialize the UPnP/IGD sensor."""
super().__init__(device)
self._type_name = sensor_type_name
self._type = sensor_type
self._name = '{} {}'.format(device.name, sensor_type['name'])
self._state = None
@property
def name(self):
def name(self) -> str:
"""Return the name of the sensor."""
return self._name
@property
def state(self):
def unique_id(self) -> str:
"""Return an unique ID."""
return '{}_{}'.format(self._device.udn, self._type_name)
@property
def state(self) -> str:
"""Return the state of the device."""
if self._state:
return format(float(self._state) / self.unit_factor, '.1f')
return self._state
return format(self._state, 'd')
@property
def icon(self):
def icon(self) -> str:
"""Icon to use in the frontend, if any."""
return SENSOR_TYPES[self.type][2]
return 'mdi:server-network'
@property
def unit_of_measurement(self):
def unit_of_measurement(self) -> str:
"""Return the unit of measurement of this entity, if any."""
return self.unit
return self._type['unit']
async def async_update(self):
"""Get the latest information from the IGD."""
if self.type == BYTES_RECEIVED:
self._state = await self._service.get_total_bytes_received()
elif self.type == BYTES_SENT:
self._state = await self._service.get_total_bytes_sent()
elif self.type == PACKETS_RECEIVED:
self._state = await self._service.get_total_packets_received()
elif self.type == PACKETS_SENT:
self._state = await self._service.get_total_packets_sent()
if self._type_name == BYTES_RECEIVED:
self._state = await self._device.async_get_total_bytes_received()
elif self._type_name == BYTES_SENT:
self._state = await self._device.async_get_total_bytes_sent()
elif self._type_name == PACKETS_RECEIVED:
self._state = await self._device.async_get_total_packets_received()
elif self._type_name == PACKETS_SENT:
self._state = await self._device.async_get_total_packets_sent()
class PerSecondUPnPIGDSensor(UpnpSensor):
"""Abstract representation of a X Sent/Received per second sensor."""
def __init__(self, device, direction):
"""Initializer."""
super().__init__(device)
self._direction = direction
self._state = None
self._last_value = None
self._last_update_time = None
@property
def unit(self) -> str:
"""Get unit we are measuring in."""
raise NotImplementedError()
@property
def _async_fetch_value(self):
"""Fetch a value from the IGD."""
raise NotImplementedError()
@property
def unique_id(self) -> str:
"""Return an unique ID."""
return '{}_{}/sec_{}'.format(self._device.udn,
self.unit,
self._direction)
@property
def name(self) -> str:
"""Return the name of the sensor."""
return '{} {}/sec {}'.format(self._device.name,
self.unit,
self._direction)
@property
def icon(self) -> str:
"""Icon to use in the frontend, if any."""
return 'mdi:server-network'
@property
def unit_of_measurement(self) -> str:
"""Return the unit of measurement of this entity, if any."""
return '{}/sec'.format(self.unit)
def _is_overflowed(self, new_value) -> bool:
"""Check if value has overflowed."""
return new_value < self._last_value
async def async_update(self):
"""Get the latest information from the UPnP/IGD."""
new_value = await self._async_fetch_value()
if self._last_value is None:
self._last_value = new_value
self._last_update_time = datetime.now()
return
now = datetime.now()
if self._is_overflowed(new_value):
self._state = None # temporarily report nothing
else:
delta_time = (now - self._last_update_time).seconds
delta_value = new_value - self._last_value
self._state = (delta_value / delta_time)
self._last_value = new_value
self._last_update_time = now
class KBytePerSecondUPnPIGDSensor(PerSecondUPnPIGDSensor):
"""Representation of a KBytes Sent/Received per second sensor."""
@property
def unit(self) -> str:
"""Get unit we are measuring in."""
return 'kbyte'
async def _async_fetch_value(self) -> float:
"""Fetch value from device."""
if self._direction == IN:
return await self._device.async_get_total_bytes_received()
return await self._device.async_get_total_bytes_sent()
@property
def state(self) -> str:
"""Return the state of the device."""
if self._state is None:
return None
return format(float(self._state / KBYTE), '.1f')
class PacketsPerSecondUPnPIGDSensor(PerSecondUPnPIGDSensor):
"""Representation of a Packets Sent/Received per second sensor."""
@property
def unit(self) -> str:
"""Get unit we are measuring in."""
return 'packets'
async def _async_fetch_value(self) -> float:
"""Fetch value from device."""
if self._direction == IN:
return await self._device.async_get_total_packets_received()
return await self._device.async_get_total_packets_sent()
@property
def state(self) -> str:
"""Return the state of the device."""
if self._state is None:
return None
return format(float(self._state), '.1f')

View file

@ -1,144 +0,0 @@
"""
Will open a port in your router for Home Assistant and provide statistics.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/upnp/
"""
from ipaddress import ip_address
import logging
import asyncio
import voluptuous as vol
from homeassistant.const import (EVENT_HOMEASSISTANT_STOP)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import discovery
from homeassistant.util import get_local_ip
REQUIREMENTS = ['pyupnp-async==0.1.1.1']
DEPENDENCIES = ['http']
_LOGGER = logging.getLogger(__name__)
DEPENDENCIES = ['api']
DOMAIN = 'upnp'
DATA_UPNP = 'upnp_device'
CONF_LOCAL_IP = 'local_ip'
CONF_ENABLE_PORT_MAPPING = 'port_mapping'
CONF_PORTS = 'ports'
CONF_UNITS = 'unit'
CONF_HASS = 'hass'
NOTIFICATION_ID = 'upnp_notification'
NOTIFICATION_TITLE = 'UPnP Setup'
IGD_DEVICE = 'urn:schemas-upnp-org:device:InternetGatewayDevice:1'
PPP_SERVICE = 'urn:schemas-upnp-org:service:WANPPPConnection:1'
IP_SERVICE = 'urn:schemas-upnp-org:service:WANIPConnection:1'
IP_SERVICE2 = 'urn:schemas-upnp-org:service:WANIPConnection:2'
CIC_SERVICE = 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1'
UNITS = {
"Bytes": 1,
"KBytes": 1024,
"MBytes": 1024**2,
"GBytes": 1024**3,
}
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Optional(CONF_ENABLE_PORT_MAPPING, default=False): cv.boolean,
vol.Optional(CONF_UNITS, default="MBytes"): vol.In(UNITS),
vol.Optional(CONF_LOCAL_IP): vol.All(ip_address, cv.string),
vol.Optional(CONF_PORTS):
vol.Schema({vol.Any(CONF_HASS, cv.positive_int): cv.positive_int})
}),
}, extra=vol.ALLOW_EXTRA)
async def async_setup(hass, config):
"""Register a port mapping for Home Assistant via UPnP."""
config = config[DOMAIN]
host = config.get(CONF_LOCAL_IP)
if host is None:
host = get_local_ip()
if host == '127.0.0.1':
_LOGGER.error(
'Unable to determine local IP. Add it to your configuration.')
return False
import pyupnp_async
from pyupnp_async.error import UpnpSoapError
service = None
resp = await pyupnp_async.msearch_first(search_target=IGD_DEVICE)
if not resp:
return False
try:
device = await resp.get_device()
hass.data[DATA_UPNP] = device
for _service in device.services:
if _service['serviceType'] == PPP_SERVICE:
service = device.find_first_service(PPP_SERVICE)
if _service['serviceType'] == IP_SERVICE:
service = device.find_first_service(IP_SERVICE)
if _service['serviceType'] == IP_SERVICE2:
service = device.find_first_service(IP_SERVICE2)
if _service['serviceType'] == CIC_SERVICE:
unit = config[CONF_UNITS]
hass.async_create_task(discovery.async_load_platform(
hass, 'sensor', DOMAIN, {'unit': unit}, config))
except UpnpSoapError as error:
_LOGGER.error(error)
return False
if not service:
_LOGGER.warning("Could not find any UPnP IGD")
return False
port_mapping = config[CONF_ENABLE_PORT_MAPPING]
if not port_mapping:
return True
internal_port = hass.http.server_port
ports = config.get(CONF_PORTS)
if ports is None:
ports = {CONF_HASS: internal_port}
registered = []
for internal, external in ports.items():
if internal == CONF_HASS:
internal = internal_port
try:
await service.add_port_mapping(internal, external, host, 'TCP',
desc='Home Assistant')
registered.append(external)
_LOGGER.debug("Mapping external TCP port %s -> %s @ %s",
external, internal, host)
except UpnpSoapError as error:
_LOGGER.error(error)
hass.components.persistent_notification.create(
'<b>ERROR: tcp port {} is already mapped in your router.'
'</b><br />Please disable port_mapping in the <i>upnp</i> '
'configuration section.<br />'
'You will need to restart hass after fixing.'
''.format(external),
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID)
async def deregister_port(event):
"""De-register the UPnP port mapping."""
tasks = [service.delete_port_mapping(external, 'TCP')
for external in registered]
if tasks:
await asyncio.wait(tasks)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, deregister_port)
return True

View file

@ -0,0 +1,25 @@
{
"config": {
"title": "UPnP/IGD",
"step": {
"init": {
"title": "UPnP/IGD"
},
"user": {
"title": "Configuration options for the UPnP/IGD",
"data":{
"igd": "UPnP/IGD",
"enable_sensors": "Add traffic sensors",
"enable_port_mapping": "Enable port mapping for Home Assistant"
}
}
},
"error": {
},
"abort": {
"no_devices_discovered": "No UPnP/IGDs discovered",
"already_configured": "UPnP/IGD is already configured",
"no_sensors_or_port_mapping": "Enable at least sensors or port mapping"
}
}
}

View file

@ -0,0 +1,25 @@
{
"config": {
"title": "UPnP/IGD",
"step": {
"init": {
"title": "UPnP/IGD"
},
"user": {
"title": "Extra configuratie options voor UPnP/IGD",
"data":{
"igd": "UPnP/IGD",
"enable_sensors": "Verkeer sensors toevoegen",
"enable_port_mapping": "Maak port mapping voor Home Assistant"
}
}
},
"error": {
},
"abort": {
"no_devices_discovered": "Geen UPnP/IGDs gevonden",
"already_configured": "UPnP/IGD is reeds geconfigureerd",
"no_sensors_or_port_mapping": "Kies ten minste sensors of port mapping"
}
}
}

View file

@ -0,0 +1,169 @@
"""
Will open a port in your router for Home Assistant and provide statistics.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/upnp/
"""
import asyncio
from ipaddress import ip_address
import aiohttp
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import dispatcher
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.components.discovery import DOMAIN as DISCOVERY_DOMAIN
from .const import (
CONF_ENABLE_PORT_MAPPING, CONF_ENABLE_SENSORS,
CONF_HASS, CONF_LOCAL_IP, CONF_PORTS,
CONF_UDN, CONF_SSDP_DESCRIPTION,
SIGNAL_REMOVE_SENSOR,
)
from .const import DOMAIN
from .const import LOGGER as _LOGGER
from .config_flow import ensure_domain_data
from .device import Device
REQUIREMENTS = ['async-upnp-client==0.12.4']
DEPENDENCIES = ['http']
NOTIFICATION_ID = 'upnp_notification'
NOTIFICATION_TITLE = 'UPnP/IGD Setup'
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Optional(CONF_ENABLE_PORT_MAPPING, default=False): cv.boolean,
vol.Optional(CONF_ENABLE_SENSORS, default=True): cv.boolean,
vol.Optional(CONF_LOCAL_IP): vol.All(ip_address, cv.string),
vol.Optional(CONF_PORTS):
vol.Schema({
vol.Any(CONF_HASS, cv.positive_int):
vol.Any(CONF_HASS, cv.positive_int)
})
}),
}, extra=vol.ALLOW_EXTRA)
def _substitute_hass_ports(ports, hass_port):
"""Substitute 'hass' for the hass_port."""
ports = ports.copy()
# substitute 'hass' for hass_port, both keys and values
if CONF_HASS in ports:
ports[hass_port] = ports[CONF_HASS]
del ports[CONF_HASS]
for port in ports:
if ports[port] == CONF_HASS:
ports[port] = hass_port
return ports
# config
async def async_setup(hass: HomeAssistantType, config: ConfigType):
"""Register a port mapping for Home Assistant via UPnP."""
ensure_domain_data(hass)
# ensure sane config
if DOMAIN not in config:
return True
if DISCOVERY_DOMAIN not in config:
_LOGGER.warning('UPNP needs discovery, please enable it')
return False
# overridden local ip
upnp_config = config[DOMAIN]
if CONF_LOCAL_IP in upnp_config:
hass.data[DOMAIN]['local_ip'] = upnp_config[CONF_LOCAL_IP]
# determine ports
ports = {CONF_HASS: CONF_HASS} # default, port_mapping disabled by default
if CONF_PORTS in upnp_config:
# copy from config
ports = upnp_config[CONF_PORTS]
hass.data[DOMAIN]['auto_config'] = {
'active': True,
'enable_sensors': upnp_config[CONF_ENABLE_SENSORS],
'enable_port_mapping': upnp_config[CONF_ENABLE_PORT_MAPPING],
'ports': ports,
}
return True
# config flow
async def async_setup_entry(hass: HomeAssistantType,
config_entry: ConfigEntry):
"""Set up UPnP/IGD-device from a config entry."""
ensure_domain_data(hass)
data = config_entry.data
# build UPnP/IGD device
ssdp_description = data[CONF_SSDP_DESCRIPTION]
try:
device = await Device.async_create_device(hass, ssdp_description)
except (asyncio.TimeoutError, aiohttp.ClientError):
_LOGGER.error('Unable to create upnp-device')
return False
hass.data[DOMAIN]['devices'][device.udn] = device
# port mapping
if data.get(CONF_ENABLE_PORT_MAPPING):
local_ip = hass.data[DOMAIN].get('local_ip')
ports = hass.data[DOMAIN]['auto_config']['ports']
_LOGGER.debug('Enabling port mappings: %s', ports)
hass_port = hass.http.server_port
ports = _substitute_hass_ports(ports, hass_port)
await device.async_add_port_mappings(ports, local_ip=local_ip)
# sensors
if data.get(CONF_ENABLE_SENSORS):
_LOGGER.debug('Enabling sensors')
# register sensor setup handlers
hass.async_create_task(hass.config_entries.async_forward_entry_setup(
config_entry, 'sensor'))
async def unload_entry(event):
"""Unload entry on quit."""
await async_unload_entry(hass, config_entry)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unload_entry)
return True
async def async_unload_entry(hass: HomeAssistantType,
config_entry: ConfigEntry):
"""Unload a config entry."""
data = config_entry.data
udn = data[CONF_UDN]
if udn not in hass.data[DOMAIN]['devices']:
return True
device = hass.data[DOMAIN]['devices'][udn]
# port mapping
if data.get(CONF_ENABLE_PORT_MAPPING):
_LOGGER.debug('Deleting port mappings')
await device.async_delete_port_mappings()
# sensors
if data.get(CONF_ENABLE_SENSORS):
_LOGGER.debug('Deleting sensors')
dispatcher.async_dispatcher_send(hass, SIGNAL_REMOVE_SENSOR, device)
# clear stored device
del hass.data[DOMAIN]['devices'][udn]
return True

View file

@ -0,0 +1,160 @@
"""Config flow for UPNP."""
from collections import OrderedDict
import voluptuous as vol
from homeassistant import config_entries
from homeassistant import data_entry_flow
from .const import (
CONF_ENABLE_PORT_MAPPING, CONF_ENABLE_SENSORS,
CONF_SSDP_DESCRIPTION, CONF_UDN
)
from .const import DOMAIN
def ensure_domain_data(hass):
"""Ensure hass.data is filled properly."""
hass.data[DOMAIN] = hass.data.get(DOMAIN, {})
hass.data[DOMAIN]['devices'] = hass.data[DOMAIN].get('devices', {})
hass.data[DOMAIN]['discovered'] = hass.data[DOMAIN].get('discovered', {})
hass.data[DOMAIN]['auto_config'] = hass.data[DOMAIN].get('auto_config', {
'active': False,
'enable_sensors': False,
'enable_port_mapping': False,
'ports': {'hass': 'hass'},
})
@config_entries.HANDLERS.register(DOMAIN)
class UpnpFlowHandler(data_entry_flow.FlowHandler):
"""Handle a UPnP/IGD config flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
@property
def _configured_upnp_igds(self):
"""Get all configured IGDs."""
return {
entry.data[CONF_UDN]: {
'udn': entry.data[CONF_UDN],
}
for entry in self.hass.config_entries.async_entries(DOMAIN)
}
@property
def _discovered_upnp_igds(self):
"""Get all discovered entries."""
return self.hass.data[DOMAIN]['discovered']
def _store_discovery_info(self, discovery_info):
"""Add discovery info."""
udn = discovery_info['udn']
self.hass.data[DOMAIN]['discovered'][udn] = discovery_info
def _auto_config_settings(self):
"""Check if auto_config has been enabled."""
return self.hass.data[DOMAIN]['auto_config']
async def async_step_discovery(self, discovery_info):
"""
Handle a discovered UPnP/IGD.
This flow is triggered by the discovery component. It will check if the
host is already configured and delegate to the import step if not.
"""
ensure_domain_data(self.hass)
# store discovered device
discovery_info['friendly_name'] = \
'{} ({})'.format(discovery_info['host'], discovery_info['name'])
self._store_discovery_info(discovery_info)
# ensure not already discovered/configured
udn = discovery_info['udn']
if udn in self._configured_upnp_igds:
return self.async_abort(reason='already_configured')
# auto config?
auto_config = self._auto_config_settings()
if auto_config['active']:
import_info = {
'name': discovery_info['friendly_name'],
'enable_sensors': auto_config['enable_sensors'],
'enable_port_mapping': auto_config['enable_port_mapping'],
}
return await self._async_save_entry(import_info)
return await self.async_step_user()
async def async_step_user(self, user_input=None):
"""Manual set up."""
ensure_domain_data(self.hass)
# if user input given, handle it
user_input = user_input or {}
if 'name' in user_input:
if not user_input['enable_sensors'] and \
not user_input['enable_port_mapping']:
return self.async_abort(reason='no_sensors_or_port_mapping')
# ensure not already configured
configured_names = [
entry['friendly_name']
for udn, entry in self._discovered_upnp_igds.items()
if udn in self._configured_upnp_igds
]
if user_input['name'] in configured_names:
return self.async_abort(reason='already_configured')
return await self._async_save_entry(user_input)
# let user choose from all discovered, non-configured, UPnP/IGDs
names = [
entry['friendly_name']
for udn, entry in self._discovered_upnp_igds.items()
if udn not in self._configured_upnp_igds
]
if not names:
return self.async_abort(reason='no_devices_discovered')
return self.async_show_form(
step_id='user',
data_schema=vol.Schema(
OrderedDict([
(vol.Required('name'), vol.In(names)),
(vol.Optional('enable_sensors'), bool),
(vol.Optional('enable_port_mapping'), bool),
])
))
async def async_step_import(self, import_info):
"""Import a new UPnP/IGD as a config entry."""
ensure_domain_data(self.hass)
return await self._async_save_entry(import_info)
async def _async_save_entry(self, import_info):
"""Store UPNP/IGD as new entry."""
ensure_domain_data(self.hass)
# ensure we know the host
name = import_info['name']
discovery_infos = [info
for info in self._discovered_upnp_igds.values()
if info['friendly_name'] == name]
if not discovery_infos:
return self.async_abort(reason='host_not_found')
discovery_info = discovery_infos[0]
return self.async_create_entry(
title=discovery_info['name'],
data={
CONF_SSDP_DESCRIPTION: discovery_info['ssdp_description'],
CONF_UDN: discovery_info['udn'],
CONF_ENABLE_SENSORS: import_info['enable_sensors'],
CONF_ENABLE_PORT_MAPPING: import_info['enable_port_mapping'],
},
)

View file

@ -0,0 +1,14 @@
"""Constants for the IGD component."""
import logging
CONF_ENABLE_PORT_MAPPING = 'port_mapping'
CONF_ENABLE_SENSORS = 'sensors'
CONF_HASS = 'hass'
CONF_LOCAL_IP = 'local_ip'
CONF_PORTS = 'ports'
CONF_SSDP_DESCRIPTION = 'ssdp_description'
CONF_UDN = 'udn'
DOMAIN = 'upnp'
LOGGER = logging.getLogger('homeassistant.components.upnp')
SIGNAL_REMOVE_SENSOR = 'upnp_remove_sensor'

View file

@ -0,0 +1,131 @@
"""Hass representation of an UPnP/IGD."""
import asyncio
from ipaddress import IPv4Address
import aiohttp
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util import get_local_ip
from .const import LOGGER as _LOGGER
class Device:
"""Hass representation of an UPnP/IGD."""
def __init__(self, igd_device):
"""Initializer."""
self._igd_device = igd_device
self._mapped_ports = []
@classmethod
async def async_create_device(cls,
hass: HomeAssistantType,
ssdp_description: str):
"""Create UPnP/IGD device."""
# build async_upnp_client requester
from async_upnp_client.aiohttp import AiohttpSessionRequester
session = async_get_clientsession(hass)
requester = AiohttpSessionRequester(session, True)
# create async_upnp_client device
from async_upnp_client import UpnpFactory
factory = UpnpFactory(requester,
disable_state_variable_validation=True)
upnp_device = await factory.async_create_device(ssdp_description)
# wrap with async_upnp_client IgdDevice
from async_upnp_client.igd import IgdDevice
igd_device = IgdDevice(upnp_device, None)
return cls(igd_device)
@property
def udn(self):
"""Get the UDN."""
return self._igd_device.udn
@property
def name(self):
"""Get the name."""
return self._igd_device.name
async def async_add_port_mappings(self, ports, local_ip=None):
"""Add port mappings."""
# determine local ip, ensure sane IP
if local_ip is None:
local_ip = get_local_ip()
if local_ip == '127.0.0.1':
_LOGGER.error(
'Could not create port mapping, our IP is 127.0.0.1')
local_ip = IPv4Address(local_ip)
# create port mappings
for external_port, internal_port in ports.items():
await self._async_add_port_mapping(external_port,
local_ip,
internal_port)
self._mapped_ports.append(external_port)
async def _async_add_port_mapping(self,
external_port,
local_ip,
internal_port):
"""Add a port mapping."""
# create port mapping
from async_upnp_client import UpnpError
_LOGGER.info('Creating port mapping %s:%s:%s (TCP)',
external_port, local_ip, internal_port)
try:
await self._igd_device.async_add_port_mapping(
remote_host=None,
external_port=external_port,
protocol='TCP',
internal_port=internal_port,
internal_client=local_ip,
enabled=True,
description="Home Assistant",
lease_duration=None)
self._mapped_ports.append(external_port)
except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError):
_LOGGER.error('Could not add port mapping: %s:%s:%s',
external_port, local_ip, internal_port)
async def async_delete_port_mappings(self):
"""Remove a port mapping."""
for port in self._mapped_ports:
await self._async_delete_port_mapping(port)
async def _async_delete_port_mapping(self, external_port):
"""Remove a port mapping."""
from async_upnp_client import UpnpError
_LOGGER.info('Deleting port mapping %s (TCP)', external_port)
try:
await self._igd_device.async_delete_port_mapping(
remote_host=None,
external_port=external_port,
protocol='TCP')
self._mapped_ports.remove(external_port)
except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError):
_LOGGER.error('Could not delete port mapping')
async def async_get_total_bytes_received(self):
"""Get total bytes received."""
return await self._igd_device.async_get_total_bytes_received()
async def async_get_total_bytes_sent(self):
"""Get total bytes sent."""
return await self._igd_device.async_get_total_bytes_sent()
async def async_get_total_packets_received(self):
"""Get total packets received."""
# pylint: disable=invalid-name
return await self._igd_device.async_get_total_packets_received()
async def async_get_total_packets_sent(self):
"""Get total packets sent."""
return await self._igd_device.async_get_total_packets_sent()

View file

@ -0,0 +1,25 @@
{
"config": {
"title": "UPnP/IGD",
"step": {
"init": {
"title": "UPnP/IGD"
},
"user": {
"title": "Configuration options for the UPnP/IGD",
"data":{
"igd": "UPnP/IGD",
"enable_sensors": "Add traffic sensors",
"enable_port_mapping": "Enable port mapping for Home Assistant"
}
}
},
"error": {
},
"abort": {
"no_devices_discovered": "No UPnP/IGDs discovered",
"already_configured": "UPnP/IGD is already configured",
"no_sensors_or_port_mapping": "Enable at least sensors or port mapping"
}
}
}

View file

@ -149,6 +149,7 @@ FLOWS = [
'sonos',
'tradfri',
'zone',
'upnp',
]

View file

@ -145,6 +145,7 @@ aqualogic==1.0
# homeassistant.components.asterisk_mbox
asterisk_mbox==0.5.0
# homeassistant.components.upnp
# homeassistant.components.media_player.dlna_dmr
async-upnp-client==0.12.4
@ -1224,9 +1225,6 @@ pytrafikverket==0.1.5.8
# homeassistant.components.device_tracker.unifi
pyunifi==2.13
# homeassistant.components.upnp
pyupnp-async==0.1.1.1
# homeassistant.components.binary_sensor.uptimerobot
pyuptimerobot==0.0.5

View file

@ -190,9 +190,6 @@ pytradfri[async]==5.6.0
# homeassistant.components.device_tracker.unifi
pyunifi==2.13
# homeassistant.components.upnp
pyupnp-async==0.1.1.1
# homeassistant.components.notify.html5
pywebpush==1.6.0

View file

@ -1,183 +0,0 @@
"""Test the UPNP component."""
from collections import OrderedDict
from unittest.mock import patch, MagicMock
import pytest
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.setup import async_setup_component
from homeassistant.components.upnp import IP_SERVICE, DATA_UPNP
class MockService(MagicMock):
"""Mock upnp IP service."""
async def add_port_mapping(self, *args, **kwargs):
"""Original function."""
self.mock_add_port_mapping(*args, **kwargs)
async def delete_port_mapping(self, *args, **kwargs):
"""Original function."""
self.mock_delete_port_mapping(*args, **kwargs)
class MockDevice(MagicMock):
"""Mock upnp device."""
def find_first_service(self, *args, **kwargs):
"""Original function."""
self._service = MockService()
return self._service
def peep_first_service(self):
"""Access Mock first service."""
return self._service
class MockResp(MagicMock):
"""Mock upnp msearch response."""
async def get_device(self, *args, **kwargs):
"""Original function."""
device = MockDevice()
service = {'serviceType': IP_SERVICE}
device.services = [service]
return device
@pytest.fixture
def mock_msearch_first(*args, **kwargs):
"""Wrap async mock msearch_first."""
async def async_mock_msearch_first(*args, **kwargs):
"""Mock msearch_first."""
return MockResp(*args, **kwargs)
with patch('pyupnp_async.msearch_first', new=async_mock_msearch_first):
yield
@pytest.fixture
def mock_async_exception(*args, **kwargs):
"""Wrap async mock exception."""
async def async_mock_exception(*args, **kwargs):
return Exception
with patch('pyupnp_async.msearch_first', new=async_mock_exception):
yield
@pytest.fixture
def mock_local_ip():
"""Mock get_local_ip."""
with patch('homeassistant.components.upnp.get_local_ip',
return_value='192.168.0.10'):
yield
async def test_setup_fail_if_no_ip(hass):
"""Test setup fails if we can't find a local IP."""
with patch('homeassistant.components.upnp.get_local_ip',
return_value='127.0.0.1'):
result = await async_setup_component(hass, 'upnp', {
'upnp': {}
})
assert not result
async def test_setup_fail_if_cannot_select_igd(hass,
mock_local_ip,
mock_async_exception):
"""Test setup fails if we can't find an UPnP IGD."""
result = await async_setup_component(hass, 'upnp', {
'upnp': {}
})
assert not result
async def test_setup_succeeds_if_specify_ip(hass, mock_msearch_first):
"""Test setup succeeds if we specify IP and can't find a local IP."""
with patch('homeassistant.components.upnp.get_local_ip',
return_value='127.0.0.1'):
result = await async_setup_component(hass, 'upnp', {
'upnp': {
'local_ip': '192.168.0.10',
'port_mapping': 'True'
}
})
assert result
mock_service = hass.data[DATA_UPNP].peep_first_service()
assert len(mock_service.mock_add_port_mapping.mock_calls) == 1
mock_service.mock_add_port_mapping.assert_called_once_with(
8123, 8123, '192.168.0.10', 'TCP', desc='Home Assistant')
async def test_no_config_maps_hass_local_to_remote_port(hass,
mock_local_ip,
mock_msearch_first):
"""Test by default we map local to remote port."""
result = await async_setup_component(hass, 'upnp', {
'upnp': {
'port_mapping': 'True'
}
})
assert result
mock_service = hass.data[DATA_UPNP].peep_first_service()
assert len(mock_service.mock_add_port_mapping.mock_calls) == 1
mock_service.mock_add_port_mapping.assert_called_once_with(
8123, 8123, '192.168.0.10', 'TCP', desc='Home Assistant')
async def test_map_hass_to_remote_port(hass,
mock_local_ip,
mock_msearch_first):
"""Test mapping hass to remote port."""
result = await async_setup_component(hass, 'upnp', {
'upnp': {
'port_mapping': 'True',
'ports': {
'hass': 1000
}
}
})
assert result
mock_service = hass.data[DATA_UPNP].peep_first_service()
assert len(mock_service.mock_add_port_mapping.mock_calls) == 1
mock_service.mock_add_port_mapping.assert_called_once_with(
8123, 1000, '192.168.0.10', 'TCP', desc='Home Assistant')
async def test_map_internal_to_remote_ports(hass,
mock_local_ip,
mock_msearch_first):
"""Test mapping local to remote ports."""
ports = OrderedDict()
ports['hass'] = 1000
ports[1883] = 3883
result = await async_setup_component(hass, 'upnp', {
'upnp': {
'port_mapping': 'True',
'ports': ports
}
})
assert result
mock_service = hass.data[DATA_UPNP].peep_first_service()
assert len(mock_service.mock_add_port_mapping.mock_calls) == 2
mock_service.mock_add_port_mapping.assert_any_call(
8123, 1000, '192.168.0.10', 'TCP', desc='Home Assistant')
mock_service.mock_add_port_mapping.assert_any_call(
1883, 3883, '192.168.0.10', 'TCP', desc='Home Assistant')
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
assert len(mock_service.mock_delete_port_mapping.mock_calls) == 2
mock_service.mock_delete_port_mapping.assert_any_call(1000, 'TCP')
mock_service.mock_delete_port_mapping.assert_any_call(3883, 'TCP')

View file

@ -0,0 +1 @@
"""Tests for the IGD component."""

View file

@ -0,0 +1,240 @@
"""Tests for UPnP/IGD config flow."""
from homeassistant.components import upnp
from homeassistant.components.upnp import config_flow as upnp_config_flow
from tests.common import MockConfigEntry
async def test_flow_none_discovered(hass):
"""Test no device discovered flow."""
flow = upnp_config_flow.UpnpFlowHandler()
flow.hass = hass
hass.data[upnp.DOMAIN] = {
'discovered': {}
}
result = await flow.async_step_user()
assert result['type'] == 'abort'
assert result['reason'] == 'no_devices_discovered'
async def test_flow_already_configured(hass):
"""Test device already configured flow."""
flow = upnp_config_flow.UpnpFlowHandler()
flow.hass = hass
# discovered device
udn = 'uuid:device_1'
hass.data[upnp.DOMAIN] = {
'discovered': {
udn: {
'friendly_name': '192.168.1.1 (Test device)',
'host': '192.168.1.1',
'udn': udn,
},
},
}
# configured entry
MockConfigEntry(domain=upnp.DOMAIN, data={
'udn': udn,
'host': '192.168.1.1',
}).add_to_hass(hass)
result = await flow.async_step_user({
'name': '192.168.1.1 (Test device)',
'enable_sensors': True,
'enable_port_mapping': False,
})
assert result['type'] == 'abort'
assert result['reason'] == 'already_configured'
async def test_flow_no_sensors_no_port_mapping(hass):
"""Test single device, no sensors, no port_mapping."""
flow = upnp_config_flow.UpnpFlowHandler()
flow.hass = hass
# discovered device
udn = 'uuid:device_1'
hass.data[upnp.DOMAIN] = {
'discovered': {
udn: {
'friendly_name': '192.168.1.1 (Test device)',
'host': '192.168.1.1',
'udn': udn,
},
},
}
# configured entry
MockConfigEntry(domain=upnp.DOMAIN, data={
'udn': udn,
'host': '192.168.1.1',
}).add_to_hass(hass)
result = await flow.async_step_user({
'name': '192.168.1.1 (Test device)',
'enable_sensors': False,
'enable_port_mapping': False,
})
assert result['type'] == 'abort'
assert result['reason'] == 'no_sensors_or_port_mapping'
async def test_flow_discovered_form(hass):
"""Test single device discovered, show form flow."""
flow = upnp_config_flow.UpnpFlowHandler()
flow.hass = hass
# discovered device
udn = 'uuid:device_1'
hass.data[upnp.DOMAIN] = {
'discovered': {
udn: {
'friendly_name': '192.168.1.1 (Test device)',
'host': '192.168.1.1',
'udn': udn,
},
},
}
result = await flow.async_step_user()
assert result['type'] == 'form'
assert result['step_id'] == 'user'
async def test_flow_two_discovered_form(hass):
"""Test two devices discovered, show form flow with two devices."""
flow = upnp_config_flow.UpnpFlowHandler()
flow.hass = hass
# discovered device
udn_1 = 'uuid:device_1'
udn_2 = 'uuid:device_2'
hass.data[upnp.DOMAIN] = {
'discovered': {
udn_1: {
'friendly_name': '192.168.1.1 (Test device)',
'host': '192.168.1.1',
'udn': udn_1,
},
udn_2: {
'friendly_name': '192.168.2.1 (Test device)',
'host': '192.168.2.1',
'udn': udn_2,
},
},
}
result = await flow.async_step_user()
assert result['type'] == 'form'
assert result['step_id'] == 'user'
assert result['data_schema']({
'name': '192.168.1.1 (Test device)',
'enable_sensors': True,
'enable_port_mapping': False,
})
assert result['data_schema']({
'name': '192.168.2.1 (Test device)',
'enable_sensors': True,
'enable_port_mapping': False,
})
async def test_config_entry_created(hass):
"""Test config entry is created."""
flow = upnp_config_flow.UpnpFlowHandler()
flow.hass = hass
# discovered device
hass.data[upnp.DOMAIN] = {
'discovered': {
'uuid:device_1': {
'friendly_name': '192.168.1.1 (Test device)',
'name': 'Test device 1',
'host': '192.168.1.1',
'ssdp_description': 'http://192.168.1.1/desc.xml',
'udn': 'uuid:device_1',
},
},
}
result = await flow.async_step_user({
'name': '192.168.1.1 (Test device)',
'enable_sensors': True,
'enable_port_mapping': False,
})
assert result['type'] == 'create_entry'
assert result['data'] == {
'ssdp_description': 'http://192.168.1.1/desc.xml',
'udn': 'uuid:device_1',
'port_mapping': False,
'sensors': True,
}
assert result['title'] == 'Test device 1'
async def test_flow_discovery_auto_config_sensors(hass):
"""Test creation of device with auto_config."""
flow = upnp_config_flow.UpnpFlowHandler()
flow.hass = hass
# auto_config active
hass.data[upnp.DOMAIN] = {
'auto_config': {
'active': True,
'enable_port_mapping': False,
'enable_sensors': True,
},
}
# discovered device
result = await flow.async_step_discovery({
'name': 'Test device 1',
'host': '192.168.1.1',
'ssdp_description': 'http://192.168.1.1/desc.xml',
'udn': 'uuid:device_1',
})
assert result['type'] == 'create_entry'
assert result['data'] == {
'ssdp_description': 'http://192.168.1.1/desc.xml',
'udn': 'uuid:device_1',
'sensors': True,
'port_mapping': False,
}
assert result['title'] == 'Test device 1'
async def test_flow_discovery_auto_config_sensors_port_mapping(hass):
"""Test creation of device with auto_config, with port mapping."""
flow = upnp_config_flow.UpnpFlowHandler()
flow.hass = hass
# auto_config active, with port_mapping
hass.data[upnp.DOMAIN] = {
'auto_config': {
'active': True,
'enable_port_mapping': True,
'enable_sensors': True,
},
}
# discovered device
result = await flow.async_step_discovery({
'name': 'Test device 1',
'host': '192.168.1.1',
'ssdp_description': 'http://192.168.1.1/desc.xml',
'udn': 'uuid:device_1',
})
assert result['type'] == 'create_entry'
assert result['data'] == {
'udn': 'uuid:device_1',
'ssdp_description': 'http://192.168.1.1/desc.xml',
'sensors': True,
'port_mapping': True,
}
assert result['title'] == 'Test device 1'

View file

@ -0,0 +1,188 @@
"""Test UPnP/IGD setup process."""
from ipaddress import ip_address
from unittest.mock import patch, MagicMock
from homeassistant.setup import async_setup_component
from homeassistant.components import upnp
from homeassistant.components.upnp.device import Device
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from tests.common import MockConfigEntry
from tests.common import mock_coro
class MockDevice(Device):
"""Mock device for Device."""
def __init__(self, udn):
"""Initializer."""
super().__init__(None)
self._udn = udn
self.added_port_mappings = []
self.removed_port_mappings = []
@classmethod
async def async_create_device(cls, hass, ssdp_description):
"""Return self."""
return cls()
@property
def udn(self):
"""Get the UDN."""
return self._udn
async def _async_add_port_mapping(self,
external_port,
local_ip,
internal_port):
"""Add a port mapping."""
entry = [external_port, local_ip, internal_port]
self.added_port_mappings.append(entry)
async def _async_delete_port_mapping(self, external_port):
"""Remove a port mapping."""
entry = external_port
self.removed_port_mappings.append(entry)
async def test_async_setup_no_auto_config(hass):
"""Test async_setup."""
# setup component, enable auto_config
await async_setup_component(hass, 'upnp')
assert hass.data[upnp.DOMAIN]['auto_config'] == {
'active': False,
'enable_sensors': False,
'enable_port_mapping': False,
'ports': {'hass': 'hass'},
}
async def test_async_setup_auto_config(hass):
"""Test async_setup."""
# setup component, enable auto_config
await async_setup_component(hass, 'upnp', {'upnp': {}, 'discovery': {}})
assert hass.data[upnp.DOMAIN]['auto_config'] == {
'active': True,
'enable_sensors': True,
'enable_port_mapping': False,
'ports': {'hass': 'hass'},
}
async def test_async_setup_auto_config_port_mapping(hass):
"""Test async_setup."""
# setup component, enable auto_config
await async_setup_component(hass, 'upnp', {
'upnp': {
'port_mapping': True,
'ports': {'hass': 'hass'},
},
'discovery': {}})
assert hass.data[upnp.DOMAIN]['auto_config'] == {
'active': True,
'enable_sensors': True,
'enable_port_mapping': True,
'ports': {'hass': 'hass'},
}
async def test_async_setup_auto_config_no_sensors(hass):
"""Test async_setup."""
# setup component, enable auto_config
await async_setup_component(hass, 'upnp', {
'upnp': {'sensors': False},
'discovery': {}})
assert hass.data[upnp.DOMAIN]['auto_config'] == {
'active': True,
'enable_sensors': False,
'enable_port_mapping': False,
'ports': {'hass': 'hass'},
}
async def test_async_setup_entry_default(hass):
"""Test async_setup_entry."""
udn = 'uuid:device_1'
entry = MockConfigEntry(domain=upnp.DOMAIN, data={
'ssdp_description': 'http://192.168.1.1/desc.xml',
'udn': udn,
'sensors': True,
'port_mapping': False,
})
# ensure hass.http is available
await async_setup_component(hass, 'upnp')
# mock homeassistant.components.upnp.device.Device
mock_device = MagicMock()
mock_device.udn = udn
mock_device.async_add_port_mappings.return_value = mock_coro()
mock_device.async_delete_port_mappings.return_value = mock_coro()
with patch.object(Device, 'async_create_device') as mock_create_device:
mock_create_device.return_value = mock_coro(
return_value=mock_device)
with patch('homeassistant.components.upnp.device.get_local_ip',
return_value='192.168.1.10'):
assert await upnp.async_setup_entry(hass, entry) is True
# ensure device is stored/used
assert hass.data[upnp.DOMAIN]['devices'][udn] == mock_device
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
# ensure cleaned up
assert udn not in hass.data[upnp.DOMAIN]['devices']
# ensure no port-mapping-methods called
assert len(mock_device.async_add_port_mappings.mock_calls) == 0
assert len(mock_device.async_delete_port_mappings.mock_calls) == 0
async def test_async_setup_entry_port_mapping(hass):
"""Test async_setup_entry."""
udn = 'uuid:device_1'
entry = MockConfigEntry(domain=upnp.DOMAIN, data={
'ssdp_description': 'http://192.168.1.1/desc.xml',
'udn': udn,
'sensors': False,
'port_mapping': True,
})
# ensure hass.http is available
await async_setup_component(hass, 'upnp', {
'upnp': {
'port_mapping': True,
'ports': {'hass': 'hass'},
},
'discovery': {},
})
mock_device = MockDevice(udn)
with patch.object(Device, 'async_create_device') as mock_create_device:
mock_create_device.return_value = mock_coro(return_value=mock_device)
with patch('homeassistant.components.upnp.device.get_local_ip',
return_value='192.168.1.10'):
assert await upnp.async_setup_entry(hass, entry) is True
# ensure device is stored/used
assert hass.data[upnp.DOMAIN]['devices'][udn] == mock_device
# ensure add-port-mapping-methods called
assert mock_device.added_port_mappings == [
[8123, ip_address('192.168.1.10'), 8123]
]
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
# ensure cleaned up
assert udn not in hass.data[upnp.DOMAIN]['devices']
# ensure delete-port-mapping-methods called
assert mock_device.removed_port_mappings == [8123]