mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
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:
commit
cf0147098a
@ -695,6 +695,7 @@ omit =
|
|||||||
homeassistant/components/sensor/haveibeenpwned.py
|
homeassistant/components/sensor/haveibeenpwned.py
|
||||||
homeassistant/components/sensor/hp_ilo.py
|
homeassistant/components/sensor/hp_ilo.py
|
||||||
homeassistant/components/sensor/htu21d.py
|
homeassistant/components/sensor/htu21d.py
|
||||||
|
homeassistant/components/sensor/upnp.py
|
||||||
homeassistant/components/sensor/imap_email_content.py
|
homeassistant/components/sensor/imap_email_content.py
|
||||||
homeassistant/components/sensor/imap.py
|
homeassistant/components/sensor/imap.py
|
||||||
homeassistant/components/sensor/influxdb.py
|
homeassistant/components/sensor/influxdb.py
|
||||||
@ -781,7 +782,6 @@ omit =
|
|||||||
homeassistant/components/sensor/travisci.py
|
homeassistant/components/sensor/travisci.py
|
||||||
homeassistant/components/sensor/twitch.py
|
homeassistant/components/sensor/twitch.py
|
||||||
homeassistant/components/sensor/uber.py
|
homeassistant/components/sensor/uber.py
|
||||||
homeassistant/components/sensor/upnp.py
|
|
||||||
homeassistant/components/sensor/ups.py
|
homeassistant/components/sensor/ups.py
|
||||||
homeassistant/components/sensor/uscis.py
|
homeassistant/components/sensor/uscis.py
|
||||||
homeassistant/components/sensor/vasttrafik.py
|
homeassistant/components/sensor/vasttrafik.py
|
||||||
|
@ -82,7 +82,6 @@ homeassistant/components/sensor/sma.py @kellerza
|
|||||||
homeassistant/components/sensor/sql.py @dgomes
|
homeassistant/components/sensor/sql.py @dgomes
|
||||||
homeassistant/components/sensor/sytadin.py @gautric
|
homeassistant/components/sensor/sytadin.py @gautric
|
||||||
homeassistant/components/sensor/tibber.py @danielhiversen
|
homeassistant/components/sensor/tibber.py @danielhiversen
|
||||||
homeassistant/components/sensor/upnp.py @dgomes
|
|
||||||
homeassistant/components/sensor/waqi.py @andrey-git
|
homeassistant/components/sensor/waqi.py @andrey-git
|
||||||
homeassistant/components/switch/tplink.py @rytilahti
|
homeassistant/components/switch/tplink.py @rytilahti
|
||||||
homeassistant/components/vacuum/roomba.py @pschmitt
|
homeassistant/components/vacuum/roomba.py @pschmitt
|
||||||
|
@ -50,6 +50,7 @@ CONFIG_ENTRY_HANDLERS = {
|
|||||||
SERVICE_HUE: 'hue',
|
SERVICE_HUE: 'hue',
|
||||||
SERVICE_IKEA_TRADFRI: 'tradfri',
|
SERVICE_IKEA_TRADFRI: 'tradfri',
|
||||||
'sonos': 'sonos',
|
'sonos': 'sonos',
|
||||||
|
'igd': 'upnp',
|
||||||
}
|
}
|
||||||
|
|
||||||
SERVICE_HANDLERS = {
|
SERVICE_HANDLERS = {
|
||||||
|
@ -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
|
For more details about this platform, please refer to the documentation at
|
||||||
https://home-assistant.io/components/sensor.upnp/
|
https://home-assistant.io/components/sensor.upnp/
|
||||||
"""
|
"""
|
||||||
|
from datetime import datetime
|
||||||
import logging
|
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.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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEPENDENCIES = ['upnp']
|
DEPENDENCIES = ['upnp']
|
||||||
|
|
||||||
BYTES_RECEIVED = 1
|
BYTES_RECEIVED = 'bytes_received'
|
||||||
BYTES_SENT = 2
|
BYTES_SENT = 'bytes_sent'
|
||||||
PACKETS_RECEIVED = 3
|
PACKETS_RECEIVED = 'packets_received'
|
||||||
PACKETS_SENT = 4
|
PACKETS_SENT = 'packets_sent'
|
||||||
|
|
||||||
# sensor_type: [friendly_name, convert_unit, icon]
|
|
||||||
SENSOR_TYPES = {
|
SENSOR_TYPES = {
|
||||||
BYTES_RECEIVED: ['received bytes', True, 'mdi:server-network'],
|
BYTES_RECEIVED: {
|
||||||
BYTES_SENT: ['sent bytes', True, 'mdi:server-network'],
|
'name': 'bytes received',
|
||||||
PACKETS_RECEIVED: ['packets received', False, 'mdi:server-network'],
|
'unit': 'bytes',
|
||||||
PACKETS_SENT: ['packets sent', False, 'mdi:server-network'],
|
},
|
||||||
|
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,
|
async def async_setup_platform(hass, config, async_add_entities,
|
||||||
discovery_info=None):
|
discovery_info=None):
|
||||||
"""Set up the IGD sensors."""
|
"""Old way of setting up UPnP/IGD sensors."""
|
||||||
if discovery_info is None:
|
_LOGGER.debug('async_setup_platform: config: %s, discovery: %s',
|
||||||
return
|
config, discovery_info)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class IGDSensor(Entity):
|
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||||
"""Representation of a UPnP IGD sensor."""
|
"""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):
|
data = config_entry.data
|
||||||
"""Initialize the IGD sensor."""
|
udn = data['udn']
|
||||||
self._service = service
|
device = hass.data[DATA_UPNP]['devices'][udn]
|
||||||
self.type = sensor_type
|
async_add_sensor(device)
|
||||||
self.unit = unit
|
|
||||||
self.unit_factor = UNITS[unit] if unit in UNITS else 1
|
|
||||||
self._name = 'IGD {}'.format(SENSOR_TYPES[sensor_type][0])
|
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
|
self._state = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self) -> str:
|
||||||
"""Return the name of the sensor."""
|
"""Return the name of the sensor."""
|
||||||
return self._name
|
return self._name
|
||||||
|
|
||||||
@property
|
@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."""
|
"""Return the state of the device."""
|
||||||
if self._state:
|
return format(self._state, 'd')
|
||||||
return format(float(self._state) / self.unit_factor, '.1f')
|
|
||||||
return self._state
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def icon(self):
|
def icon(self) -> str:
|
||||||
"""Icon to use in the frontend, if any."""
|
"""Icon to use in the frontend, if any."""
|
||||||
return SENSOR_TYPES[self.type][2]
|
return 'mdi:server-network'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def unit_of_measurement(self):
|
def unit_of_measurement(self) -> str:
|
||||||
"""Return the unit of measurement of this entity, if any."""
|
"""Return the unit of measurement of this entity, if any."""
|
||||||
return self.unit
|
return self._type['unit']
|
||||||
|
|
||||||
async def async_update(self):
|
async def async_update(self):
|
||||||
"""Get the latest information from the IGD."""
|
"""Get the latest information from the IGD."""
|
||||||
if self.type == BYTES_RECEIVED:
|
if self._type_name == BYTES_RECEIVED:
|
||||||
self._state = await self._service.get_total_bytes_received()
|
self._state = await self._device.async_get_total_bytes_received()
|
||||||
elif self.type == BYTES_SENT:
|
elif self._type_name == BYTES_SENT:
|
||||||
self._state = await self._service.get_total_bytes_sent()
|
self._state = await self._device.async_get_total_bytes_sent()
|
||||||
elif self.type == PACKETS_RECEIVED:
|
elif self._type_name == PACKETS_RECEIVED:
|
||||||
self._state = await self._service.get_total_packets_received()
|
self._state = await self._device.async_get_total_packets_received()
|
||||||
elif self.type == PACKETS_SENT:
|
elif self._type_name == PACKETS_SENT:
|
||||||
self._state = await self._service.get_total_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')
|
||||||
|
@ -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
|
|
25
homeassistant/components/upnp/.translations/en.json
Normal file
25
homeassistant/components/upnp/.translations/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
25
homeassistant/components/upnp/.translations/nl.json
Normal file
25
homeassistant/components/upnp/.translations/nl.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
169
homeassistant/components/upnp/__init__.py
Normal file
169
homeassistant/components/upnp/__init__.py
Normal 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
|
160
homeassistant/components/upnp/config_flow.py
Normal file
160
homeassistant/components/upnp/config_flow.py
Normal 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'],
|
||||||
|
},
|
||||||
|
)
|
14
homeassistant/components/upnp/const.py
Normal file
14
homeassistant/components/upnp/const.py
Normal 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'
|
131
homeassistant/components/upnp/device.py
Normal file
131
homeassistant/components/upnp/device.py
Normal 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()
|
25
homeassistant/components/upnp/strings.json
Normal file
25
homeassistant/components/upnp/strings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -149,6 +149,7 @@ FLOWS = [
|
|||||||
'sonos',
|
'sonos',
|
||||||
'tradfri',
|
'tradfri',
|
||||||
'zone',
|
'zone',
|
||||||
|
'upnp',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -145,6 +145,7 @@ aqualogic==1.0
|
|||||||
# homeassistant.components.asterisk_mbox
|
# homeassistant.components.asterisk_mbox
|
||||||
asterisk_mbox==0.5.0
|
asterisk_mbox==0.5.0
|
||||||
|
|
||||||
|
# homeassistant.components.upnp
|
||||||
# homeassistant.components.media_player.dlna_dmr
|
# homeassistant.components.media_player.dlna_dmr
|
||||||
async-upnp-client==0.12.4
|
async-upnp-client==0.12.4
|
||||||
|
|
||||||
@ -1224,9 +1225,6 @@ pytrafikverket==0.1.5.8
|
|||||||
# homeassistant.components.device_tracker.unifi
|
# homeassistant.components.device_tracker.unifi
|
||||||
pyunifi==2.13
|
pyunifi==2.13
|
||||||
|
|
||||||
# homeassistant.components.upnp
|
|
||||||
pyupnp-async==0.1.1.1
|
|
||||||
|
|
||||||
# homeassistant.components.binary_sensor.uptimerobot
|
# homeassistant.components.binary_sensor.uptimerobot
|
||||||
pyuptimerobot==0.0.5
|
pyuptimerobot==0.0.5
|
||||||
|
|
||||||
|
@ -190,9 +190,6 @@ pytradfri[async]==5.6.0
|
|||||||
# homeassistant.components.device_tracker.unifi
|
# homeassistant.components.device_tracker.unifi
|
||||||
pyunifi==2.13
|
pyunifi==2.13
|
||||||
|
|
||||||
# homeassistant.components.upnp
|
|
||||||
pyupnp-async==0.1.1.1
|
|
||||||
|
|
||||||
# homeassistant.components.notify.html5
|
# homeassistant.components.notify.html5
|
||||||
pywebpush==1.6.0
|
pywebpush==1.6.0
|
||||||
|
|
||||||
|
@ -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')
|
|
1
tests/components/upnp/__init__.py
Normal file
1
tests/components/upnp/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for the IGD component."""
|
240
tests/components/upnp/test_config_flow.py
Normal file
240
tests/components/upnp/test_config_flow.py
Normal 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'
|
188
tests/components/upnp/test_init.py
Normal file
188
tests/components/upnp/test_init.py
Normal 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]
|
Loading…
x
Reference in New Issue
Block a user